fix: figures a browser will actually draw, and two requests that could hang

Twenty-one stem figures are JPEG 2000. Chrome dropped it in 2015, Firefox and
Edge never had it, and the slim base image ships no MIME table — so
`guess_type` returned nothing, the fallback was `application/octet-stream`, and
`nosniff` finished the job. Those figures rendered nowhere but Safari.

The bytes were never the problem: Pillow decodes JP2 here perfectly well. Only
the delivery had to change, so it changes the way everything else already does
— through the thumbnail machinery, as a cached WebP derivative, stored beside
the original. A format no browser draws now asks for conversion whatever size
it was requested at, decided by the file's own magic rather than by the query
string. The 41 KB original comes back as an 83 KB full-size WebP or a 5 KB
thumbnail, and the stored file is untouched.

`.jp2`, `.jpx`, `.jpf` and `.webp` are registered at import, because a
container with no `/etc/mime.types` is a container that mislabels every one of
them. `.webp` had no figures behind it yet and would have failed the same way.

Two calls could hang for ten minutes. The SDK reads for that long by default
and this client retries nothing, so a stalled connection is a stalled request —
three of them in extraction, which does its own retrying. Both now pass an
explicit two-minute timeout.

Also removed: `EMBEDDING_PROVIDER`, which looks like a switch between a local
encoder and a remote one and is read nowhere, with a comment claiming
embeddings run locally when they have always gone over the network to the
proxy; and a `.replace("openai/", "")` that existed only to undo a prefix
nothing adds any more. The JPEG 2000 comment named the wrong mechanism — the
filename is no guide because there is no MIME table, not because it lies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 16:29:45 +02:00
parent 17f238bded
commit 6203c3a92f
6 changed files with 74 additions and 32 deletions

View file

@ -23,12 +23,6 @@ class Settings(BaseSettings):
AWS_SECRET_ACCESS_KEY: str = ""
AWS_REGION: str = "us-east-1"
AWS_BEDROCK_REGION: str = "us-east-1"
# Embeddings run locally by default: a search must not depend on a remote
# service being up, and a local model cannot change under us at runtime.
# BGE-M3 via the existing LiteLLM proxy — no extra credential. The retry
# task backfills anything an outage leaves unembedded, and search still
# answers from full text while the semantic half is unavailable.
EMBEDDING_PROVIDER: str = "litellm"
EMBEDDING_DIMENSIONS: int = 1024
APP_URL: str = "https://quiz.danvics.com"

View file

