pdf-quiz-generator/backend/app/services/vision_service.py
Daniel 3418ed023b fix: WebP figures, the openai SDK removed, and a voice a site can add to
Three things landed together; the message names all of them, because a commit
that mentions one is a commit nobody finds the other two in.

**Figures.** Thirty-four JPEG 2000 files — 21 on questions, the rest unattached
in the media library — are WebP now, with `questions.image_path`,
`questions.explanation_image_path` and `media_assets.path` repointed together.
Serving already converted them on the way out, so nothing was broken; this
removes the step and makes what is stored the same thing that is served. The
originals stay: they are the only copy of what came out of the PDF, they cost a
few megabytes between them, and a conversion nobody can undo is not one to run
against a live bank. Paths are found by what the columns say rather than by
listing a bucket, because three tables record them and updating two would be
worse than none.

**The openai SDK is gone.** Ten call sites — one more than the map said, the
Celery article drafter — every one of them a POST with a JSON body, and not one
reading usage, cost, tool calls or logprobs. Every other call to the same proxy
was already plain httpx: embeddings, the ChromaDB embedding function, speech
both ways, model discovery, the vision probe. So this deletes an abstraction
rather than swapping one for another, and leaves one HTTP client instead of
two. `chat()` and `achat()` return the message content; a `ProxyError` carries
the status and the first 500 characters of the body, which is where the proxy
explains itself.

Behaviour is preserved deliberately, including a 600-second fallback timeout
for the four call sites that were running on the SDK's ten-minute default.
Lowering that is a real change and belongs in its own commit.

Proved against the live proxy on both services rather than only against mocks:
a completion, an async completion, a real 400 the vision probe still classifies
as a refusal, 407 models read from the catalogue, and a word read off an image.

**Voice.** A chosen voice is honoured whatever serves it. The prefix check only
accepted a locally served one, so a site adding a hosted voice would offer it
in Settings, save the learner's choice, and then quietly read every question in
the default voice. The list has always come from the database — adding a voice
is a row in Settings → AI models, never a code change.

And the sign-in page stops offering a locked door: `signup-policy` reports
whether registration is open at all, and the Sign up link goes when it is not.
The switch existed and the only way to discover it was to fill the form in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:13:05 +02:00

