chalchitra / backend /oracle.py
ajit3259's picture
fix(oracle): on a parse failure, retry with the reliable baseline prompt
fff92f9
Raw
History Blame Contribute Delete
8.86 kB
"""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