Spaces:
Runtime error
Runtime error
| """Phase 9 β audience-matched startframe selection (SPEC_NEW Β§16.5). | |
| `select_startframe(audience_target, product_id, script_context)` picks the best | |
| startframe (an `avatars` row, Β§16.1) for a product given a free-form audience | |
| description (e.g. the Β§16.4 winning audience: "kvinnor 50+, intresse hΓ€lsa"). | |
| The contract (Β§16.5): | |
| - **SQL pre-filter on `product_id` ONLY** β the creative anchor. NO age/gender | |
| buckets: the owner decided Claude reasons about age itself. Retired startframes | |
| (`status = 'retired'`) are excluded; everything else (incl. NULL status) is a | |
| candidate. | |
| - **Claude ranks** ALL candidates by how well their `audience` / `selection_text` | |
| fit `audience_target` + `script_context` β it decides which age/look wins β and | |
| returns the best `startframe_id`, a `rationale`, and an **adapted prompt** | |
| (that startframe's `prompt_template` tailored to the script/variation). The JSON | |
| is parsed defensively and the call retries ONCE with the parse error appended. | |
| - **Deterministic fallback** (no ANTHROPIC_API_KEY): score every candidate by | |
| case-insensitive token overlap between the stringified `audience_target` and the | |
| candidate's `selection_text` + `audience` values; pick the highest score, | |
| tie-break on the most-recent `created_at`. The prompt is the chosen | |
| `prompt_template` unchanged; the rationale is "matched on: <overlapping tokens>". | |
| This makes audience matching demonstrable WITHOUT Claude (DRY_RUN-testable). | |
| Seed-orthogonality holds (Β§12.4): the startframe / audience are creative levers in | |
| `videos.tags`, never the render seed. This module never sees a seed. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from typing import Any | |
| from .config import get_settings | |
| # ββ Audience β startframe ranking system prompt (Β§16.5) ββββββββββββββββββββββ | |
| CASTING_SYSTEM = """You cast the best STARTFRAME (a UGC avatar in a locked scene) \ | |
| for a Swedish video ad, given a target AUDIENCE and the SCRIPT it will speak. | |
| You are given a list of candidate startframes for ONE product. Each candidate has \ | |
| an id, a one-line `selection_text` descriptor, and a structured `audience` object \ | |
| (gender, age as FREE text, appearance, setting, vibe, ...). There are NO fixed age \ | |
| buckets β YOU decide which age and look best fit the target audience by reasoning \ | |
| over the descriptors. A "kvinnor 50+" audience should pick a candidate who reads as \ | |
| an older woman; a younger-skewing audience should pick a younger one, and so on. | |
| Pick the SINGLE best-fitting candidate. Then ADAPT that candidate's \ | |
| `prompt_template` (its scene-locking prompt) to THIS specific script/variation β \ | |
| keep the person, scene, framing and identity intact, only tailor the wording so it \ | |
| fits what the avatar is about to say. Do not invent a new scene. | |
| Output ONLY this JSON object β no prose, no markdown fences: | |
| {"startframe_id": "the chosen candidate id, exactly as given", | |
| "prompt": "the adapted scene-locking prompt for this script", | |
| "rationale": "one short sentence on why this startframe fits the audience"}""" | |
| # ββ Token helpers (deterministic fallback, Β§16.5) ββββββββββββββββββββββββββββ | |
| # Swedish-friendly tokenizer: letters/digits incl. Γ₯ Γ€ ΓΆ; everything else splits. | |
| _TOKEN_RE = re.compile(r"[0-9a-zΓ₯Àâ]+", re.IGNORECASE) | |
| # Tiny stopword set so generic glue words don't fake an "overlap" match. | |
| _STOPWORDS = frozenset( | |
| {"och", "i", "med", "som", "fΓΆr", "av", "en", "ett", "den", "det", "pΓ₯", "till", | |
| "ca", "the", "a", "an", "of", "and", "with"} | |
| ) | |
| def _tokens(value: Any) -> set[str]: | |
| """Lowercased content tokens from any value (str / dict / list), stopwords removed.""" | |
| return {t for t in _TOKEN_RE.findall(_stringify(value).lower()) if t not in _STOPWORDS} | |
| def _stringify(value: Any) -> str: | |
| """Flatten audience_target / audience metadata to a single searchable string. | |
| A dict/list is rendered via its values (keys like "gender" are not content).""" | |
| if value is None: | |
| return "" | |
| if isinstance(value, str): | |
| return value | |
| if isinstance(value, dict): | |
| return " ".join(_stringify(v) for v in value.values()) | |
| if isinstance(value, (list, tuple, set)): | |
| return " ".join(_stringify(v) for v in value) | |
| return str(value) | |
| def _candidate_text(cand: dict) -> str: | |
| """The matchable text for a candidate: its selection_text + audience values.""" | |
| return f"{cand.get('selection_text') or ''} {_stringify(cand.get('audience'))}" | |
| # Stem length for soft token overlap so Swedish inflections of the same word match | |
| # (e.g. "kvinnor" β "kvinna" share the 5-char stem "kvinn"). Short tokens (gender | |
| # words, ages) must still match exactly, so the stem is only applied to longer tokens. | |
| _STEM_LEN = 4 | |
| _NUM_RE = re.compile(r"^\d+$") | |
| def _stem(token: str) -> str: | |
| return token[:_STEM_LEN] if len(token) > _STEM_LEN else token | |
| def _ages(tokens: set[str]) -> set[int]: | |
| return {int(t) for t in tokens if _NUM_RE.match(t)} | |
| def _score(cand: dict, target_tokens: set[str]) -> tuple[float, set[str]]: | |
| """Audience-fit score for one candidate (case-insensitive TOKEN OVERLAP, Β§16.5). | |
| A shared word counts when its stems match (so "kvinnor"β"kvinna" overlap). On | |
| top of the overlap count we add a small age-proximity bonus: a target age (e.g. | |
| "ΓΆver 50") pulls toward the nearest candidate age ("ca 55") rather than a distant | |
| one ("ca 28"), without ever outweighing a real word match. Returns (score, the | |
| overlapping target tokens) for the "matched on:" rationale.""" | |
| cand_tokens = _tokens(_candidate_text(cand)) | |
| cand_stems = {_stem(t) for t in cand_tokens} | |
| overlap = {t for t in target_tokens if _stem(t) in cand_stems} | |
| score = float(len(overlap)) | |
| target_ages, cand_ages = _ages(target_tokens), _ages(cand_tokens) | |
| if target_ages and cand_ages: | |
| nearest = min(abs(ta - ca) for ta in target_ages for ca in cand_ages) | |
| # < 1.0 so it only ever breaks ties between equal word-overlap candidates. | |
| score += max(0.0, 1.0 - nearest / 100.0) | |
| return score, overlap | |
| def _fallback_select(candidates: list[dict], audience_target: Any) -> dict: | |
| """Deterministic audience match (no Claude): highest token-overlap score wins, | |
| tie-break on most-recent created_at. Candidates arrive pre-sorted created_at DESC, | |
| so the first-seen candidate keeps a tie (Β§16.5).""" | |
| target_tokens = _tokens(audience_target) | |
| best: dict | None = None | |
| best_score = -1.0 | |
| best_overlap: set[str] = set() | |
| for cand in candidates: # created_at DESC β first seen wins a tie | |
| score, overlap = _score(cand, target_tokens) | |
| if score > best_score: | |
| best, best_score, best_overlap = cand, score, overlap | |
| assert best is not None # candidates is non-empty (checked by the caller) | |
| matched = ", ".join(sorted(best_overlap)) if best_overlap else "none (newest active fallback)" | |
| return { | |
| "startframe_id": str(best["id"]), | |
| "prompt": best.get("prompt_template") or "", | |
| "rationale": f"matched on: {matched}", | |
| } | |
| # ββ Claude defensive JSON parse (Β§16.5) ββββββββββββββββββββββββββββββββββββββ | |
| def _parse_choice(raw: str, valid_ids: set[str]) -> dict: | |
| """Parse the CASTING_SYSTEM reply; validate the chosen id is a real candidate.""" | |
| s = raw.strip() | |
| a, b = s.find("{"), s.rfind("}") | |
| if a == -1 or b == -1: | |
| raise ValueError("no JSON object in select_startframe output") | |
| obj = json.loads(s[a:b + 1]) | |
| sid = str(obj.get("startframe_id") or "").strip() | |
| if sid not in valid_ids: | |
| raise ValueError(f"chosen startframe_id {sid!r} is not a candidate for this product") | |
| prompt = str(obj.get("prompt") or "").strip() | |
| rationale = str(obj.get("rationale") or "").strip() | |
| if not prompt: | |
| raise ValueError("select_startframe returned an empty adapted prompt") | |
| return {"startframe_id": sid, "prompt": prompt, "rationale": rationale} | |
| def _build_user_prompt(audience_target: Any, candidates: list[dict], script_context: Any) -> str: | |
| """The user turn: the target audience, the script context, and every candidate | |
| (id + selection_text + audience + its base prompt_template) for Claude to rank.""" | |
| cand_view = [ | |
| { | |
| "id": str(c["id"]), | |
| "selection_text": c.get("selection_text") or "", | |
| "audience": c.get("audience") or {}, | |
| "prompt_template": c.get("prompt_template") or "", | |
| } | |
| for c in candidates | |
| ] | |
| return ( | |
| "AUDIENCE (free-form, from the winning ad-set β Β§16.4):\n" | |
| f"{json.dumps(audience_target, ensure_ascii=False)}\n\n" | |
| "SCRIPT CONTEXT for this variation (may be null):\n" | |
| f"{json.dumps(script_context, ensure_ascii=False)}\n\n" | |
| "CANDIDATE STARTFRAMES (pick exactly one id):\n" | |
| f"{json.dumps(cand_view, ensure_ascii=False, indent=2)}" | |
| ) | |
| # ββ Public API (Β§16.5) βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def select_startframe( | |
| audience_target: Any, | |
| product_id: str, | |
| script_context: Any | None = None, | |
| ) -> dict: | |
| """Choose the best startframe for `product_id` given a free-form `audience_target`. | |
| Returns {startframe_id (str), prompt, rationale}. `prompt` is the chosen | |
| startframe's scene-locking prompt β adapted to `script_context` by Claude, or the | |
| base `prompt_template` unchanged in the deterministic fallback (SPEC_NEW Β§16.5). | |
| SQL pre-filters `avatars` on `product_id` ONLY (no age/gender buckets) and drops | |
| retired startframes. Raises if the product has zero startframes. | |
| """ | |
| from . import db | |
| s = get_settings() | |
| # SQL pre-filter: this product's non-retired startframes. coalesce(status,'active') | |
| # so a NULL status counts as active. created_at DESC for the fallback tie-break. | |
| candidates = db.fetch_all( | |
| """ | |
| select id, name, persona, prompt_template, product_id, audience, | |
| selection_text, status, created_at | |
| from avatars | |
| where product_id = %s | |
| and coalesce(status, 'active') <> 'retired' | |
| order by created_at desc | |
| """, | |
| (product_id,), | |
| ) | |
| if not candidates: | |
| raise RuntimeError( | |
| f"select_startframe: product {product_id} has no startframes β " | |
| f"generate or upload at least one (Β§16.3) before casting" | |
| ) | |
| # Claude ranks ALL candidates (Β§16.5); deterministic fallback when there is no key. | |
| if s.anthropic_api_key: | |
| import anthropic | |
| valid_ids = {str(c["id"]) for c in candidates} | |
| client = anthropic.Anthropic(api_key=s.anthropic_api_key) | |
| user = _build_user_prompt(audience_target, candidates, script_context) | |
| 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=1024, | |
| system=CASTING_SYSTEM, messages=[{"role": "user", "content": content}], | |
| ) | |
| text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") | |
| try: | |
| return _parse_choice(text, valid_ids) | |
| except Exception as exc: # noqa: BLE001 β retry once, then fall back | |
| last_err = str(exc) | |
| # Claude failed twice β deterministic match still returns a usable startframe. | |
| return _fallback_select(candidates, audience_target) | |
| return _fallback_select(candidates, audience_target) | |