Anonymous German language learning app with multiple learning modes.
- Cookie-based anonymous player identity.
- Random generated display names.
- Multiple learning modes: flashcards, letter-scramble vocabulary practice, Goethe-Institut styled exercises, and grammar assistant.
- Mistake-aware RAG tutoring: wrong quiz/story answers can be sent as structured context so the assistant explains the exact learner error with cited grammar notes.
- Lightweight RAG data loop: grammar questions are logged with status, context type, route, and cited chunks so unanswered questions can drive corpus expansion and eval cases.
- Modular TypeScript frontend with Vite.
- FastAPI backend with SQLAlchemy.
- Render deployment config with Supabase Postgres.
Install dependencies:
uv sync --locked
npm install --prefix frontendRun the backend:
uv run uvicorn app.main:app --reloadRun the frontend dev server:
npm run dev --prefix frontendFor a production-style local run:
uv sync --locked --no-dev
npm run build --prefix frontend
uv run --no-sync uvicorn app.main:app --host 0.0.0.0 --port 8000Or run the production container stack without Render:
docker compose up --buildThe Compose stack starts:
frontend: Nginx serving the built Vite app onhttp://localhost:8000backend: FastAPI API/RAG service on the internal app networkdb: Postgres 16 with pgvector on the private database network
Useful environment overrides:
APP_PORT=8080 docker compose up --build
DEUTSCHELEARN_ADMIN_TOKEN=change-me \
DEUTSCHELEARN_COHERE_API_KEY=... \
DEUTSCHELEARN_OPENROUTER_API_KEY=... \
docker compose up --buildThe backend container runs alembic upgrade head before starting Uvicorn. The frontend proxies
/api/* requests to the backend service and serves all other routes as the SPA. The default Compose
database credentials are local-development friendly; set DEUTSCHELEARN_POSTGRES_PASSWORD
and API keys from your shell or a deployment secret store for hosted environments. If your
database password contains URL-special characters, URL-encode it because Compose also uses it
inside DATABASE_URL.
Production installs use uv sync --locked --no-dev to skip local-only tooling such as tests,
crawling, and PDF ingestion. Use the default uv sync --locked for development.
DATABASE_URL=sqlite:///./quiz.db
COOKIE_SECURE=false
COOKIE_SAMESITE=lax
SEED_ON_STARTUP=true
COHERE_API_KEY=
COHERE_EMBEDDING_MODEL=embed-multilingual-v3.0
COHERE_EMBEDDING_DIMENSION=1024
OPENROUTER_API_KEY=
OPENROUTER_CHAT_MODEL=google/gemma-4-31b-it:free
OPENROUTER_CHAT_FALLBACK_MODELS=openrouter/free
WEBSHARE_PROXY_USERNAME=
WEBSHARE_PROXY_PASSWORD=
GRAMMAR_SIMILARITY_THRESHOLD=0.45
GRAMMAR_RELATIVE_SIMILARITY_THRESHOLD=0.85
On Render, use the included render.yaml. It provisions a web service plus Postgres and sets COOKIE_SECURE=true.
Dictation uses Webshare when both proxy variables are set; without them, transcript requests connect directly.
The existing PROXY_USERNAME and PROXY_PASSWORD names are also accepted. The proxy config uses the
library's rotating residential setup and its default retry count for blocked requests.
Only uncached transcript fetch attempts count toward the default limits: 3 per client per hour, 10 per day, and 10
new transcripts globally per day. The shared catalog caps at 500 videos; transcript size is capped
at 20,000 characters and 500 segments. Cached videos remain available after fetch quotas are reached.
Reports are rate-limited and queued for admin review; they do not hide videos automatically. These
limits can be changed with the DICTATION_* environment settings in app/core/config.py.
Do not run production with the default SQLite URL. Render web service disks are ephemeral, so
sqlite:///./quiz.db can disappear on a new deploy. Create the service from render.yaml, or
manually provision a Render Postgres database and set the web service DATABASE_URL environment
variable to that database connection string. The app refuses to start in ENVIRONMENT=production
when DATABASE_URL still points to SQLite.
Schema changes are managed with Alembic:
uv run alembic upgrade head
uv run alembic revision --autogenerate -m "describe schema change"For a fresh Supabase database, set DATABASE_URL to the Supabase Postgres connection string
with SSL enabled, then run:
DATABASE_URL="postgresql://..." uv run alembic upgrade headThe grammar assistant uses a Render-friendly split:
- Render serves the public API and widget only.
- Curated Markdown notes live in
data/grammar. - Source PDFs live in local
data/grammar_pdfsand are described bydata/grammar_sources.json. - Local/dev ingestion embeds notes with Cohere
embed-multilingual-v3.0and writes chunks to the same Supabase Postgres database used byDATABASE_URL. - OpenRouter generates answers with
OPENROUTER_CHAT_MODEL, defaulting togoogle/gemma-4-31b-it:free. OPENROUTER_CHAT_FALLBACK_MODELSis a comma-separated fallback list, defaulting toopenrouter/free, so a temporary rate limit on the preferred free model does not disable the assistant.- Retrieval uses a calibrated absolute floor (
GRAMMAR_SIMILARITY_THRESHOLD=0.45) and a relative floor (GRAMMAR_RELATIVE_SIMILARITY_THRESHOLD=0.85) so weak neighbouring chunks are dropped before generation.
The embedding model is pinned to Cohere embed-multilingual-v3.0 at 1024 dimensions. Changing
the model is a schema migration plus full re-embed, not an environment-only change.
Set DATABASE_URL to the Supabase Postgres URL, run migrations there, then ingest:
UV_CACHE_DIR=/tmp/uv-cache uv run alembic upgrade head
UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/ingest_grammar.py --changed-onlyPDF ingestion uses pymupdf4llm locally and fails fast when a PDF appears scanned or produces
too little text. Add PDFs to data/grammar_pdfs, then add entries to data/grammar_sources.json:
[
{
"id": "a2-grammar-book",
"title": "A2 Grammar Book",
"level": "A2",
"topic": "praeposition_dativ",
"path": "a2-grammar-book.pdf",
"source": "private-pdf",
"pages": [12, 18]
}
]Useful ingestion commands:
UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/ingest_grammar.py --source-kind pdf --extract-only
UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/ingest_grammar.py --source-kind pdf --changed-only
UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/ingest_grammar.py --source-kind all --changed-only --delete-missingUse --dry-run to inspect chunk ids and --delete-missing after removing corpus files. Ingestion
must not run on Render free tier. data/grammar_pdfs/ is gitignored by default.
Run the retrieval harness with:
npm run evalThe current golden set is a starter set and should grow toward roughly 60 items. Before relying on an LLM judge for answer quality, hand-score 20 generation outputs and record the agreement rate here.
The grammar API accepts optional structured context for wrong answers and reading passages. The
service uses this context to enrich retrieval while keeping answer generation grounded in the
same cited chunks. Each grammar request is recorded in grammar_question_logs, which gives the
admin side a future source for content-gap reports such as frequently asked no-match topics,
weak routes, and chunks that are over- or under-used.
The app still creates missing tables on startup for local development, but production database schema changes should be applied with Alembic before deploying application code that depends on them.
The workflow in .github/workflows/render-deploy.yml runs backend linting, backend tests,
frontend audit, and frontend build before Render deploys. Keep Render's GitHub auto-deploy
enabled; the workflow does not call a deploy hook, avoiding a second deploy/build for the same
push.