llm corrected
Browse files- app/ads1/ads_analyst.py +19 -14
- app/ads1/keyword_inspector.py +56 -12
- app/recs/generate.py +40 -35
app/ads1/ads_analyst.py
CHANGED
|
@@ -145,54 +145,59 @@ def rule_based_insights(context: dict) -> str:
|
|
| 145 |
bullets: list[str] = []
|
| 146 |
|
| 147 |
if c["leads"] == 0:
|
| 148 |
-
bullets.append("- No conversions recorded
|
| 149 |
elif c["cpl"] > TARGET_CPL:
|
| 150 |
bullets.append(
|
| 151 |
-
f"- CPL is ${c['cpl']:.2f}, above the ${TARGET_CPL:.0f} target
|
| 152 |
)
|
| 153 |
else:
|
| 154 |
-
bullets.append(f"- CPL is ${c['cpl']:.2f} with {c['leads']} leads
|
| 155 |
|
| 156 |
if trend["spend_change_pct"] > 10 and trend["clicks_change_pct"] < trend["spend_change_pct"]:
|
| 157 |
-
bullets.append("- Spend is rising faster than clicks
|
| 158 |
|
| 159 |
if signals["wasted_spend_ratio"] > 0.2:
|
| 160 |
bullets.append(
|
| 161 |
-
f"- About {signals['wasted_spend_ratio'] * 100:.0f}% of keywords have zero conversions
|
| 162 |
)
|
| 163 |
|
| 164 |
for row in drivers.get("best_keywords", [])[:1]:
|
| 165 |
bullets.append(
|
| 166 |
-
f"- '{row['keyword']}' is a top performer ({row['conversions']} conversions)
|
| 167 |
)
|
| 168 |
|
| 169 |
for row in drivers.get("worst_keywords", [])[:1]:
|
| 170 |
if row.get("conversions", 0) == 0:
|
| 171 |
-
bullets.append(f"- '{row['keyword']}' spent without converting
|
| 172 |
|
| 173 |
if len(bullets) < 3:
|
| 174 |
-
bullets.append(f"- CTR is {c['ctr']:.2f}% across {c['impressions']:,} impressions
|
| 175 |
|
| 176 |
return "\n\n".join(bullets[:5])
|
| 177 |
|
| 178 |
|
| 179 |
def run_ads_analyst_card(dfs: dict, campaign_name: str | None = None) -> str:
|
| 180 |
-
print("\
|
| 181 |
|
| 182 |
if not dfs:
|
| 183 |
-
return "
|
| 184 |
|
| 185 |
scoped = _dfs_for_campaign(dfs, campaign_name)
|
| 186 |
context = build_ads_analyst_context(scoped, campaign_name)
|
| 187 |
-
print("
|
| 188 |
|
| 189 |
prompt = build_ads_analyst_prompt(context)
|
| 190 |
-
print("
|
| 191 |
|
| 192 |
result = generate_explanation(prompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
if is_bad_llm_output(result):
|
| 194 |
-
print("
|
| 195 |
result = rule_based_insights(context)
|
| 196 |
|
| 197 |
-
print("\
|
| 198 |
return result
|
|
|
|
| 145 |
bullets: list[str] = []
|
| 146 |
|
| 147 |
if c["leads"] == 0:
|
| 148 |
+
bullets.append("- No conversions recorded - review targeting, landing page, and conversion tracking.")
|
| 149 |
elif c["cpl"] > TARGET_CPL:
|
| 150 |
bullets.append(
|
| 151 |
+
f"- CPL is ${c['cpl']:.2f}, above the ${TARGET_CPL:.0f} target - tighten bids on expensive terms."
|
| 152 |
)
|
| 153 |
else:
|
| 154 |
+
bullets.append(f"- CPL is ${c['cpl']:.2f} with {c['leads']} leads - performance is within a workable range.")
|
| 155 |
|
| 156 |
if trend["spend_change_pct"] > 10 and trend["clicks_change_pct"] < trend["spend_change_pct"]:
|
| 157 |
+
bullets.append("- Spend is rising faster than clicks - efficiency is slipping; audit keyword bids.")
|
| 158 |
|
| 159 |
if signals["wasted_spend_ratio"] > 0.2:
|
| 160 |
bullets.append(
|
| 161 |
+
f"- About {signals['wasted_spend_ratio'] * 100:.0f}% of keywords have zero conversions - pause or cut budget there."
|
| 162 |
)
|
| 163 |
|
| 164 |
for row in drivers.get("best_keywords", [])[:1]:
|
| 165 |
bullets.append(
|
| 166 |
+
f"- '{row['keyword']}' is a top performer ({row['conversions']} conversions) - consider scaling budget."
|
| 167 |
)
|
| 168 |
|
| 169 |
for row in drivers.get("worst_keywords", [])[:1]:
|
| 170 |
if row.get("conversions", 0) == 0:
|
| 171 |
+
bullets.append(f"- '{row['keyword']}' spent without converting - reduce bids or pause.")
|
| 172 |
|
| 173 |
if len(bullets) < 3:
|
| 174 |
+
bullets.append(f"- CTR is {c['ctr']:.2f}% across {c['impressions']:,} impressions - test stronger ad copy if CTR is low.")
|
| 175 |
|
| 176 |
return "\n\n".join(bullets[:5])
|
| 177 |
|
| 178 |
|
| 179 |
def run_ads_analyst_card(dfs: dict, campaign_name: str | None = None) -> str:
|
| 180 |
+
print("\nSTART [analyst_card] STARTED", flush=True)
|
| 181 |
|
| 182 |
if not dfs:
|
| 183 |
+
return "WARNING: No campaign data - select a campaign on the Dashboard tab first."
|
| 184 |
|
| 185 |
scoped = _dfs_for_campaign(dfs, campaign_name)
|
| 186 |
context = build_ads_analyst_context(scoped, campaign_name)
|
| 187 |
+
print("CONTEXT [analyst_card] context built", flush=True)
|
| 188 |
|
| 189 |
prompt = build_ads_analyst_prompt(context)
|
| 190 |
+
print("PROMPT [analyst_card] prompt built", flush=True)
|
| 191 |
|
| 192 |
result = generate_explanation(prompt)
|
| 193 |
+
print(f"[analyst_card] raw/clean LLM result preview: {str(result)[:400]}", flush=True)
|
| 194 |
+
if "Analysis failed:" in str(result):
|
| 195 |
+
print("[analyst_card] LLM call failed; returning the backend error instead of hiding it.", flush=True)
|
| 196 |
+
return result
|
| 197 |
+
|
| 198 |
if is_bad_llm_output(result):
|
| 199 |
+
print("WARNING [analyst_card] LLM fallback - using rule-based insights", flush=True)
|
| 200 |
result = rule_based_insights(context)
|
| 201 |
|
| 202 |
+
print("\nRESULT [analyst_card] LLM result received", flush=True)
|
| 203 |
return result
|
app/ads1/keyword_inspector.py
CHANGED
|
@@ -22,6 +22,15 @@ def build_keyword_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 22 |
return df
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
# -------------------------
|
| 26 |
# Prompt (simplified + stronger reasoning)
|
| 27 |
# -------------------------
|
|
@@ -29,14 +38,40 @@ def build_keyword_prompt(context: dict) -> str:
|
|
| 29 |
payload = json.dumps(context, indent=2, default=str)
|
| 30 |
|
| 31 |
return keyword_inspector_prompt(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# -------------------------
|
| 33 |
# Main runner
|
| 34 |
# -------------------------
|
| 35 |
def run_keyword_inspector(dfs: dict, campaign_name: str | None = None) -> str:
|
| 36 |
-
print("\
|
| 37 |
|
| 38 |
if not dfs or "keywords" not in dfs:
|
| 39 |
-
return "
|
| 40 |
|
| 41 |
df = dfs["keywords"].copy()
|
| 42 |
df = build_keyword_features(df)
|
|
@@ -45,24 +80,33 @@ def run_keyword_inspector(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 45 |
if campaign_name and "campaign_name" in df.columns:
|
| 46 |
df = df[df["campaign_name"] == campaign_name]
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
context = {
|
| 49 |
"campaign_name": campaign_name,
|
| 50 |
-
"keywords": df.to_dict("records")
|
| 51 |
}
|
| 52 |
|
| 53 |
-
print("
|
| 54 |
|
| 55 |
prompt = build_keyword_prompt(context)
|
| 56 |
-
print("
|
| 57 |
|
| 58 |
result = generate_explanation(prompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
if is_bad_llm_output(result):
|
| 61 |
-
print("
|
| 62 |
-
return (
|
| 63 |
-
"- Unable to generate LLM insights right now.\n"
|
| 64 |
-
"- Check keyword data quality or retry."
|
| 65 |
-
)
|
| 66 |
|
| 67 |
-
print("
|
| 68 |
return result
|
|
|
|
| 22 |
return df
|
| 23 |
|
| 24 |
|
| 25 |
+
def prepare_keyword_context_df(df: pd.DataFrame, max_rows: int = 20) -> pd.DataFrame:
|
| 26 |
+
winners = df[df["conversions"] > 0].sort_values(["conversions", "cpa"], ascending=[False, True]).head(10)
|
| 27 |
+
waste = df[df["conversions"] == 0].sort_values("cost", ascending=False).head(10)
|
| 28 |
+
out = pd.concat([waste, winners], ignore_index=True)
|
| 29 |
+
if "keyword" in out.columns:
|
| 30 |
+
out = out.drop_duplicates(subset=["keyword"])
|
| 31 |
+
return out.head(max_rows)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
# -------------------------
|
| 35 |
# Prompt (simplified + stronger reasoning)
|
| 36 |
# -------------------------
|
|
|
|
| 38 |
payload = json.dumps(context, indent=2, default=str)
|
| 39 |
|
| 40 |
return keyword_inspector_prompt(payload)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def rule_based_keyword_actions(context: dict) -> str:
|
| 44 |
+
rows = context.get("keywords", [])
|
| 45 |
+
if not rows:
|
| 46 |
+
return "- No keyword action found because no usable keyword rows were available."
|
| 47 |
+
|
| 48 |
+
bullets = []
|
| 49 |
+
for row in rows[:5]:
|
| 50 |
+
keyword = row.get("keyword", "this keyword")
|
| 51 |
+
cost = row.get("cost", 0)
|
| 52 |
+
clicks = row.get("clicks", 0)
|
| 53 |
+
conversions = row.get("conversions", 0)
|
| 54 |
+
ctr = row.get("ctr", 0)
|
| 55 |
+
cpa = row.get("cpa", 0)
|
| 56 |
+
if conversions > 0:
|
| 57 |
+
bullets.append(
|
| 58 |
+
f"- Treat '{keyword}' as a winning or scaling keyword because it produced {conversions} conversions at CPA {cpa:.2f} and CTR {ctr:.2f}%."
|
| 59 |
+
)
|
| 60 |
+
else:
|
| 61 |
+
bullets.append(
|
| 62 |
+
f"- Reduce or pause '{keyword}' because it spent {cost:.2f} across {clicks} clicks with 0 conversions."
|
| 63 |
+
)
|
| 64 |
+
return "\n\n".join(bullets)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
# -------------------------
|
| 68 |
# Main runner
|
| 69 |
# -------------------------
|
| 70 |
def run_keyword_inspector(dfs: dict, campaign_name: str | None = None) -> str:
|
| 71 |
+
print("\nSTART [keyword_inspector] STARTED", flush=True)
|
| 72 |
|
| 73 |
if not dfs or "keywords" not in dfs:
|
| 74 |
+
return "WARNING: No keyword data available."
|
| 75 |
|
| 76 |
df = dfs["keywords"].copy()
|
| 77 |
df = build_keyword_features(df)
|
|
|
|
| 80 |
if campaign_name and "campaign_name" in df.columns:
|
| 81 |
df = df[df["campaign_name"] == campaign_name]
|
| 82 |
|
| 83 |
+
df = prepare_keyword_context_df(df)
|
| 84 |
+
keep_cols = [
|
| 85 |
+
col
|
| 86 |
+
for col in ["keyword", "cost", "clicks", "impressions", "conversions", "ctr", "cpa"]
|
| 87 |
+
if col in df.columns
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
context = {
|
| 91 |
"campaign_name": campaign_name,
|
| 92 |
+
"keywords": df[keep_cols].round(2).to_dict("records")
|
| 93 |
}
|
| 94 |
|
| 95 |
+
print("CONTEXT [keyword_inspector] context built", flush=True)
|
| 96 |
|
| 97 |
prompt = build_keyword_prompt(context)
|
| 98 |
+
print("PROMPT [keyword_inspector] prompt built", flush=True)
|
| 99 |
|
| 100 |
result = generate_explanation(prompt)
|
| 101 |
+
print(f"[keyword_inspector] raw/clean LLM result preview: {str(result)[:400]}", flush=True)
|
| 102 |
+
if "Analysis failed:" in str(result):
|
| 103 |
+
print("[keyword_inspector] LLM call failed; returning the backend error instead of hiding it.", flush=True)
|
| 104 |
+
return result
|
| 105 |
+
|
| 106 |
|
| 107 |
+
if is_bad_llm_output(result) or not result.strip().startswith("-"):
|
| 108 |
+
print("WARNING [keyword_inspector] LLM fallback triggered", flush=True)
|
| 109 |
+
return rule_based_keyword_actions(context)
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
+
print("RESULT [keyword_inspector] result received", flush=True)
|
| 112 |
return result
|
app/recs/generate.py
CHANGED
|
@@ -57,9 +57,11 @@ def _looks_like_garbage(text: str) -> bool:
|
|
| 57 |
|
| 58 |
|
| 59 |
def is_fallback_output(text: str) -> bool:
|
|
|
|
| 60 |
return (
|
| 61 |
not text
|
| 62 |
-
or
|
|
|
|
| 63 |
or text.startswith("This recommendation was generated")
|
| 64 |
)
|
| 65 |
|
|
@@ -74,7 +76,7 @@ def sanitize_explanation(text: str, rec: Dict | None = None) -> str:
|
|
| 74 |
|
| 75 |
bullets: list[str] = []
|
| 76 |
for ln in lines:
|
| 77 |
-
if re.match(r"^[-
|
| 78 |
bullets.append(ln)
|
| 79 |
elif re.match(r"^\d+\.\s+\S", ln):
|
| 80 |
bullets.append(re.sub(r"^\d+\.\s+", "- ", ln))
|
|
@@ -89,14 +91,13 @@ def sanitize_explanation(text: str, rec: Dict | None = None) -> str:
|
|
| 89 |
|
| 90 |
|
| 91 |
def _messages_to_prompt(messages: list[dict[str, str]]) -> str:
|
| 92 |
-
|
| 93 |
-
for msg in messages
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
|
| 101 |
def _message_text(message: dict) -> str:
|
| 102 |
content = (message.get("content") or "").strip()
|
|
@@ -109,21 +110,24 @@ def _message_text(message: dict) -> str:
|
|
| 109 |
def _infer(llm, messages: list[dict[str, str]]) -> str:
|
| 110 |
max_tokens = int(os.getenv("LLAMA_MAX_TOKENS", "384"))
|
| 111 |
temperature = float(os.getenv("LLAMA_TEMPERATURE", "0.35"))
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
| 127 |
out = llm(
|
| 128 |
_messages_to_prompt(messages),
|
| 129 |
max_tokens=max_tokens,
|
|
@@ -145,12 +149,12 @@ def _coerce_prompt(prompt: str | Dict, rec: Dict | None) -> tuple[str, Dict | No
|
|
| 145 |
|
| 146 |
|
| 147 |
def generate_explanation(prompt: str | Dict, rec: Dict | None = None, stream: bool = False):
|
| 148 |
-
print("\
|
| 149 |
|
| 150 |
try:
|
| 151 |
user_content, rec = _coerce_prompt(prompt, rec)
|
| 152 |
print(
|
| 153 |
-
f"
|
| 154 |
f"len={len(user_content)}",
|
| 155 |
flush=True,
|
| 156 |
)
|
|
@@ -164,27 +168,28 @@ def generate_explanation(prompt: str | Dict, rec: Dict | None = None, stream: bo
|
|
| 164 |
|
| 165 |
with _infer_lock:
|
| 166 |
llm = load_model()
|
| 167 |
-
print("
|
| 168 |
-
print("
|
| 169 |
raw = _infer(llm, messages)
|
| 170 |
|
| 171 |
-
print("
|
| 172 |
-
print("
|
| 173 |
if raw:
|
| 174 |
-
print("
|
| 175 |
|
| 176 |
clean = sanitize_explanation(raw, rec)
|
| 177 |
if is_bad_llm_output(clean):
|
| 178 |
clean = fallback_explanation(rec)
|
| 179 |
-
print("
|
| 180 |
if stream:
|
| 181 |
return iter([clean])
|
| 182 |
return clean
|
| 183 |
|
| 184 |
except Exception as e:
|
| 185 |
-
print("
|
| 186 |
traceback.print_exc()
|
| 187 |
-
err = f"
|
| 188 |
if stream:
|
| 189 |
return iter([err])
|
| 190 |
return err
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def is_fallback_output(text: str) -> bool:
|
| 60 |
+
lower = (text or "").lower()
|
| 61 |
return (
|
| 62 |
not text
|
| 63 |
+
or lower.startswith("warning:")
|
| 64 |
+
or "analysis failed:" in lower
|
| 65 |
or text.startswith("This recommendation was generated")
|
| 66 |
)
|
| 67 |
|
|
|
|
| 76 |
|
| 77 |
bullets: list[str] = []
|
| 78 |
for ln in lines:
|
| 79 |
+
if re.match(r"^[-*]\s+\S", ln):
|
| 80 |
bullets.append(ln)
|
| 81 |
elif re.match(r"^\d+\.\s+\S", ln):
|
| 82 |
bullets.append(re.sub(r"^\d+\.\s+", "- ", ln))
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
def _messages_to_prompt(messages: list[dict[str, str]]) -> str:
|
| 94 |
+
system = next((msg["content"] for msg in messages if msg["role"] == "system"), "")
|
| 95 |
+
user = next((msg["content"] for msg in messages if msg["role"] == "user"), "")
|
| 96 |
+
return (
|
| 97 |
+
f"{system}\n\n"
|
| 98 |
+
f"Request:\n{user}\n\n"
|
| 99 |
+
"Answer with only the final bullet points:\n"
|
| 100 |
+
)
|
|
|
|
| 101 |
|
| 102 |
def _message_text(message: dict) -> str:
|
| 103 |
content = (message.get("content") or "").strip()
|
|
|
|
| 110 |
def _infer(llm, messages: list[dict[str, str]]) -> str:
|
| 111 |
max_tokens = int(os.getenv("LLAMA_MAX_TOKENS", "384"))
|
| 112 |
temperature = float(os.getenv("LLAMA_TEMPERATURE", "0.35"))
|
| 113 |
+
use_chat_completion = os.getenv("LLAMA_USE_CHAT_COMPLETION", "0") == "1"
|
| 114 |
+
|
| 115 |
+
if use_chat_completion:
|
| 116 |
+
try:
|
| 117 |
+
out = llm.create_chat_completion(
|
| 118 |
+
messages=messages,
|
| 119 |
+
max_tokens=max_tokens,
|
| 120 |
+
temperature=temperature,
|
| 121 |
+
)
|
| 122 |
+
raw = _message_text(out["choices"][0]["message"])
|
| 123 |
+
if raw and not _looks_like_garbage(raw):
|
| 124 |
+
print("OK [generate_explanation] via create_chat_completion", flush=True)
|
| 125 |
+
return raw
|
| 126 |
+
print("WARNING [generate_explanation] chat_completion empty/garbage - raw fallback", flush=True)
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
print(f"WARNING [generate_explanation] chat_completion failed - raw fallback: {repr(exc)}", flush=True)
|
| 129 |
+
|
| 130 |
+
print("LLM [generate_explanation] using raw llama prompt", flush=True)
|
| 131 |
out = llm(
|
| 132 |
_messages_to_prompt(messages),
|
| 133 |
max_tokens=max_tokens,
|
|
|
|
| 149 |
|
| 150 |
|
| 151 |
def generate_explanation(prompt: str | Dict, rec: Dict | None = None, stream: bool = False):
|
| 152 |
+
print("\nLLM [generate_explanation] CALLED", flush=True)
|
| 153 |
|
| 154 |
try:
|
| 155 |
user_content, rec = _coerce_prompt(prompt, rec)
|
| 156 |
print(
|
| 157 |
+
f"PROMPT [generate_explanation] prompt type={type(prompt).__name__} "
|
| 158 |
f"len={len(user_content)}",
|
| 159 |
flush=True,
|
| 160 |
)
|
|
|
|
| 168 |
|
| 169 |
with _infer_lock:
|
| 170 |
llm = load_model()
|
| 171 |
+
print("MODEL [generate_explanation] model loaded", flush=True)
|
| 172 |
+
print("LLM [generate_explanation] calling LLM...", flush=True)
|
| 173 |
raw = _infer(llm, messages)
|
| 174 |
|
| 175 |
+
print("LLM [generate_explanation] response received", flush=True)
|
| 176 |
+
print("RAW [generate_explanation] raw output length:", len(raw), flush=True)
|
| 177 |
if raw:
|
| 178 |
+
print("RAW [generate_explanation] raw preview:", raw[:400], flush=True)
|
| 179 |
|
| 180 |
clean = sanitize_explanation(raw, rec)
|
| 181 |
if is_bad_llm_output(clean):
|
| 182 |
clean = fallback_explanation(rec)
|
| 183 |
+
print("OK [generate_explanation] cleaned output ready", flush=True)
|
| 184 |
if stream:
|
| 185 |
return iter([clean])
|
| 186 |
return clean
|
| 187 |
|
| 188 |
except Exception as e:
|
| 189 |
+
print("ERROR [generate_explanation] ERROR:", repr(e), flush=True)
|
| 190 |
traceback.print_exc()
|
| 191 |
+
err = f"WARNING: Analysis failed: {e}"
|
| 192 |
if stream:
|
| 193 |
return iter([err])
|
| 194 |
return err
|
| 195 |
+
|