@ -326,8 +326,8 @@ async def chat(
messages.append({"role": msg.role, "content": msg.content})
try:
from app.services.ai_service import get_async_client
client = get_async_client(api_key)
from app.services.ai_service import DEFAULT_TIMEOUT, get_async_client
client = get_async_client(api_key, timeout=DEFAULT_TIMEOUT)
response = await client.chat.completions.create(
model=model_id,
messages=messages,

View file

@ -67,7 +67,13 @@ def _read_upload(path, request, attempt_id, db, headers, width=None):
# ask and stored beside the original. `None` back means there is nothing
# smaller worth serving — not an image, or already narrower than asked —
# so the original goes out, which is what the caller wanted anyway.
if width:
# A width asks for a smaller copy. A format no browser draws asks for one
# too, whatever size it was requested at: 21 stem figures here are JPEG
# 2000, which Chrome dropped in 2015 and Firefox never had, and which the
# slim base image does not even have a MIME type for — so they were going
# out as `application/octet-stream` under `nosniff` and rendering nowhere.
# The bytes are fine; Pillow reads them. Only the delivery had to change.
if width or thumbnails.needs_converting(path):
small = thumbnails.get(path, width)
if small is not None:
if request.method == "HEAD":

View file

@ -44,6 +44,12 @@ def _client_kwargs(api_key: str | None, timeout: float | None) -> dict:
return kwargs
#: The SDK reads for ten minutes by default and this client retries nothing, so
#: a stalled connection is a stalled request — three of them in extraction,
#: which does its own retrying. Two minutes is longer than any answer here has
#: ever legitimately taken.
DEFAULT_TIMEOUT = 120.0
def get_client(api_key: str | None = None, timeout: float | None = None) -> OpenAI:
"""Blocking client for the completions proxy. Every call site goes through
here: the same three settings assembled by hand at each one is how one of
@ -185,7 +191,7 @@ def extract_questions(
for attempt in range(3):
try:
# Don't force JSON mode — let the model respond naturally and we parse it
response = get_client(use_key).chat.completions.create(
response = get_client(use_key, timeout=DEFAULT_TIMEOUT).chat.completions.create(
model=use_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1, # low temp for faithful extraction
@ -539,10 +545,9 @@ def generate_tts_audio(
# ── OpenAI (default) ────────────────────────────────────────
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
clean_model = use_model.replace("openai/", "")
oai_voice = "alloy"
if ":" in clean_model:
clean_model, oai_voice = clean_model.split(":", 1)
if ":" in use_model:
use_model, oai_voice = use_model.split(":", 1)
# Per-model key > OPENAI_API_KEY (direct) > LITELLM_API_KEY (proxy)
if api_key:
@ -559,7 +564,7 @@ def generate_tts_audio(
resp = httpx.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": clean_model, "input": text, "voice": oai_voice},
json={"model": use_model, "input": text, "voice": oai_voice},
timeout=60,
)
resp.raise_for_status()

View file

@ -37,21 +37,49 @@ WIDTHS = (256, 640)
#: What is worth resizing. A PDF or an SVG is not: one is not an image and the
#: other is already small and resolution-independent.
RESIZABLE = {"image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp", "image/tiff"}
RESIZABLE = {"image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp",
"image/tiff", "image/jp2"}
#: Formats a browser will not draw. JPEG 2000 is the live case: Chrome dropped
#: it in 2015, Firefox and Edge never had it, and 21 stem figures in this bank
#: are `.jpx`. Pillow decodes it here, so the file is fine — it is only the
#: delivery that has to change, and it changes to WebP like everything else.
UNDISPLAYABLE = {"image/jp2", "image/tiff", "image/bmp"}
#: Registered at import, because the slim base image ships no `/etc/mime.types`
#: and `guess_type` therefore returns None for these. That is how a JPEG 2000
#: figure came to be served as `application/octet-stream` under `nosniff`,
#: which no browser will render however capable it is.
for _suffix, _type in ((".jp2", "image/jp2"), (".jpx", "image/jp2"),
(".jpf", "image/jp2"), (".webp", "image/webp")):
mimetypes.add_type(_type, _suffix)
QUALITY = 82
def thumb_key(key: str, width: int) -> str:
return f"thumbs/{width}/{key}"
def thumb_key(key: str, width: int | None) -> str:
return f"thumbs/{width or 'full'}/{key}"
def media_type(key: str) -> str:
return mimetypes.guess_type(key)[0] or ""
def is_resizable(key: str) -> bool:
return (mimetypes.guess_type(key)[0] or "") in RESIZABLE
return media_type(key) in RESIZABLE
def render(data: bytes, width: int) -> bytes | None:
"""One derivative, or None if it should not or cannot be made."""
def needs_converting(key: str) -> bool:
"""Whether a browser would refuse to draw this even at full size."""
return media_type(key) in UNDISPLAYABLE
def render(data: bytes, width: int | None) -> bytes | None:
"""One derivative, or None if it should not or cannot be made.
A width of None means "convert, do not resize" the whole picture, in a
format a browser will draw.
"""
try:
from PIL import Image, ImageOps
except ImportError: # pragma: no cover - Pillow is a hard dependency here
@ -63,11 +91,14 @@ def render(data: bytes, width: int) -> bytes | None:
# sideways with a rotation flag, and a thumbnail made without reading
# that flag is a sideways thumbnail of a portrait image.
image = ImageOps.exif_transpose(image)
if image.width <= width:
return None
height = max(1, round(image.height * width / image.width))
image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB")
image = image.resize((width, height), Image.LANCZOS)
if width is not None:
if image.width <= width:
return None
height = max(1, round(image.height * width / image.width))
image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB")
image = image.resize((width, height), Image.LANCZOS)
else:
image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB")
out = io.BytesIO()
image.save(out, format="WEBP", quality=QUALITY, method=4)
return out.getvalue()
@ -79,13 +110,17 @@ def render(data: bytes, width: int) -> bytes | None:
return None
def get(key: str, width: int) -> bytes | None:
def get(key: str, width: int | None) -> bytes | None:
"""The derivative, making it on first ask. None means "serve the original".
Not a cache that can be turned off: the second reader of a page gets the
stored copy, and the first pays for it once.
"""
if width not in WIDTHS or not is_resizable(key):
if not is_resizable(key):
return None
# `None` is the convert-only case and is not a width anybody may ask for;
# it is decided here by the file's own format, never by the query string.
if width is not None and width not in WIDTHS:
return None
derived = thumb_key(key, width)
existing = storage_service.load(derived)
@ -108,7 +143,7 @@ def get(key: str, width: int) -> bytes | None:
def forget(key: str) -> None:
"""Drop every derivative of a key, for when the original changes or goes."""
for width in WIDTHS:
for width in (*WIDTHS, None):
try:
storage_service.delete(thumb_key(key, width))
except Exception:

View file

@ -258,10 +258,12 @@ def data_url(data: bytes, media_type: str) -> str:
#: How a JPEG 2000 file starts — the JP2 container, then a bare codestream.
#: Sniffed rather than taken from the name because the name lies: the stem
#: images extracted from these PDFs are `.jpx` files that `mimetypes` reports
#: as `image/jpeg`, and the provider answers "the image data you provided does
#: not represent a valid image" some seconds and two fallback hops later.
#: 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")