from __future__ import annotations import json import os import re import threading import traceback from typing import Dict from app.models.llm import load_model TARGET_CPL = 20.0 _IM_END = "<|im_end|>" _STOP_SEQUENCES = [_IM_END, "<|im_start|>", ""] _infer_lock = threading.Lock() _SYSTEM = ( "You are a Google Ads analyst. " "Reply with 3 to 5 markdown bullet points only. " "Each bullet must be one short, actionable insight about the campaign data. " "No introduction, no numbered lists, no step-by-step reasoning." ) def fallback_explanation(rec: Dict | None = None) -> str: return "This recommendation was generated from campaign performance metrics." def _strip_thinking(text: str) -> str: text = re.sub(r"<\s*think\s*>.*?<\s*/\s*think\s*>", "", text, flags=re.DOTALL | re.IGNORECASE) text = re.sub( r".*?", "", text, flags=re.DOTALL | re.IGNORECASE, ) return text.strip() def _looks_like_garbage(text: str) -> bool: if not text: return True lower = text.lower() if "return only" in lower or "no reasoning" in lower or "no explanation" in lower: return True if "google ads analyst" in lower and text.count("-") < 2: return True if "ads performance analyst" in lower and text.count("-") < 2: return True if re.search(r"(?:\d[\s\n]+){6,}", text): return True digit_ratio = sum(ch.isdigit() for ch in text) / max(len(text), 1) return digit_ratio > 0.22 def is_fallback_output(text: str) -> bool: return ( not text or text.startswith("⚠️") or text.startswith("This recommendation was generated") ) def is_bad_llm_output(text: str) -> bool: return is_fallback_output(text) or _looks_like_garbage(text) def sanitize_explanation(text: str, rec: Dict | None = None) -> str: text = _strip_thinking(text) lines = [ln.strip() for ln in text.splitlines() if ln.strip()] bullets: list[str] = [] for ln in lines: if re.match(r"^[-•*]\s+\S", ln): bullets.append(ln) elif re.match(r"^\d+\.\s+\S", ln): bullets.append(re.sub(r"^\d+\.\s+", "- ", ln)) if len(bullets) >= 2: return "\n\n".join(bullets[:5]) flat = re.sub(r"[ \t]+", " ", text).strip() if len(flat) < 20 or _looks_like_garbage(flat): return fallback_explanation(rec) return flat def _messages_to_prompt(messages: list[dict[str, str]]) -> str: chunks: list[str] = [] for msg in messages: role = msg["role"] content = msg["content"] chunks.append(f"<|im_start|>{role}\n{content}{_IM_END}\n") chunks.append("<|im_start|>assistant\n") return "".join(chunks) def _message_text(message: dict) -> str: content = (message.get("content") or "").strip() reasoning = (message.get("reasoning_content") or "").strip() if content and reasoning and _looks_like_garbage(content): return reasoning return content or reasoning def _infer(llm, messages: list[dict[str, str]]) -> str: max_tokens = int(os.getenv("LLAMA_MAX_TOKENS", "384")) temperature = float(os.getenv("LLAMA_TEMPERATURE", "0.35")) try: out = llm.create_chat_completion( messages=messages, max_tokens=max_tokens, temperature=temperature, ) raw = _message_text(out["choices"][0]["message"]) if raw and not _looks_like_garbage(raw): print("✅ [generate_explanation] via create_chat_completion", flush=True) return raw print("⚠️ [generate_explanation] chat_completion empty/garbage — raw fallback", flush=True) except TypeError as exc: print(f"⚠️ [generate_explanation] chat_completion failed: {exc}", flush=True) out = llm( _messages_to_prompt(messages), max_tokens=max_tokens, temperature=temperature, stop=_STOP_SEQUENCES, echo=False, ) return (out["choices"][0].get("text") or "").strip() def _coerce_prompt(prompt: str | Dict, rec: Dict | None) -> tuple[str, Dict | None]: if isinstance(prompt, dict): rec = rec or prompt reason = prompt.get("reason") if reason: return str(reason).strip(), rec return json.dumps(prompt, default=str), rec return str(prompt).strip(), rec def generate_explanation(prompt: str | Dict, rec: Dict | None = None, stream: bool = False): print("\n🔥 [generate_explanation] CALLED", flush=True) try: user_content, rec = _coerce_prompt(prompt, rec) print( f"🧾 [generate_explanation] prompt type={type(prompt).__name__} " f"len={len(user_content)}", flush=True, ) if "/no_think" not in user_content: user_content = f"{user_content}\n/no_think" messages = [ {"role": "system", "content": _SYSTEM}, {"role": "user", "content": user_content}, ] with _infer_lock: llm = load_model() print("🧠 [generate_explanation] model loaded", flush=True) print("🚀 [generate_explanation] calling LLM...", flush=True) raw = _infer(llm, messages) print("📡 [generate_explanation] response received", flush=True) print("📄 [generate_explanation] raw output length:", len(raw), flush=True) if raw: print("📄 [generate_explanation] raw preview:", raw[:400], flush=True) clean = sanitize_explanation(raw, rec) if is_bad_llm_output(clean): clean = fallback_explanation(rec) print("✨ [generate_explanation] cleaned output ready", flush=True) if stream: return iter([clean]) return clean except Exception as e: print("❌ [generate_explanation] ERROR:", repr(e), flush=True) traceback.print_exc() err = f"⚠️ Analysis failed: {e}" if stream: return iter([err]) return err