"""The oracle: turn images + a fragment into an interpretation and three films. This is the stable core. It assembles the vision request, calls whichever provider is configured, and parses the model's reply into the frozen JSON contract the frontend already understands: {"interpretation": str, "films": [{"title": str, "year": int, "rationale": str}]} The frontend never learns which model answered. """ from __future__ import annotations import json import re from typing import Any from .prompt import closing_instruction, exclude_clause, fragment_lead, system_prompt from .providers import Provider, get_provider from .sanitize import sanitize_fragment, sanitize_images, sanitize_titles MAX_IMAGES = 4 MAX_TOKENS = 2048 # Below this, an interpretation reads as a flat one-liner rather than a reading. MIN_INTERPRETATION_CHARS = 180 # A slightly hotter retry to shake a generic first pass loose. RETRY_TEMPERATURE = 0.95 # Phrases the prompt forbids — describing "the image" instead of the felt moment. # Word-bounded so we don't trip on "imagery", "depict", etc. _BANNED = re.compile( r"\bthe (images?|photos?|photographs?|pictures?)\b" r"|\b(this|these) (image|images|photo|photos|picture|pictures)\b" r"|\bthe (image|photo|picture) shows?\b", re.IGNORECASE, ) class OracleError(Exception): """Raised when the model reply can't be coerced into the contract.""" def _image_block(data_url: str) -> dict[str, Any]: """OpenAI-format image block. Accepts a full data URL or raw base64.""" url = data_url if data_url.startswith("data:") else f"data:image/jpeg;base64,{data_url}" return {"type": "image_url", "image_url": {"url": url}} def _build_content( images: list[str], fragment: str, exclude: list[str] ) -> list[dict[str, Any]]: content: list[dict[str, Any]] = [_image_block(u) for u in images[:MAX_IMAGES]] fragment = (fragment or "").strip() has_fragment = bool(fragment) if has_fragment: content.append({"type": "text", "text": fragment_lead(fragment)}) if exclude: content.append({"type": "text", "text": exclude_clause(exclude)}) content.append({"type": "text", "text": closing_instruction(has_fragment)}) return content def _repair(text: str) -> str: """Best-effort fixes for the small ways models produce not-quite-valid JSON.""" # Drop trailing commas before a closing brace/bracket: {"a":1,} -> {"a":1} text = re.sub(r",\s*([}\]])", r"\1", text) return text def _extract_json(raw: str) -> dict[str, Any]: """Pull a JSON object out of a model reply. Models vary: some wrap the JSON in ```fences```, some add a preamble, some leave a trailing comma. We strip fences, take the outermost {...}, and retry once after light repair before giving up. """ cleaned = re.sub(r"```(?:json)?", "", raw).strip() start, end = cleaned.find("{"), cleaned.rfind("}") if start == -1 or end == -1 or end < start: raise OracleError("No JSON object found in the model reply.") candidate = cleaned[start : end + 1] for attempt in (candidate, _repair(candidate)): try: return json.loads(attempt) except json.JSONDecodeError: continue raise OracleError("Model reply was not valid JSON.") def _ci_get(d: dict[str, Any], *keys: str) -> Any: """Fetch a value by any of `keys`, case-insensitively (models vary on casing).""" lower = {str(k).lower(): v for k, v in d.items()} for k in keys: if k.lower() in lower: return lower[k.lower()] return None def _coerce_year(value: Any) -> int | None: """A year may arrive as 1999, "1999", "c. 1999", or "1999.0".""" if value is None: return None digits = re.search(r"\d{4}", str(value)) return int(digits.group()) if digits else None def _validate(data: dict[str, Any]) -> dict[str, Any]: interp = _ci_get(data, "interpretation", "reading") films = _ci_get(data, "films", "movies", "recommendations") if not isinstance(interp, str) or not interp.strip(): raise OracleError("Reply missing a usable 'interpretation'.") if not isinstance(films, list) or not films: raise OracleError("Reply missing a usable 'films' list.") clean_films = [] for f in films: if not isinstance(f, dict): continue title = str(_ci_get(f, "title", "name") or "").strip() rationale = str(_ci_get(f, "rationale", "reason", "why") or "").strip() if not title or not rationale: continue # Models sometimes embed the year in the title ("Before Sunrise (1995)"). # Strip it so the UI doesn't show "(1995) (1995)", and use it if the # separate year field is missing. year = _coerce_year(_ci_get(f, "year")) m = re.search(r"\s*\((\d{4})\)\s*$", title) if m: title = title[: m.start()].strip() year = year or int(m.group(1)) clean_films.append({"title": title, "year": year, "rationale": rationale}) if not clean_films: raise OracleError("No well-formed films in the reply.") return {"interpretation": interp.strip(), "films": clean_films[:3]} def _is_weak(interpretation: str) -> bool: """A flat or rule-breaking interpretation worth one retry.""" text = interpretation.strip() return len(text) < MIN_INTERPRETATION_CHARS or bool(_BANNED.search(text)) def _result_weak(result: dict[str, Any]) -> bool: """Worth a retry if the reading is flat OR we got fewer than three films. Smaller models sometimes return one film despite "exactly 3" — that reads as broken, so a short count alone justifies the one retry. """ return _is_weak(result["interpretation"]) or len(result["films"]) < 3 def _run(provider: Provider, content, *, refined: bool, temperature=None) -> dict[str, Any]: raw = provider.complete(system_prompt(refined=refined), content, MAX_TOKENS, temperature) if not raw.strip(): raise OracleError("The model returned an empty reply.") return _validate(_extract_json(raw)) def interpret( images: list[str], fragment: str = "", provider: Provider | None = None, exclude: list[str] | None = None, refined: bool | None = None, ) -> dict[str, Any]: """Run the full flow. `images` are data URLs or raw base64 JPEG strings. `exclude` is the session's already-shown titles; the model is asked to find different films that still fit the moment (the "other films" re-roll). Stateless — that list lives on the client and rides in the request. `refined` overrides the prompt variant for a single clean pass (no retry) — used for A/B testing the baseline vs refined prompt on the same image. When None (the normal path), the first pass uses the configured default and one retry with the refined prompt covers a flat or unparseable reply. """ # Guardrails first: decode/validate/re-encode images and clean the inputs # before anything reaches the model. InputError propagates to a clean 4xx. images = sanitize_images(images) fragment = sanitize_fragment(fragment) exclude = sanitize_titles(exclude or []) provider = provider or get_provider() content = _build_content(images, fragment, exclude) # Explicit override (A/B / debugging): one clean pass with the chosen variant. if refined is not None: return _run(provider, content, refined=refined) # First pass with the configured default (refined=None reads the env flag). # If it parses and reads well, ship it. first: dict[str, Any] | None = None parse_failed = False try: first = _run(provider, content, refined=None) if not _result_weak(first): return first except OracleError: parse_failed = True # malformed/empty — the retry below is our second chance # One bounded retry, hotter. If the first reply *parsed* but read flat, the # refined prompt sharpens it. If it failed to *parse*, the refined prompt was # likely the culprit (it's chattier), so fall back to the more JSON-reliable # baseline instead of repeating the same failure. retry_refined = False if parse_failed else True try: retry = _run(provider, content, refined=retry_refined, temperature=RETRY_TEMPERATURE) except OracleError: if first is not None: return first # a bad retry shouldn't lose a usable first pass raise if first is None or not _result_weak(retry): return retry # Both flagged — prefer more films, then the meatier interpretation. if len(retry["films"]) != len(first["films"]): return retry if len(retry["films"]) > len(first["films"]) else first return retry if len(retry["interpretation"]) > len(first["interpretation"]) else first