Spaces:
Runtime error
Runtime error
| """CS#1 "what worked" creative analysis (SPEC_NEW §16.4). | |
| `analyze_winners(days=14)` is a Claude skill over the last N days of | |
| `metrics_daily` joined to the creative attributes that drive learning — the ad | |
| ANGLE (`videos.tags->>'angle'`), the startframe AUDIENCE (`avatars.audience`), | |
| and per-ad performance (`publishes` → `metrics_daily`). It determines WHAT | |
| WORKED: which audience performed, which angles won, and which startframe traits | |
| correlate with purchases / low CPA. It writes ONE `creative_insights` row | |
| `{period_days, winning_audience, winning_angles[], winning_startframe_traits, | |
| evidence}` (the owner's original `cs1_analyses` intent) and returns it with | |
| uuids stringified. | |
| The output feeds the strategist (§7.5) AND the audience→startframe selector | |
| (§16.5). It reuses the §7.5 EXPLOIT data guard: a creative needs ≥ 2,000 | |
| impressions to count as PROVEN. Claude (`INSIGHTS_SYSTEM`) decides the winners | |
| when `ANTHROPIC_API_KEY` is set — parsed defensively, retried once with the | |
| error appended; otherwise a deterministic fallback ranks the proven creatives by | |
| purchases then CPA (so dry-run / tests work with no key). An empty DB still | |
| writes a cold-start row. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from psycopg.types.json import Json | |
| from .config import get_settings | |
| # The §7.5 EXPLOIT data guard, restated here so the SQL pre-filter, the prompt, | |
| # and the fallback all agree on what "proven" means (≥ 2,000 impressions). | |
| MIN_PROVEN_IMPRESSIONS = 2000 | |
| INSIGHTS_SYSTEM = f"""You analyse what is WORKING in an automated Swedish UGC \ | |
| video-ad account, so the pipeline can make more of it. You see per-creative \ | |
| performance for the last N days, aggregated by ad ANGLE and by the startframe's \ | |
| target AUDIENCE (gender, age, appearance, setting, vibe — free text, no fixed \ | |
| buckets). Each row carries impressions, spend, purchases, revenue, CPA, the 3s \ | |
| hook rate, and whether it is PROVEN. | |
| Your job is to name what worked: | |
| - DATA GUARD. A creative only counts as PROVEN once it has at least \ | |
| {MIN_PROVEN_IMPRESSIONS} impressions. Never call something a winner on thinner \ | |
| data — the rows already flag this as `proven`; reason only from proven rows when \ | |
| deciding winners (mention unproven ones only as "too early to tell"). | |
| - WINNING AUDIENCE. From the proven rows, decide which AUDIENCE performed best \ | |
| (most purchases at the lowest CPA). Describe it free-form as an object with the \ | |
| same shape as the audience metadata you were given (gender, age, appearance, \ | |
| setting, vibe) — the single audience the data says to lean into. | |
| - WINNING ANGLES. List the ad angles that won, best first. | |
| - WINNING STARTFRAME TRAITS. The startframe/audience traits (look, setting, \ | |
| vibe, age, gender) that correlate with purchases and low CPA — the levers to \ | |
| reuse when generating the next startframes. | |
| - EVIDENCE. A short snapshot of the ranked proven creatives your call rests on. | |
| Output ONLY this JSON object — no prose, no markdown fences: | |
| {{"winning_audience": {{...}} | null, "winning_angles": ["string", ...], \ | |
| "winning_startframe_traits": {{...}} | null, "evidence": {{...}} | [...]}}""" | |
| def _aggregate_winner_stats(days: int) -> list[dict]: | |
| """Aggregate metrics_daily (last `days`) per creative attribute — angle | |
| (videos.tags->>'angle') + the startframe AUDIENCE — joining publishes → | |
| videos and LEFT JOIN avatars for the startframe's audience (SPEC_NEW §16.4). | |
| The feature space is creative levers ONLY: this SQL reads videos.tags and | |
| avatars.audience, never videos.seed (§12.4). Each row carries the §7.5 guard | |
| verdict (`proven`, ≥ 2000 impressions) so the prompt and the fallback agree | |
| on what counts. | |
| """ | |
| from . import db | |
| rows = db.fetch_all( | |
| """ | |
| select | |
| v.tags->>'angle' as angle, | |
| a.id as avatar_id, | |
| a.name as avatar_name, | |
| a.audience as audience, | |
| a.selection_text as selection_text, | |
| coalesce(sum(m.impressions), 0) as impressions, | |
| coalesce(sum(m.spend), 0) as spend, | |
| coalesce(sum(m.video_3s), 0) as video_3s, | |
| coalesce(sum(m.purchases), 0) as purchases, | |
| coalesce(sum(m.revenue), 0) as revenue | |
| from metrics_daily m | |
| join publishes p on p.ad_id = m.ad_id | |
| join videos v on v.id = p.video_id | |
| left join avatars a on a.id = v.avatar_id | |
| where m.date >= (current_date - %s::int) | |
| group by v.tags->>'angle', a.id, a.name, a.audience, a.selection_text | |
| order by purchases desc, revenue desc | |
| """, | |
| (days,), | |
| ) | |
| stats: list[dict] = [] | |
| for r in rows: | |
| impressions = int(r["impressions"] or 0) | |
| spend = float(r["spend"] or 0.0) | |
| video_3s = int(r["video_3s"] or 0) | |
| purchases = int(r["purchases"] or 0) | |
| revenue = float(r["revenue"] or 0.0) | |
| cpa = round(spend / purchases, 2) if purchases else None | |
| three_s_rate = round(video_3s / impressions, 4) if impressions else None | |
| # avatars.audience reads back as a python dict (jsonb); {} for legacy/no avatar. | |
| audience = r["audience"] if isinstance(r["audience"], dict) else {} | |
| stats.append({ | |
| "angle": r["angle"], | |
| "avatar_id": str(r["avatar_id"]) if r["avatar_id"] else None, | |
| "avatar_name": r["avatar_name"], | |
| "audience": audience, | |
| "selection_text": r["selection_text"], | |
| "impressions": impressions, | |
| "spend": round(spend, 2), | |
| "video_3s": video_3s, | |
| "purchases": purchases, | |
| "revenue": round(revenue, 2), | |
| "cpa": cpa, | |
| "three_s_rate": three_s_rate, | |
| "proven": impressions >= MIN_PROVEN_IMPRESSIONS, | |
| }) | |
| return stats | |
| def _audience_summary(audience: dict | None) -> str: | |
| """One-line human summary of an audience object (for traits text / evidence).""" | |
| if not audience: | |
| return "" | |
| parts = [str(audience[k]) for k in ("gender", "age", "appearance", "setting", "vibe") | |
| if audience.get(k)] | |
| return ", ".join(parts) | |
| def _fallback_insight(stats: list[dict]) -> dict: | |
| """Deterministic CS#1 for no-key / dry-run (SPEC_NEW §16.4). | |
| Rank the PROVEN creatives (≥ 2000 impressions) by purchases then lowest CPA; | |
| the best one's audience is the winning audience, its angle leads the winning | |
| angles, and its audience traits become the winning startframe traits. The | |
| evidence is the ranked proven snapshot. Cold start (no proven creatives) | |
| yields empty winners with a cold_start evidence note — never a crash. | |
| """ | |
| proven = [s for s in stats if s.get("proven")] | |
| # Most purchases first, then lowest CPA (None CPA sorts last). | |
| proven.sort( | |
| key=lambda s: ( | |
| -int(s.get("purchases") or 0), | |
| s.get("cpa") if s.get("cpa") is not None else float("inf"), | |
| ) | |
| ) | |
| if not proven: | |
| return { | |
| "winning_audience": None, | |
| "winning_angles": [], | |
| "winning_startframe_traits": None, | |
| "evidence": { | |
| "cold_start": True, | |
| "proven_creatives": 0, | |
| "note": ( | |
| f"No creative has cleared the {MIN_PROVEN_IMPRESSIONS}-impression " | |
| "guard yet, so there is nothing proven to learn from." | |
| ), | |
| "ranked": [], | |
| }, | |
| } | |
| best = proven[0] | |
| # Winning angles: distinct proven angles ordered by total purchases (best first). | |
| by_angle: dict[str, int] = {} | |
| for s in proven: | |
| angle = (s.get("angle") or "").strip() | |
| if angle: | |
| by_angle[angle] = by_angle.get(angle, 0) + int(s.get("purchases") or 0) | |
| winning_angles = [a for a, _ in sorted(by_angle.items(), key=lambda kv: kv[1], reverse=True)] | |
| best_audience = best.get("audience") or {} | |
| traits = dict(best_audience) if best_audience else {} | |
| if traits: | |
| traits["summary"] = _audience_summary(best_audience) | |
| return { | |
| "winning_audience": best_audience or { | |
| "summary": f"Best creative: {best.get('avatar_name') or 'unknown avatar'}" | |
| }, | |
| "winning_angles": winning_angles, | |
| "winning_startframe_traits": traits or None, | |
| "evidence": { | |
| "cold_start": False, | |
| "proven_creatives": len(proven), | |
| "best": { | |
| "angle": best.get("angle"), | |
| "avatar_name": best.get("avatar_name"), | |
| "audience": best_audience, | |
| "purchases": best.get("purchases"), | |
| "cpa": best.get("cpa"), | |
| "impressions": best.get("impressions"), | |
| }, | |
| "ranked": proven, | |
| }, | |
| } | |
| def _parse_insight_json(raw: str) -> dict: | |
| """Defensive parse of the insights JSON (first '{' to last '}'). Returns the | |
| normalised insight dict; raises ValueError on anything malformed (SPEC_NEW §16.4).""" | |
| s = raw.strip() | |
| a, b = s.find("{"), s.rfind("}") | |
| if a == -1 or b == -1: | |
| raise ValueError("no JSON object in insights output") | |
| obj = json.loads(s[a:b + 1]) | |
| if not isinstance(obj, dict): | |
| raise ValueError("insights JSON is not an object") | |
| angles_raw = obj.get("winning_angles") | |
| if angles_raw is None: | |
| angles_raw = [] | |
| if not isinstance(angles_raw, list): | |
| raise ValueError("winning_angles must be a list") | |
| winning_angles = [str(x).strip() for x in angles_raw if str(x).strip()] | |
| audience = obj.get("winning_audience") | |
| if audience is not None and not isinstance(audience, (dict, str)): | |
| raise ValueError("winning_audience must be an object, string, or null") | |
| traits = obj.get("winning_startframe_traits") | |
| if traits is not None and not isinstance(traits, (dict, str, list)): | |
| raise ValueError("winning_startframe_traits must be an object, string, list, or null") | |
| evidence = obj.get("evidence") | |
| if evidence is not None and not isinstance(evidence, (dict, list)): | |
| raise ValueError("evidence must be an object, list, or null") | |
| return { | |
| "winning_audience": audience, | |
| "winning_angles": winning_angles, | |
| "winning_startframe_traits": traits, | |
| "evidence": evidence, | |
| } | |
| def _build_user_prompt(stats: list[dict], days: int) -> str: | |
| """The user message for the Claude call: the period and the per-creative stats | |
| (proven flag included). JSON so the model reads exact numbers.""" | |
| return ( | |
| f"Period: last {days} days. Per-creative performance, aggregated by angle " | |
| f"and startframe audience (a creative is `proven` once it clears " | |
| f"{MIN_PROVEN_IMPRESSIONS} impressions):\n\n" | |
| f"{json.dumps(stats, ensure_ascii=False, default=str)}\n\n" | |
| "Decide the winning audience, the winning angles, the winning startframe " | |
| "traits, and the evidence. Reply with ONLY the JSON object." | |
| ) | |
| def analyze_winners(days: int = 14) -> dict: | |
| """Run CS#1 "what worked" analysis and insert ONE creative_insights row (SPEC_NEW §16.4). | |
| Aggregates the last `days` of metrics_daily per creative attribute (angle + | |
| startframe audience), reusing the §7.5 EXPLOIT guard (≥ 2000 impressions = | |
| PROVEN). Claude (INSIGHTS_SYSTEM) names the winning_audience, winning_angles, | |
| winning_startframe_traits, and evidence when ANTHROPIC_API_KEY is set — | |
| parsed defensively, retried ONCE with the error appended; otherwise a | |
| deterministic fallback ranks the proven creatives by purchases then CPA (so | |
| dry-run / tests work with no key). An empty DB still inserts a cold-start row. | |
| Returns the inserted creative_insights row (id stringified). | |
| """ | |
| from . import db | |
| days = int(days) | |
| if days < 1: | |
| raise ValueError("analyze_winners: days must be >= 1") | |
| s = get_settings() | |
| stats = _aggregate_winner_stats(days) | |
| insight: dict | None = None | |
| if s.anthropic_api_key: | |
| import anthropic | |
| client = anthropic.Anthropic(api_key=s.anthropic_api_key) | |
| user = _build_user_prompt(stats, days) | |
| last_err: str | None = None | |
| for attempt in range(2): | |
| content = user if attempt == 0 else ( | |
| f"{user}\n\nYour previous reply could not be used ({last_err}). " | |
| f"Reply with ONLY valid JSON in the required format." | |
| ) | |
| resp = client.messages.create( | |
| model=s.anthropic_model, max_tokens=2048, | |
| system=INSIGHTS_SYSTEM, messages=[{"role": "user", "content": content}], | |
| ) | |
| text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") | |
| try: | |
| insight = _parse_insight_json(text) | |
| except Exception as exc: # noqa: BLE001 — retry once, then fall back | |
| last_err = str(exc) | |
| continue | |
| break | |
| if insight is None: # Claude failed twice → deterministic fallback (still works) | |
| insight = _fallback_insight(stats) | |
| else: | |
| insight = _fallback_insight(stats) | |
| # Persist exactly ONE creative_insights row; the four learning surfaces are | |
| # jsonb (via Json(...)). evidence defaults to the raw stats snapshot. | |
| with db.connect() as conn: | |
| row = conn.execute( | |
| """ | |
| insert into creative_insights | |
| (period_days, winning_audience, winning_angles, | |
| winning_startframe_traits, evidence) | |
| values (%s, %s, %s, %s, %s) | |
| returning id, period_days, winning_audience, winning_angles, | |
| winning_startframe_traits, evidence, created_at | |
| """, | |
| ( | |
| days, | |
| Json(insight.get("winning_audience")), | |
| Json(insight.get("winning_angles") or []), | |
| Json(insight.get("winning_startframe_traits")), | |
| Json(insight.get("evidence") if insight.get("evidence") is not None else stats), | |
| ), | |
| ).fetchone() | |
| d = dict(row) | |
| d["id"] = str(d["id"]) | |
| return d | |