From 56fdc57389c2242d5e23dcf42977a47bcbdb8192 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 19 Apr 2026 02:17:35 +0200 Subject: [PATCH] fix: gateway-agnostic URL handling for TTS and embeddings, docs cleanup - Fix double /v1 in TTS audio/speech URL when LITELLM_API_BASE includes /v1 - Fix double /v1 in embedding service and vector service URLs - Clean up docs: remove second-person language in deployment, frontend, migrations Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/ai_service.py | 10 ++++--- backend/app/services/embedding_service.py | 2 +- backend/app/services/vector_service.py | 2 +- docs/deployment.md | 10 +++---- docs/frontend.md | 6 ++--- docs/migrations.md | 32 +++++++++++------------ 6 files changed, 32 insertions(+), 30 deletions(-) diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index a73f068..9e4e2e1 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -9,8 +9,10 @@ logger = logging.getLogger(__name__) def _proxy_model(model_id: str) -> str: - """Prefix model with openai/ if using a LiteLLM proxy and no provider is specified.""" - if settings.LITELLM_API_BASE and "/" not in model_id: + """Always prefix model with openai/ when using a proxy. The proxy (Bifrost) + expects provider/model format; litellm strips ONE openai/ prefix before + sending to OpenAI-compatible endpoints, so we wrap with an extra openai/.""" + if settings.LITELLM_API_BASE: return f"openai/{model_id}" return model_id @@ -425,13 +427,13 @@ def generate_tts_audio( # Per-model key > OPENAI_API_KEY (direct) > LITELLM_API_KEY (proxy) if api_key: key = api_key - base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/") + base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/").removesuffix("/v1") elif settings.OPENAI_API_KEY: key = settings.OPENAI_API_KEY base = "https://api.openai.com" else: key = settings.LITELLM_API_KEY - base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/") + base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/").removesuffix("/v1") try: resp = httpx.post( diff --git a/backend/app/services/embedding_service.py b/backend/app/services/embedding_service.py index 721e546..771a74f 100644 --- a/backend/app/services/embedding_service.py +++ b/backend/app/services/embedding_service.py @@ -46,7 +46,7 @@ def generate_embedding(text: str) -> list[float] | None: # ── 1. LiteLLM proxy (direct httpx — avoids LiteLLM param validation) ── embedding_model = _get_embedding_model() - api_base = (settings.LITELLM_API_BASE or "").rstrip("/") + api_base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1") if embedding_model and settings.LITELLM_API_KEY and api_base: try: import httpx, json as _json diff --git a/backend/app/services/vector_service.py b/backend/app/services/vector_service.py index 2970ac0..a998990 100644 --- a/backend/app/services/vector_service.py +++ b/backend/app/services/vector_service.py @@ -13,7 +13,7 @@ class LiteLLMEmbeddingFunction(EmbeddingFunction): import httpx, json as _json from app.services.embedding_service import _get_embedding_model model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL - api_base = (settings.LITELLM_API_BASE or "").rstrip("/") + api_base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1") body = {"model": model, "input": input, "dimensions": settings.EMBEDDING_DIMENSIONS} resp = httpx.post( f"{api_base}/v1/embeddings", diff --git a/docs/deployment.md b/docs/deployment.md index 831ca6f..6f143c3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -106,7 +106,7 @@ quiz.example.com { } ``` -Note: The frontend's Nginx already proxies `/api` to the backend container internally, so you only need to expose the frontend port. +Note: The frontend's Nginx already proxies `/api` to the backend container internally, so only the frontend port needs to be exposed. --- @@ -126,7 +126,7 @@ Note: The frontend's Nginx already proxies `/api` to the backend container inter ### Celery shares the backend image -The `celery` service builds from `./backend` — the same Dockerfile as `backend`. When you rebuild `backend`, you must also rebuild `celery`: +The `celery` service builds from `./backend` — the same Dockerfile as `backend`. Rebuilding `backend` requires rebuilding `celery` as well: ```bash docker compose build backend @@ -248,7 +248,7 @@ The backend uses a Redis-based singleton lock (`startup:singleton_lock`, 5-minut ### Stale DB Connections in Celery Tasks -Celery workers are long-running processes. Without `pool_recycle=300`, connections can go stale (especially after postgres restarts). The `pool_pre_ping=True` setting validates connections before use. If you see database errors in celery logs after a postgres restart, restart the celery service: +Celery workers are long-running processes. Without `pool_recycle=300`, connections can go stale (especially after postgres restarts). The `pool_pre_ping=True` setting validates connections before use. If database errors appear in celery logs after a postgres restart, restart the celery service: ```bash docker compose restart celery @@ -256,7 +256,7 @@ docker compose restart celery ### Embedding Batch Size / Rate Limits -Embedding generation happens during quiz extraction and on startup (backfill). If the embedding API has rate limits, you may see failures in the celery logs. The embedding service handles individual failures gracefully (logs and continues). To re-embed all questions: +Embedding generation happens during quiz extraction and on startup (backfill). If the embedding API has rate limits, failures may appear in the celery logs. The embedding service handles individual failures gracefully (logs and continues). To re-embed all questions: ```bash docker compose exec backend python manage.py reembed @@ -264,7 +264,7 @@ docker compose exec backend python manage.py reembed ### Docker Build Cache Issues -If you update Python dependencies or JS packages but the build uses cached layers: +If Python dependencies or JS packages have been updated but the build is reusing cached layers: ```bash # Force fresh install of all dependencies diff --git a/docs/frontend.md b/docs/frontend.md index 142b008..4ae8d1a 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -108,7 +108,7 @@ Features: - **Search bar** - Debounced search (350ms) across quizzes and questions. Three filter modes: All, Title only, Questions only. Results show quiz title matches and matching question snippets with `HighlightText` highlighting. - **Expanded search** - Toggle to view all matching questions in a flat list. Each question has a Study button. -- **QuestionStudyModal** - Interactive modal that lets you answer a question, shows correct/incorrect feedback, and displays the explanation. Includes a lazy-loaded `TeachChat` with the `elevated` prop for higher z-index (above the modal). +- **QuestionStudyModal** - Interactive modal for answering a single question, showing correct/incorrect feedback, and displaying the explanation. Includes a lazy-loaded `TeachChat` with the `elevated` prop for higher z-index (above the modal). - **In-progress quizzes** (`InProgressSection`) - Shows quizzes the user started but hasn't finished. Resume and Delete buttons. - **Past attempts** (`PastAttemptsSection`) - Collapsible section, loads on demand. Delete buttons on each attempt. - **Quiz grid** - Quizzes grouped by category (moderator-created categories). `QuizCard` component shows title, question count, mode badge (Learning/Timed), time limit. Hover lift effect. @@ -120,7 +120,7 @@ Features: The core quiz-taking experience. Two phases: mode selection, then quiz. **Mode selection** (`ModeSelectScreen`): -- Study Mode: answers and explanations shown as you go. +- Study Mode: answers and explanations revealed after each question. - Exam Mode: answers hidden until submitted. - Optional timer input (minutes) for exam mode. - TTS voice selector (if voices are configured). @@ -167,7 +167,7 @@ Features: - **Favorites filter** - Toggle to show only favorited questions. - **Uncategorized filter** - Toggle to show only questions without a category. - **Bulk operations** - Checkbox selection on questions. "Select All" fetches ALL matching IDs from `GET /questions/bank/ids` (not just the loaded page). Bulk assign category via `POST /questions/bulk-category`. -- **Create quiz from selection** - `CreateQuizModal` lets you create a quiz from selected questions or from all questions in a category. Choose title, mode (Timed/Learning), and optional time limit. +- **Create quiz from selection** - `CreateQuizModal` creates a quiz from the currently selected questions or from all questions in a category. Inputs: title, mode (Timed/Learning), and optional time limit. - **Question study modal** - Same interactive study modal as QuizzesPage with TeachChat (elevated z-index). - **Question edit modal** (`QuestionEditModal`) - Moderators can edit question text, options (with radio button for correct answer), category, and explanation. Warning that changes apply to all quizzes sharing the question. - **Tag classification** - Moderators can trigger AI classification (`POST /tags/classify`). Progress is polled every 2 seconds. Tags reload on completion. diff --git a/docs/migrations.md b/docs/migrations.md index a519c7a..e60e496 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -1,10 +1,10 @@ -# Database Migrations (Alembic) +# Database migrations (Alembic) -## What's a migration? +## Overview -A migration is a versioned, ordered change to the database schema — adding a column, renaming a table, changing a type. Each change is a Python file in `backend/alembic/versions/`. Alembic tracks which ones have been applied in an `alembic_version` table in Postgres, so it knows what's new next time. +A migration is a versioned, ordered change to the database schema — adding a column, renaming a table, changing a type. Each change lives in a Python file under `backend/alembic/versions/`. Alembic records applied migrations in an `alembic_version` table inside Postgres and only runs files that have not yet been applied. -Before Alembic was wired up in this project, schema was bootstrapped via `Base.metadata.create_all()` in `backend/app/main.py` — which only creates *missing tables*, never alters existing ones. Every column change required a manual `ALTER TABLE`. Alembic fixes that: schema changes become versioned, reversible, and reproducible across environments. +Before Alembic was introduced, the schema was bootstrapped via `Base.metadata.create_all()` in `backend/app/main.py`, which only creates *missing tables* — it never alters existing ones. Every column change required a manual `ALTER TABLE`. Alembic replaces that workflow: schema changes become versioned, reversible, and reproducible across environments. ## Current setup @@ -12,28 +12,28 @@ Before Alembic was wired up in this project, schema was bootstrapped via `Base.m - The live production DB is stamped at the latest revision in `backend/alembic/versions/`. - `Base.metadata.create_all()` is still called at startup as a safety net for fresh deploys. Do **not** remove it without first generating a complete baseline migration from the current live schema. -## Developer workflow +## Workflow ```bash -# where am I? +# Current revision / available heads docker compose exec backend alembic current docker compose exec backend alembic heads -# create a new migration (auto-diffs your model against the live DB) +# Create a new migration (auto-diffs SQLAlchemy models against the live DB) docker compose exec backend alembic revision --autogenerate -m "add some column" -# review the generated file under backend/alembic/versions/ BEFORE applying +# Review the generated file under backend/alembic/versions/ BEFORE applying -# apply pending migrations +# Apply pending migrations docker compose exec backend alembic upgrade head -# roll back the last one +# Roll back the last migration docker compose exec backend alembic downgrade -1 -# mark the DB as being at a revision without running anything (use with care) +# Mark the DB as being at a revision without running anything (use with care) docker compose exec backend alembic stamp ``` -After adding a migration in dev, rebuild the backend image so the new file is in the container: +After adding a migration in development, rebuild the backend image so the new file is baked into the container: ```bash docker compose build backend celery @@ -54,13 +54,13 @@ Always: edit model → generate migration → review → apply → commit both f ## Gotchas - Migrations run inside a transaction. A failed migration rolls back cleanly, so the DB stays consistent. -- `--autogenerate` doesn't catch everything. Things you must hand-edit into the file: +- `--autogenerate` does not catch everything. The following must be hand-edited into the generated file: - `server_default` changes - CHECK constraints - Enum value additions - Data migrations (moving rows around as part of a schema change) -- The `alembic_version` table should only ever have one row. Multiple rows = branched heads; run `alembic merge` to reconcile. -- Don't edit a migration after it's been applied to any shared environment — write a new one instead. +- The `alembic_version` table should only ever have one row. Multiple rows indicate branched heads — run `alembic merge` to reconcile. +- Never edit a migration after it has been applied to a shared environment — write a new migration instead. ## Rollback patterns @@ -71,6 +71,6 @@ docker compose exec backend alembic downgrade -1 # Jump to a specific revision (by hash prefix) docker compose exec backend alembic downgrade 9bac7bf02e38 -# Jump all the way back (rarely what you want) +# Jump all the way back to an empty schema (rarely desired in production) docker compose exec backend alembic downgrade base ```