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")