From 6203c3a92f9897d5524a54dce8a370a924c8efcd Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 16:29:45 +0200 Subject: [PATCH] fix: figures a browser will actually draw, and two requests that could hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/config.py | 6 --- backend/app/routers/teach.py | 4 +- backend/app/routers/uploads.py | 8 +++- backend/app/services/ai_service.py | 15 ++++-- backend/app/services/thumbnails.py | 63 ++++++++++++++++++++------ backend/app/services/vision_service.py | 10 ++-- 6 files changed, 74 insertions(+), 32 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6362ade..8b14c3a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/routers/teach.py b/backend/app/routers/teach.py index a0079cd..99dd5dc 100644 --- a/backend/app/routers/teach.py +++ b/backend/app/routers/teach.py @@ -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, diff --git a/backend/app/routers/uploads.py b/backend/app/routers/uploads.py index 36e1227..96a5374 100644 --- a/backend/app/routers/uploads.py +++ b/backend/app/routers/uploads.py @@ -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": diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index cb980a4..1f256d2 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -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() diff --git a/backend/app/services/thumbnails.py b/backend/app/services/thumbnails.py index e9e96a3..499ee72 100644 --- a/backend/app/services/thumbnails.py +++ b/backend/app/services/thumbnails.py @@ -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: diff --git a/backend/app/services/vision_service.py b/backend/app/services/vision_service.py index 469b145..4941d57 100644 --- a/backend/app/services/vision_service.py +++ b/backend/app/services/vision_service.py @@ -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")