464 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Which models can see, and what happens when the one doing the job cannot.
A job's model is chosen for the job, not for the pictures that turn up in it.
The extraction and tutor models configured on this deployment report
`supports_vision: false`, and handing one an `image_url` part does not produce a
worse answer — it ends the request with a proxy error. So the picture goes to a
model that can see, the one an administrator sets for the `tool` job, and its
description is folded into the prompt as text, which a model that cannot see
reads perfectly well.
Capability is asked of the proxy rather than inferred from the model id: the
catalogue here runs to several hundred entries and changes without us.
`/model/info` carries `supports_vision` per deployment and settles most of it
outright — every OpenAI and Anthropic route, and a flat `false` on the DeepSeek
one. Where the field is absent the proxy genuinely does not know, and guessing
"no" would send work to the tool model that never needed to go there, so the
model itself is asked with an eight-pixel image, once.
Both answers are cached, because the question is about the model and not about
the request: the catalogue in-process for a few minutes, the probe in Redis for
a week. Descriptions are cached too — the tutor re-sends the same figure on
every turn of a conversation, and paying a second model call for each of them
was the first thing that made this feature look slow.
Nothing here degrades quietly. If the job's model cannot see and no tool model
is configured, the caller gets an error naming the setting to change; a
text-only answer about an image nobody looked at is worse than no answer.
"""
import base64
import hashlib
import io
import logging
import mimetypes
import re
import time
from dataclasses import dataclass, field
from app.config import settings
from app.services.ai_service import chat, get_configured_model
logger = logging.getLogger(__name__)
#: How long the proxy's catalogue is trusted. Models are added to it by hand,
#: so minutes are plenty and a stale "cannot see" is only ever a slower answer.
CATALOGUE_TTL = 600
#: A probe verdict is a fact about the model, not about today, so it is kept
#: long enough to be worth having and short enough to survive a proxy rewiring.
PROBE_TTL = 7 * 86400
DESCRIPTION_TTL = 30 * 86400
#: Beyond this the base64 payload costs more than the detail is worth — a stem
#: image here is a 24 MB scanned radiograph, and 1600px of it answers the same
#: question at a tenth the size.
MAX_IMAGE_BYTES = 1_500_000
DOWNSCALE_WIDTH = 1600
#: What the tool model is asked. American, like the rest of the clinical copy.
DESCRIBE_PROMPT = (
"Describe this image for a colleague who cannot see it and must reason from "
"your words alone.\n"
"- Report what is visible: modality, body part, structures, colors, "
"measurements, axes, arrows, and any text printed on the image, quoted exactly.\n"
"- Describe abnormal findings in clinical terms. Do not name a diagnosis "
"unless the image itself is labeled with one.\n"
"- Say plainly if the image is a logo, page header, blank, or otherwise "
"carries no clinical content.\n"
"- Description only: no preamble, no answer to any question."
)
_catalogue: dict[str, bool | None] = {}
_catalogue_at: float = 0.0
_probes: dict[str, bool] = {}
class VisionUnavailable(RuntimeError):
"""No model available to look at an image the request depends on.
Raised rather than returned so that no caller can carry on without noticing
that the picture went unread. The message names the setting to change.
"""
@dataclass(frozen=True)
class Image:
data: bytes
media_type: str = "image/jpeg"
caption: str = ""
@dataclass
class Handoff:
"""What actually ran, so nobody has to infer a second model call from a
latency graph."""
primary_model: str
images: int = 0
delegated: bool = False
tool_model: str | None = None
reason: str = ""
cached: bool = False
elapsed_ms: int = 0
descriptions: list[str] = field(default_factory=list)
def as_dict(self) -> dict:
return {
"delegated": self.delegated,
"primary_model": self.primary_model,
"tool_model": self.tool_model,
"images": self.images,
"reason": self.reason,
"cached": self.cached,
"elapsed_ms": self.elapsed_ms,
}
def _redis():
try:
import redis as redis_lib
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True,
socket_connect_timeout=1)
except Exception:
return None
def _cached(key: str) -> str | None:
client = _redis()
if client is None:
return None
try:
return client.get(key)
except Exception:
return None
def _remember(key: str, value: str, ttl: int) -> None:
client = _redis()
if client is None:
return
try:
client.set(key, value, ex=ttl)
except Exception:
# A cache that is down is a cost, never a failure.
logger.debug("Could not cache %s", key, exc_info=True)
def catalogue() -> dict[str, bool | None]:
"""model name -> what the proxy says about vision, refreshed on a timer.
A name can appear several times, once per deployment behind it: `best-chat`
fans out to six. A single `false` among them decides the alias, because a
request can land on any of them and an alias that fails one time in six is
worse than one that always takes the slower path.
"""
global _catalogue, _catalogue_at
if _catalogue and time.monotonic() - _catalogue_at < CATALOGUE_TTL:
return _catalogue
import httpx
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
return {}
headers = {"Authorization": f"Bearer {settings.LITELLM_API_KEY}"} if settings.LITELLM_API_KEY else {}
try:
response = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
response.raise_for_status()
rows = response.json().get("data", [])
except Exception:
# Leave whatever is already known in place: an unreachable catalogue is
# a reason to fall back to probing, not to forget what it said before.
logger.warning("Could not read the model catalogue; vision capability "
"falls back to probing", exc_info=True)
return _catalogue
verdicts: dict[str, bool | None] = {}
for row in rows:
name = row.get("model_name")
if not name:
continue
says = (row.get("model_info") or {}).get("supports_vision")
known = verdicts.get(name, "absent")
if says is False or known is False:
verdicts[name] = False
elif says is True:
verdicts[name] = True
elif known == "absent":
verdicts[name] = None
_catalogue, _catalogue_at = verdicts, time.monotonic()
return _catalogue
def _probe_image() -> str:
"""An eight-pixel PNG as a data URL. Deliberately not one pixel: some
providers reject an image below a minimum dimension, and a refusal of the
probe would read as a refusal of images."""
from PIL import Image as PILImage
buffer = io.BytesIO()
PILImage.new("RGB", (8, 8), (255, 255, 255)).save(buffer, format="PNG")
return data_url(buffer.getvalue(), "image/png")
def _probe(model_id: str, api_key: str | None) -> bool:
"""Ask the model itself, for the models the catalogue has no opinion on.
A 4xx is the proxy or the provider refusing the shape of the request, which
for a one-token call carrying nothing but a white square means it will not
take images; that verdict is worth keeping. A timeout or a 5xx says nothing
about the model, so it is not cached and the caller takes the safe path
this once.
"""
cache_key = f"vision:probe:{model_id}"
if model_id in _probes:
return _probes[model_id]
remembered = _cached(cache_key)
if remembered is not None:
_probes[model_id] = remembered == "1"
return _probes[model_id]
try:
chat(
model=model_id,
max_tokens=1,
messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": _probe_image()}},
{"type": "text", "text": "Reply with: ok"},
]}],
api_key=api_key,
timeout=30,
)
verdict = True
except Exception as error:
status = getattr(error, "status_code", None)
if not (isinstance(status, int) and 400 <= status < 500):
logger.info("Vision probe of %s was inconclusive: %s", model_id, error)
return False
logger.info("Vision probe: %s refused an image (%s)", model_id, error)
verdict = False
_probes[model_id] = verdict
_remember(cache_key, "1" if verdict else "0", PROBE_TTL)
return verdict
def can_see(model_id: str, api_key: str | None = None) -> bool:
"""Whether this model may be handed an image. Cached both ways."""
if not model_id:
return False
verdict = catalogue().get(model_id, None)
if verdict is not None:
return verdict
return _probe(model_id, api_key)
def data_url(data: bytes, media_type: str) -> str:
return f"data:{media_type};base64,{base64.b64encode(data).decode()}"
#: How a JPEG 2000 file starts — the JP2 container, then a bare codestream.
#: Sniffed rather than taken from the name, because the name is no guide: the
#: stem images extracted from these PDFs are `.jpx`, the slim base image ships
#: no MIME table so `mimetypes` reports nothing at all for them, and the
#: default that fills the gap is `image/jpeg`. The provider then answers "the
#: image data you provided does not represent a valid image" some seconds and
#: two fallback hops later.
JPEG_2000_SIGNATURES = (b"\x00\x00\x00\x0cjP ", b"\xff\x4f\xff\x51")
def prepare(data: bytes, media_type: str | None = None, caption: str = "") -> Image | None:
"""Bytes as a vision API will take them, or None if they cannot be.
Two conversions, both learned the hard way: JPEG 2000 comes out of the PDF
pipeline and no vision API accepts it, and a full-resolution scan spends
more on base64 than the extra pixels are worth.
"""
if not data:
return None
media_type = media_type or "image/jpeg"
if data.startswith(JPEG_2000_SIGNATURES):
media_type = "image/jp2"
if media_type in ("image/jp2", "image/jpx"):
try:
import fitz # already a dependency of the PDF pipeline
data, media_type = fitz.Pixmap(data).tobytes("png"), "image/png"
except Exception:
logger.info("Could not re-encode a JPEG 2000 image for a vision model",
exc_info=True)
return None
if len(data) > MAX_IMAGE_BYTES:
from app.services import thumbnails
smaller = thumbnails.render(data, DOWNSCALE_WIDTH)
if smaller:
data, media_type = smaller, "image/webp"
return Image(data=data, media_type=media_type, caption=caption)
def load_image(key: str, caption: str = "") -> Image | None:
"""A stored upload, ready for a model. Never raises."""
if not key:
return None
try:
if re.match(r"^https?://", key, re.I):
import httpx
response = httpx.get(key, timeout=10, follow_redirects=True)
response.raise_for_status()
return prepare(response.content,
response.headers.get("content-type", "").split(";")[0] or None,
caption)
from app.services import storage_service
# Some rows hold the URL the browser uses rather than the storage key;
# `/uploads/` is that route's prefix and nothing in the bucket has it.
key = key.removeprefix("/uploads/")
data = storage_service.s3_object(key) or storage_service.load(key)
return prepare(data, mimetypes.guess_type(key)[0], caption) if data else None
except Exception:
logger.info("Could not load %s for a vision model", key, exc_info=True)
return None
def image_part(image: Image) -> dict:
return {"type": "image_url", "image_url": {"url": data_url(image.data, image.media_type)}}
def word_image(word: str) -> Image:
"""A picture of a word, for testing that a model can read a picture.
Presence in the catalogue is not proof, the same way a transcription model
listed by the proxy is not proof that anything can be transcribed. The test
makes the tool model read something only an eye could have read.
"""
from PIL import Image as PILImage, ImageDraw, ImageFont
canvas = PILImage.new("RGB", (480, 160), (255, 255, 255))
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.load_default(size=64)
except TypeError: # Pillow before 9.2 cannot size the default font
font = ImageFont.load_default()
draw.text((40, 45), word, fill=(0, 0, 0), font=font)
buffer = io.BytesIO()
canvas.save(buffer, format="PNG")
return Image(data=buffer.getvalue(), media_type="image/png", caption="test image")
def describe(images: list[Image], model_id: str, api_key: str | None = None,
context: str = "", use_cache: bool = True) -> list[str]:
"""What the tool model sees, one description per image.
Cached on the bytes, the surrounding context and the model, because the
same figure comes back on every turn of a tutor conversation and none of
those turns changes what the picture shows.
"""
return _describe(images, model_id, api_key, context, use_cache)[0]
def _describe(images: list[Image], model_id: str, api_key: str | None,
context: str, use_cache: bool) -> tuple[list[str], int]:
"""The descriptions, and how many of them came from the cache."""
out: list[str] = []
hits = 0
for image in images:
digest = hashlib.sha256(
image.data + context.encode() + model_id.encode()).hexdigest()[:24]
cache_key = f"vision:description:{digest}"
if use_cache:
remembered = _cached(cache_key)
if remembered:
out.append(remembered)
hits += 1
continue
prompt = DESCRIBE_PROMPT
if image.caption:
prompt += f"\n\nWhat this image is: {image.caption}"
if context:
prompt += f"\n\nWhere it appears:\n{context[:2000]}"
text = (chat(
model=model_id,
temperature=0,
max_tokens=700,
messages=[{"role": "user", "content": [
image_part(image), {"type": "text", "text": prompt},
]}],
api_key=api_key,
timeout=120,
) or "").strip()
if not text:
raise VisionUnavailable(
f"{model_id} is configured as the tool model but returned no "
"description of the image.")
out.append(text)
if use_cache:
_remember(cache_key, text, DESCRIPTION_TTL)
return out, hits
def image_context(db, images: list[Image], *, model_id: str, api_key: str | None = None,
context: str = "", tool: tuple[str, str | None] | None = None
) -> tuple[list[dict], Handoff]:
"""Message content parts carrying these images, whatever the model can do.
The caller splices the parts into a user message and does not need to know
which of the two things happened: either the images themselves, or a
description of each written by the tool model. The Handoff says which, for
the log line and for the response.
`tool` names the tool model outright, for callers running in a thread pool:
a SQLAlchemy session belongs to one thread, so those resolve it once on the
way in and pass it down rather than handing `db` to every worker.
"""
handoff = Handoff(primary_model=model_id, images=len(images))
if not images:
return [], handoff
started = time.monotonic()
if can_see(model_id, api_key):
handoff.reason = "the model reads images itself"
handoff.elapsed_ms = int((time.monotonic() - started) * 1000)
return [image_part(image) for image in images], handoff
tool = tool or get_configured_model(db, "tool")
if not tool:
raise VisionUnavailable(
f"{model_id} cannot read images and no tool model is configured. "
"In Settings → AI models, allow a model that can see for the Tool "
"job and select it.")
tool_model, tool_key = tool
if catalogue().get(tool_model) is False:
raise VisionUnavailable(
f"{model_id} cannot read images, and the tool model set to cover it "
f"({tool_model}) cannot either. Choose a model that can see for the "
"Tool job in Settings → AI models.")
handoff.delegated = True
handoff.tool_model = tool_model
handoff.reason = f"{model_id} cannot read images"
try:
handoff.descriptions, hits = _describe(images, tool_model, tool_key, context, True)
except VisionUnavailable:
raise
except Exception as error:
raise VisionUnavailable(
f"The tool model {tool_model} could not read the image: {error}") from error
handoff.elapsed_ms = int((time.monotonic() - started) * 1000)
handoff.cached = hits == len(images)
parts = []
for image, text in zip(images, handoff.descriptions):
what = image.caption or "an image accompanying this request"
parts.append({"type": "text", "text": (
f"[Description of {what}. You cannot see images, so {tool_model} "
f"looked at it and wrote this. Treat it as what the image shows.]\n{text}")})
logger.info("Vision handoff: %s cannot see, %s described %d image(s) in %d ms",
model_id, tool_model, len(images), handoff.elapsed_ms)
return parts, handoff