| """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 |
|
|
| |
| MIN_INTERPRETATION_CHARS = 180 |
| |
| RETRY_TEMPERATURE = 0.95 |
|
|
| |
| |
| _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.""" |
| |
| 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 |
| |
| |
| |
| 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. |
| """ |
| |
| |
| images = sanitize_images(images) |
| fragment = sanitize_fragment(fragment) |
| exclude = sanitize_titles(exclude or []) |
|
|
| provider = provider or get_provider() |
| content = _build_content(images, fragment, exclude) |
|
|
| |
| if refined is not None: |
| return _run(provider, content, refined=refined) |
|
|
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| 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 |
| raise |
|
|
| if first is None or not _result_weak(retry): |
| return retry |
| |
| 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 |
|
|