blizzarman commited on
Commit
00d5ae5
·
1 Parent(s): 6909b9e

feat: M0 walking skeleton (app, service protocols, CI/CD, Space deploy)

Browse files

- Gradio app: one tab per skill + diagnostics tab with LLM ping
- Provider-agnostic services via typing.Protocol + tested fakes
(single OpenAI-compatible LLM client: Gemini/Mistral/Ollama/OpenAI)
- Env-driven config (pydantic-settings), offline-safe defaults
- CI: ruff + pytest, Docker image pushed to GHCR on main
- Repo sync workflow to HF Space (Docker SDK, cpu-basic)
- ADRs: stack & architecture, LLM provider strategy, datasets & licensing

Files changed (49) hide show
  1. .dockerignore +18 -0
  2. .env.example +29 -0
  3. .github/workflows/ci.yml +63 -0
  4. .github/workflows/deploy-space.yml +34 -0
  5. .gitignore +34 -0
  6. .pre-commit-config.yaml +19 -0
  7. .python-version +1 -0
  8. Dockerfile +31 -0
  9. LICENSE +1 -1
  10. README.md +101 -1
  11. docs/adr/0001-stack-and-architecture.md +30 -0
  12. docs/adr/0002-llm-provider-strategy.md +23 -0
  13. docs/adr/0003-datasets-and-licensing.md +21 -0
  14. docs/evals/.gitkeep +0 -0
  15. evals/README.md +4 -0
  16. pre-commit-config.yaml +19 -0
  17. pyproject.toml +62 -0
  18. scripts/download_data.py +66 -0
  19. src/tutor/__init__.py +0 -0
  20. src/tutor/app/__init__.py +0 -0
  21. src/tutor/app/main.py +76 -0
  22. src/tutor/config.py +56 -0
  23. src/tutor/domain/__init__.py +0 -0
  24. src/tutor/domain/models.py +98 -0
  25. src/tutor/exercises/__init__.py +0 -0
  26. src/tutor/ml/__init__.py +0 -0
  27. src/tutor/ml/cefr/__init__.py +0 -0
  28. src/tutor/ml/pronunciation/__init__.py +0 -0
  29. src/tutor/ml/srs/__init__.py +0 -0
  30. src/tutor/services/__init__.py +0 -0
  31. src/tutor/services/asr/__init__.py +0 -0
  32. src/tutor/services/asr/base.py +33 -0
  33. src/tutor/services/llm/__init__.py +0 -0
  34. src/tutor/services/llm/base.py +40 -0
  35. src/tutor/services/llm/factory.py +34 -0
  36. src/tutor/services/llm/fake.py +26 -0
  37. src/tutor/services/llm/openai_compat.py +52 -0
  38. src/tutor/services/storage/__init__.py +0 -0
  39. src/tutor/services/storage/base.py +22 -0
  40. src/tutor/services/storage/memory.py +26 -0
  41. src/tutor/services/tts/__init__.py +0 -0
  42. src/tutor/services/tts/base.py +48 -0
  43. test +0 -0
  44. tests/test_app.py +10 -0
  45. tests/test_config.py +31 -0
  46. tests/test_llm.py +52 -0
  47. tests/test_services.py +52 -0
  48. training/README.md +5 -0
  49. uv.lock +0 -0
.dockerignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .venv
4
+ .env*
5
+ data
6
+ models
7
+ mlruns
8
+ docs
9
+ evals
10
+ scripts
11
+ tests
12
+ training
13
+ .pytest_cache
14
+ .ruff_cache
15
+ .gradio
16
+ __pycache__
17
+ *.md
18
+ !README.md
.env.example ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env for local dev. Never commit .env (gitignored).
2
+ # On the HF Space, set these in Settings > Variables and secrets.
3
+
4
+ # --- App ---
5
+ APP_ENV=dev # dev | prod
6
+ LOG_LEVEL=INFO
7
+
8
+ # --- Languages (per-learner override comes in M3) ---
9
+ DEFAULT_SOURCE_LANG=fr
10
+ DEFAULT_TARGET_LANG=en
11
+
12
+ # --- LLM (provider-agnostic via OpenAI-compatible endpoints, see ADR 0002) ---
13
+ LLM_PROVIDER=fake # fake | gemini | openai | mistral | ollama
14
+ LLM_MODEL=gemini-2.5-flash # safe default; check AI Studio for the current free-tier flash (e.g. gemini-3-flash)
15
+ LLM_API_KEY= # Gemini: https://aistudio.google.com/apikey
16
+ # NB: Gemini free tier => prompts may be used for training. Demo data only.
17
+ # LLM_BASE_URL= # override the per-provider preset endpoint if needed
18
+ LLM_TIMEOUT_S=30
19
+
20
+ # --- ASR / TTS / storage (real implementations land in M2 / M3) ---
21
+ ASR_PROVIDER=fake
22
+ TTS_PROVIDER=fake
23
+ STORAGE_BACKEND=memory
24
+
25
+ # --- Gradio ---
26
+ HOST=0.0.0.0
27
+ PORT=7860
28
+ GRADIO_AUTH_USERNAME= # set both to enable basic auth (recruiter demo account)
29
+ GRADIO_AUTH_PASSWORD=
.github/workflows/ci.yml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ quality:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v5
19
+ with:
20
+ python-version: "3.12"
21
+ enable-cache: true
22
+
23
+ - name: Install dependencies (frozen)
24
+ run: uv sync --frozen
25
+
26
+ - name: Ruff format check
27
+ run: uv run ruff format --check .
28
+
29
+ - name: Ruff lint
30
+ run: uv run ruff check .
31
+
32
+ - name: Tests
33
+ run: uv run pytest
34
+
35
+ docker:
36
+ needs: quality
37
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
38
+ runs-on: ubuntu-latest
39
+ permissions:
40
+ contents: read
41
+ packages: write
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+
45
+ - uses: docker/setup-buildx-action@v3
46
+
47
+ - name: Log in to GHCR
48
+ uses: docker/login-action@v3
49
+ with:
50
+ registry: ghcr.io
51
+ username: ${{ github.actor }}
52
+ password: ${{ secrets.GITHUB_TOKEN }}
53
+
54
+ - name: Build & push
55
+ uses: docker/build-push-action@v6
56
+ with:
57
+ context: .
58
+ push: true
59
+ tags: |
60
+ ghcr.io/${{ github.repository }}:latest
61
+ ghcr.io/${{ github.repository }}:${{ github.sha }}
62
+ cache-from: type=gha
63
+ cache-to: type=gha,mode=max
.github/workflows/deploy-space.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to HF Space
2
+
3
+ # A Docker Space rebuilds from the Dockerfile inside the Space repo: it cannot
4
+ # pull the GHCR image directly. GHCR remains the CI artifact / portability story;
5
+ # this job syncs the exact same repo (same Dockerfile) to the Space.
6
+ #
7
+ # One-time setup:
8
+ # 1. Create a Docker Space on Hugging Face (SDK: Docker, hardware: cpu-basic).
9
+ # 2. GitHub repo -> Settings > Secrets : HF_TOKEN (write token)
10
+ # 3. GitHub repo -> Settings > Variables: HF_SPACE = "<hf-username>/<space-name>"
11
+ # 4. HF Space -> Settings > Variables/Secrets: LLM_PROVIDER, LLM_MODEL, LLM_API_KEY,
12
+ # GRADIO_AUTH_USERNAME, GRADIO_AUTH_PASSWORD
13
+
14
+ on:
15
+ workflow_dispatch:
16
+ push:
17
+ branches: [main]
18
+
19
+ jobs:
20
+ sync:
21
+ runs-on: ubuntu-latest
22
+ if: ${{ vars.HF_SPACE != '' }}
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ with:
26
+ fetch-depth: 0
27
+
28
+ - name: Push to Space
29
+ env:
30
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
31
+ HF_SPACE: ${{ vars.HF_SPACE }}
32
+ run: |
33
+ HF_USER="${HF_SPACE%%/*}"
34
+ git push --force "https://${HF_USER}:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE}" HEAD:main
.gitignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Secrets & env
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # Data, models, experiment tracking (ephemeral / heavy)
7
+ data/
8
+ models/
9
+ mlruns/
10
+ *.db
11
+ *.sqlite3
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ .venv/
17
+ dist/
18
+ build/
19
+ *.egg-info/
20
+
21
+ # Tooling caches
22
+ .pytest_cache/
23
+ .ruff_cache/
24
+ .coverage
25
+ htmlcov/
26
+
27
+ # Gradio
28
+ .gradio/
29
+ flagged/
30
+
31
+ # OS / IDE
32
+ .DS_Store
33
+ .idea/
34
+ .vscode/
.pre-commit-config.yaml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-added-large-files
10
+ args: ["--maxkb=1024"]
11
+ - id: detect-private-key
12
+
13
+ # rev mirrors the exact ruff version pinned in pyproject.toml ([dependency-groups].dev)
14
+ - repo: https://github.com/astral-sh/ruff-pre-commit
15
+ rev: v0.15.16
16
+ hooks:
17
+ - id: ruff-check
18
+ args: ["--fix"]
19
+ - id: ruff-format
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime image for HF Spaces (Docker SDK, cpu-basic) and GHCR.
2
+ # Heavy ML deps (torch, transformers) are deliberately absent until M1,
3
+ # and training deps never enter this image (see docs/adr/0001).
4
+ FROM python:3.12-slim-bookworm
5
+
6
+ # uv — pinned to the same version as local dev
7
+ COPY --from=ghcr.io/astral-sh/uv:0.11.20 /uv /uvx /bin/
8
+
9
+ ENV UV_COMPILE_BYTECODE=1 \
10
+ UV_LINK_MODE=copy \
11
+ UV_PYTHON_DOWNLOADS=never \
12
+ PYTHONUNBUFFERED=1
13
+
14
+ # Non-root user with uid 1000 (Hugging Face Spaces requirement)
15
+ RUN useradd -m -u 1000 app
16
+ WORKDIR /app
17
+
18
+ # Layer 1: dependencies only (cached as long as pyproject/uv.lock don't change)
19
+ COPY pyproject.toml uv.lock ./
20
+ RUN uv sync --frozen --no-install-project --no-dev
21
+
22
+ # Layer 2: project code
23
+ COPY README.md ./
24
+ COPY src ./src
25
+ RUN uv sync --frozen --no-dev && chown -R app:app /app
26
+
27
+ USER app
28
+ ENV PATH="/app/.venv/bin:$PATH"
29
+ EXPOSE 7860
30
+
31
+ CMD ["python", "-m", "tutor.app.main"]
LICENSE CHANGED
@@ -1,6 +1,6 @@
1
  MIT License
2
 
3
- Copyright (c) 2026 Arthur_Diaz
4
 
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
  of this software and associated documentation files (the "Software"), to deal
 
1
  MIT License
2
 
3
+ Copyright (c) 2026 Arthur
4
 
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
  of this software and associated documentation files (the "Software"), to deal
README.md CHANGED
@@ -1 +1,101 @@
1
- # polyglot-tutor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Polyglot Tutor
3
+ emoji: 🌍
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # 🌍 Polyglot Tutor
12
+
13
+ > Adaptive language tutor covering the four skills (reading, listening, writing, speaking),
14
+ > driven by a CEFR classifier and a predictive spaced-repetition learner model.
15
+ > Runs for free on a Hugging Face Space (CPU) with an optional local-GPU "premium" mode.
16
+
17
+ **Status: M0 — walking skeleton.** CI, Docker → GHCR, HF Space deploy and all service
18
+ contracts (LLM / ASR / TTS / storage) are live; features land milestone by milestone.
19
+
20
+ <!-- The YAML front-matter above is Hugging Face Space metadata: this repo is synced
21
+ as-is to a Docker Space by .github/workflows/deploy-space.yml -->
22
+
23
+ ## TL;DR (for recruiters)
24
+
25
+ - **ML core**: fine-tuned multilingual CEFR (A1–C2) text classifier benchmarked against a
26
+ published baseline; FSRS-style spaced repetition evaluated offline on public review logs;
27
+ LLM-generated exercises gated by an LLM-as-judge.
28
+ - **Engineering**: provider-agnostic services (`typing.Protocol` + env-driven factories),
29
+ pinned toolchain (uv, ruff, pre-commit), tested fakes, CI → GHCR, free-tier deployment with
30
+ documented constraints (ephemeral disk, cold starts, rate limits).
31
+ - **Honest evals**: every milestone ships metrics with caveats, tracked in MLflow
32
+ (see `docs/evals/`).
33
+
34
+ ## Architecture
35
+
36
+ ```mermaid
37
+ flowchart LR
38
+ UI[Gradio UI<br/>4 skill tabs] --> EX[Exercise generators<br/>+ cache]
39
+ EX --> LLM[LLMClient<br/>Gemini / Mistral / Ollama / fake]
40
+ EX --> ASR[ASRClient<br/>faster-whisper / remote / fake]
41
+ EX --> TTS[TTSClient<br/>edge-tts / local / fake]
42
+ EX --> CEFR[CEFR classifier<br/>ONNX int8, CPU]
43
+ UI --> SRS[Learner model<br/>FSRS + ability estimate]
44
+ SRS --> DB[(Repository<br/>memory / SQLite / Supabase)]
45
+ EX --> DB
46
+ ```
47
+
48
+ Each box on the right is a `Protocol` with swappable implementations selected by environment
49
+ variables — the same image runs as the free Space ("light") or against a local GPU box over
50
+ Tailscale ("premium"). See `docs/adr/` for the reasoning.
51
+
52
+ ## Quickstart (dev)
53
+
54
+ ```bash
55
+ uv sync # runtime + dev deps (pinned via uv.lock)
56
+ uv run pre-commit install
57
+ cp .env.example .env # defaults to the offline fake provider
58
+ uv run python -m tutor.app.main # http://localhost:7860
59
+ uv run pytest
60
+ ```
61
+
62
+ To talk to a real LLM, set in `.env`: `LLM_PROVIDER=gemini`, `LLM_API_KEY=...`
63
+ (free key at https://aistudio.google.com/apikey), then check the *Diagnostics* tab → *Ping LLM*.
64
+
65
+ Dataset download / EDA (kept out of the runtime image):
66
+
67
+ ```bash
68
+ uv run --group data python scripts/download_data.py
69
+ ```
70
+
71
+ ## Deployment
72
+
73
+ 1. **CI (GitHub Actions)** — lint + tests on every push/PR; on `main`, the Docker image is
74
+ pushed to `ghcr.io/<owner>/polyglot-tutor`.
75
+ 2. **HF Space (Docker, cpu-basic)** — create the Space once, then set:
76
+ - GitHub → *Secrets*: `HF_TOKEN` (write) · *Variables*: `HF_SPACE=<user>/<space>`
77
+ - Space → *Settings*: `LLM_PROVIDER`, `LLM_MODEL`, `LLM_API_KEY`,
78
+ `GRADIO_AUTH_USERNAME`, `GRADIO_AUTH_PASSWORD`
79
+ Every push to `main` syncs the repo to the Space, which rebuilds from the same Dockerfile
80
+ (a Docker Space cannot pull the GHCR image — GHCR is the CI artifact, see ADR 0001).
81
+
82
+ ## Roadmap
83
+
84
+ | Milestone | Scope | Headline eval |
85
+ |---|---|---|
86
+ | **M0** ✅ | Walking skeleton: CI, GHCR, Space, Protocols + fakes | CI green, live Space |
87
+ | **M1** | CEFR classifier + reading comprehension (LLM questions, cached & judge-gated) | macro-F1 / adjacent acc. / QWK vs published UniversalCEFR baseline |
88
+ | **M2** | TTS + dictation with light ASR | WER (incl. non-native audio) justifying tiny vs small |
89
+ | **M3** | Supabase persistence + writing correction (typed errors via ERRANT, per-learner profile) | error-type P/R on W&I+LOCNESS sample |
90
+ | **M4** | Learner model: FSRS scheduling + ability estimate, next-exercise policy | AUC / log-loss / calibration on public Anki review logs |
91
+ | **M5** | Pronunciation scoring + premium local-GPU mode (Tailscale) | correlation with expert scores on speechocean762 |
92
+ | **M6** (stretch) | Real-time voice conversation tab (Gemini Live API behind a ConversationClient Protocol) | qualitative demo; latency budget documented |
93
+
94
+ ## Data & licensing
95
+
96
+ This is a **non-commercial portfolio/demo project**. CEFR-labeled corpora come from
97
+ [UniversalCEFR](https://huggingface.co/UniversalCEFR) (per-subset, mostly research-only
98
+ licenses — original papers cited); SRS benchmarks use the open Anki review-log datasets;
99
+ pronunciation evaluation uses speechocean762 (free for any use). No licensed text is
100
+ committed to this repo; `data/` is gitignored and rebuilt by `scripts/download_data.py`.
101
+ Details and caveats: `docs/adr/0003-datasets-and-licensing.md`.
docs/adr/0001-stack-and-architecture.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR 0001 — Stack & architecture de base
2
+
3
+ - **Statut** : accepté (M0)
4
+ - **Date** : 2026-06-11
5
+
6
+ ## Contexte
7
+ Tuteur de langue adaptatif (4 compétences, modèle d'apprenant prédictif), déployé gratuitement
8
+ sur HF Spaces `cpu-basic` (2 vCPU / 16 GB / disque éphémère), avec un mode "premium" optionnel
9
+ sur GPU local (WSL2, RTX 3070) accessible via Tailscale. Budget ~0–10 €/mois.
10
+
11
+ ## Décisions
12
+ 1. **Python 3.12 + uv**, src-layout, ruff (lint + format) via pre-commit, pytest. Versions
13
+ pinnées : `uv.lock` (exact), `ruff` épinglé à l'identique dans pyproject et pre-commit.
14
+ 2. **Protocols + fakes d'abord** : `LLMClient`, `ASRClient`, `TTSClient`, `Repository` sont des
15
+ `typing.Protocol`. Chaque backend réel (API/local) est interchangeable par variable d'env ;
16
+ les fakes rendent l'app démarrable et testable sans aucun secret ni réseau.
17
+ 3. **`training/` hors du paquet runtime** : le fine-tuning (XLM-R, M1) tourne sur le GPU local ;
18
+ l'image Docker du Space n'embarque jamais torch-CUDA. L'inférence du classifieur sera
19
+ exportée ONNX int8 pour le CPU du Space.
20
+ 4. **Déploiement** : un Docker Space rebuild depuis le Dockerfile du repo Space (impossible de
21
+ pointer une image externe) → GHCR = artefact CI/portabilité, et un workflow synchronise le
22
+ même repo vers le Space. Image non-root (uid 1000), port 7860, front-matter HF dans README.
23
+ 5. **Modèle d'apprenant multilingue par construction** : `target_lang` est une donnée
24
+ (domaine + config), pas une constante — l'anglais est la cible de lancement, l'ajout d'une
25
+ langue ne doit exiger ni migration ni refonte.
26
+
27
+ ## Conséquences
28
+ - Cold start Space (mise en veille après inactivité) → lazy-loading des modèles (M1+).
29
+ - edge-tts (API non officielle) impose un fallback TTS local derrière le Protocol (M2).
30
+ - Les fakes servent aussi de documentation exécutable des contrats.
docs/adr/0002-llm-provider-strategy.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR 0002 — Stratégie provider LLM : un client OpenAI-compatible unique
2
+
3
+ - **Statut** : accepté (M0)
4
+ - **Date** : 2026-06-11
5
+
6
+ ## Contexte
7
+ Le projet exige de basculer API ↔ local par variable d'env. Gemini (endpoint de compatibilité
8
+ OpenAI), Mistral et Ollama exposent tous une API chat-completions compatible OpenAI.
9
+
10
+ ## Décision
11
+ Un seul client `OpenAICompatClient(model, api_key, base_url)` couvre les quatre providers via
12
+ des presets de `base_url` ; `LLM_PROVIDER=fake` reste le défaut zéro-config (tests, CI, premier
13
+ boot du Space). **Provider principal : Gemini Flash** (free tier ~1 500 req/jour sur les
14
+ modèles Flash, soit un budget ~0 €).
15
+
16
+ ## Conséquences / caveats
17
+ - Free tier Gemini : ~10–15 RPM et données potentiellement utilisées pour l'entraînement →
18
+ données de démo uniquement, et retry/backoff sur 429 à implémenter en M1 (avec cache
19
+ d'exercices pour minimiser les appels).
20
+ - Le nom de modèle est entièrement piloté par `LLM_MODEL` (les noms bougent : 2.5-flash,
21
+ 3-flash…) — aucun nom de modèle codé en dur.
22
+ - Les fonctionnalités spécifiques (structured output natif, etc.) pourront justifier des
23
+ implémentations dédiées derrière le même Protocol, sans toucher au reste du code.
docs/adr/0003-datasets-and-licensing.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR 0003 — Datasets & licences
2
+
3
+ - **Statut** : accepté (M0) — mix d'entraînement M1 à finaliser après EDA
4
+ - **Date** : 2026-06-11
5
+
6
+ ## Décisions
7
+ | Besoin | Source | Licence / accès |
8
+ |---|---|---|
9
+ | Classif CECRL (M1) | **UniversalCEFR** (HF org), sous-jeux EN `reference` (cefr_sp_en, readme_en, cambridge-exams, …) | non-commercial research, par sous-corpus ; citer les papiers sources |
10
+ | Baselines M1 | `UniversalCEFR/xlm-roberta-base-cefr-all-classifier` (+ ModernBERT) | comparaison directe à une baseline publiée |
11
+ | Éval correction écrite (M3) | ERRANT (typage auto, EN) + W&I+LOCNESS (BEA-2019) | W&I : ToU séparées à accepter |
12
+ | Benchmark SRS offline (M4) | `open-spaced-repetition/anki-revlogs-10k` (échantillon ~200–500 users) ; HLR vs FSRS via srs-benchmark | licence dédiée FSRS-Anki ; caveat : Anki ≠ notre app (domain shift documenté) |
13
+ | Prononciation (M5) + WER non-natif (M2) | **speechocean762** (OpenSLR 101) | libre, y compris usage commercial |
14
+
15
+ ## Conséquences
16
+ - Le projet est **explicitement non commercial** (portfolio/démo) : compatible avec les
17
+ licences research-only ; section "Data & licensing" du README à maintenir en phase.
18
+ - ERRANT étant anglais-only, le typage d'erreurs pour d'autres langues cibles passera par un
19
+ rubric LLM derrière le même contrat de service.
20
+ - Aucun texte sous licence restrictive n'est commité dans le repo (`data/` gitignoré),
21
+ uniquement des scripts de téléchargement reproductibles.
docs/evals/.gitkeep ADDED
File without changes
evals/README.md ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # evals/
2
+
3
+ Evaluation harness (MLflow tracking on local SQLite, LLM-as-judge for generated exercises).
4
+ First real content lands with M1; per-milestone reports go to docs/evals/.
pre-commit-config.yaml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-added-large-files
10
+ args: ["--maxkb=1024"]
11
+ - id: detect-private-key
12
+
13
+ # rev mirrors the exact ruff version pinned in pyproject.toml ([dependency-groups].dev)
14
+ - repo: https://github.com/astral-sh/ruff-pre-commit
15
+ rev: v0.15.16
16
+ hooks:
17
+ - id: ruff-check
18
+ args: ["--fix"]
19
+ - id: ruff-format
pyproject.toml ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "polyglot-tutor"
3
+ version = "0.1.0"
4
+ description = "Adaptive language tutor: CEFR-aware exercises across the four skills, driven by a predictive spaced-repetition learner model."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [{ name = "Arthur" }]
9
+ dependencies = [
10
+ # Runtime deps only — keep the Docker image (HF Space, cpu-basic) lean.
11
+ "gradio>=6.17,<7",
12
+ "openai>=2.41,<3", # single OpenAI-compatible client: Gemini / Mistral / Ollama / OpenAI (ADR 0002)
13
+ "pydantic>=2.8",
14
+ "pydantic-settings>=2.14,<3",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=9.0",
20
+ "pytest-asyncio>=1.4",
21
+ "ruff==0.15.16", # exact pin, mirrored in .pre-commit-config.yaml
22
+ "pre-commit>=4.6",
23
+ ]
24
+ data = [
25
+ # Dataset download / EDA (UniversalCEFR & friends). Not in the runtime image.
26
+ "datasets>=5.0",
27
+ ]
28
+ train = [
29
+ # M1: transformers, torch (CUDA, local GPU), optimum/onnxruntime for CPU export.
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/tutor"]
38
+
39
+ [tool.ruff]
40
+ target-version = "py312"
41
+ line-length = 100
42
+ src = ["src", "tests", "scripts"]
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "E", "W", # pycodestyle
47
+ "F", # pyflakes
48
+ "I", # isort
49
+ "UP", # pyupgrade
50
+ "B", # bugbear
51
+ "SIM", # simplify
52
+ "C4", # comprehensions
53
+ "PTH", # pathlib
54
+ "RET", # return
55
+ "ASYNC", # async pitfalls
56
+ "RUF",
57
+ ]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ asyncio_mode = "auto"
62
+ addopts = "-q"
scripts/download_data.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download UniversalCEFR subsets and print a quick EDA (level/granularity stats).
2
+
3
+ UniversalCEFR (Imperial et al., EMNLP 2025) standardises 26 CEFR-labeled
4
+ corpora across 13 languages into one JSON schema:
5
+ https://huggingface.co/UniversalCEFR
6
+
7
+ Usage (deps live in the "data" group, kept out of the runtime image):
8
+ uv run --group data python scripts/download_data.py
9
+ uv run --group data python scripts/download_data.py --datasets UniversalCEFR/readme_fr --save
10
+
11
+ Notes:
12
+ - Default subsets below are confirmed English sets; the final M1 training mix
13
+ (e.g. cambridge-exams for document-level texts) is decided in ADR 0003 after
14
+ reviewing the org's data directory.
15
+ - Licenses are per-subset and mostly non-commercial research: keep the README
16
+ licensing section in sync with whatever lands here.
17
+ """
18
+
19
+ import argparse
20
+ from collections import Counter
21
+ from pathlib import Path
22
+
23
+ from datasets import load_dataset
24
+
25
+ DEFAULT_DATASETS = [
26
+ "UniversalCEFR/cefr_sp_en", # sentence-level, expert-annotated (CEFR-SP)
27
+ "UniversalCEFR/readme_en", # document-level readability (ReadMe++)
28
+ ]
29
+
30
+ RAW_DIR = Path("data/raw")
31
+
32
+
33
+ def summarize(dataset_id: str, save: bool) -> None:
34
+ dataset = load_dataset(dataset_id, split="train")
35
+ levels = Counter(dataset["cefr_level"])
36
+ formats = Counter(dataset["format"]) if "format" in dataset.column_names else {}
37
+ categories = Counter(dataset["category"]) if "category" in dataset.column_names else {}
38
+
39
+ print(f"\n=== {dataset_id} — {len(dataset)} rows ===")
40
+ print(f" levels : {dict(sorted(levels.items()))}")
41
+ if formats:
42
+ print(f" formats : {dict(formats)}")
43
+ if categories:
44
+ print(f" categories: {dict(categories)}")
45
+
46
+ if save:
47
+ RAW_DIR.mkdir(parents=True, exist_ok=True)
48
+ out_path = RAW_DIR / f"{dataset_id.split('/')[-1]}.parquet"
49
+ dataset.to_parquet(out_path)
50
+ print(f" saved -> {out_path}")
51
+
52
+
53
+ def main() -> None:
54
+ parser = argparse.ArgumentParser(description=__doc__)
55
+ parser.add_argument("--datasets", nargs="+", default=DEFAULT_DATASETS)
56
+ parser.add_argument(
57
+ "--save", action="store_true", help="save each subset to data/raw/*.parquet"
58
+ )
59
+ args = parser.parse_args()
60
+
61
+ for dataset_id in args.datasets:
62
+ summarize(dataset_id, save=args.save)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
src/tutor/__init__.py ADDED
File without changes
src/tutor/app/__init__.py ADDED
File without changes
src/tutor/app/main.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio entrypoint — M0 walking skeleton.
2
+
3
+ One tab per skill (wired milestone by milestone) plus a Diagnostics tab that
4
+ shows the resolved configuration and can ping the configured LLM, which makes
5
+ the deployed Space verifiable end to end from day one.
6
+ """
7
+
8
+ import gradio as gr
9
+
10
+ from tutor.config import Settings, get_settings
11
+ from tutor.services.llm.base import ChatMessage, LLMError
12
+ from tutor.services.llm.factory import create_llm_client
13
+
14
+
15
+ def build_app(settings: Settings | None = None) -> gr.Blocks:
16
+ settings = settings or get_settings()
17
+ llm = create_llm_client(settings)
18
+
19
+ async def ping_llm() -> str:
20
+ try:
21
+ response = await llm.complete(
22
+ [ChatMessage(role="user", content="Reply with exactly one word: pong")],
23
+ temperature=0.0,
24
+ max_tokens=16,
25
+ )
26
+ except LLMError as exc:
27
+ return f"❌ {exc}"
28
+ return f"✅ [{response.model}] {response.text.strip()}"
29
+
30
+ with gr.Blocks(title="Polyglot Tutor") as app:
31
+ gr.Markdown(
32
+ "# 🌍 Polyglot Tutor\n"
33
+ f"Adaptive language tutor — learning **{settings.default_target_lang}** "
34
+ f"from **{settings.default_source_lang}**. *M0 walking skeleton.*"
35
+ )
36
+
37
+ with gr.Tab("📖 Reading"):
38
+ gr.Markdown("CEFR-graded texts + generated comprehension questions — **M1**.")
39
+ with gr.Tab("🎧 Listening"):
40
+ gr.Markdown("TTS audio + dictation with ASR scoring — **M2**.")
41
+ with gr.Tab("✍️ Writing"):
42
+ gr.Markdown("LLM correction with typed errors, per-learner error profile — **M3**.")
43
+ with gr.Tab("🗣️ Speaking"):
44
+ gr.Markdown("Read-aloud exercises with pronunciation scoring — **M5**.")
45
+
46
+ with gr.Tab("⚙️ Diagnostics"):
47
+ gr.Markdown(
48
+ f"- env: `{settings.app_env}`\n"
49
+ f"- LLM: `{settings.llm_provider}` / `{settings.llm_model}`\n"
50
+ f"- ASR: `{settings.asr_provider}` · TTS: `{settings.tts_provider}` · "
51
+ f"storage: `{settings.storage_backend}`"
52
+ )
53
+ ping_button = gr.Button("Ping LLM", variant="primary")
54
+ ping_output = gr.Textbox(label="LLM response", interactive=False)
55
+ ping_button.click(ping_llm, outputs=ping_output)
56
+
57
+ return app
58
+
59
+
60
+ def main() -> None:
61
+ settings = get_settings()
62
+ auth: tuple[str, str] | None = None
63
+ if settings.gradio_auth_username and settings.gradio_auth_password:
64
+ auth = (
65
+ settings.gradio_auth_username,
66
+ settings.gradio_auth_password.get_secret_value(),
67
+ )
68
+ build_app(settings).launch(
69
+ server_name=settings.host,
70
+ server_port=settings.port,
71
+ auth=auth,
72
+ )
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
src/tutor/config.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralised, environment-driven configuration.
2
+
3
+ Every provider choice (LLM, ASR, TTS, storage) is a plain env var so the same
4
+ image runs as the free HF Space ("light" mode) or against the local GPU box
5
+ ("premium" mode) without code changes.
6
+
7
+ The `Literal` types are intentionally restricted to *implemented* providers:
8
+ adding an implementation means widening the Literal, so the config can never
9
+ silently point at a backend that does not exist yet.
10
+ """
11
+
12
+ from functools import lru_cache
13
+ from typing import Literal
14
+
15
+ from pydantic import SecretStr
16
+ from pydantic_settings import BaseSettings, SettingsConfigDict
17
+
18
+
19
+ class Settings(BaseSettings):
20
+ model_config = SettingsConfigDict(
21
+ env_file=".env",
22
+ env_file_encoding="utf-8",
23
+ extra="ignore",
24
+ )
25
+
26
+ # --- App ---
27
+ app_env: Literal["dev", "prod"] = "dev"
28
+ log_level: str = "INFO"
29
+
30
+ # --- Languages ---
31
+ default_source_lang: str = "fr"
32
+ default_target_lang: str = "en"
33
+
34
+ # --- LLM ---
35
+ llm_provider: Literal["fake", "gemini", "openai", "mistral", "ollama"] = "fake"
36
+ llm_model: str = "gemini-2.5-flash"
37
+ llm_api_key: SecretStr | None = None
38
+ llm_base_url: str | None = None
39
+ llm_timeout_s: float = 30.0
40
+
41
+ # --- ASR / TTS / storage (implementations land in M2 / M3) ---
42
+ asr_provider: Literal["fake"] = "fake"
43
+ tts_provider: Literal["fake"] = "fake"
44
+ storage_backend: Literal["memory"] = "memory"
45
+
46
+ # --- Gradio ---
47
+ host: str = "0.0.0.0"
48
+ port: int = 7860
49
+ gradio_auth_username: str | None = None
50
+ gradio_auth_password: SecretStr | None = None
51
+
52
+
53
+ @lru_cache
54
+ def get_settings() -> Settings:
55
+ """Process-wide settings singleton (tests build `Settings` directly instead)."""
56
+ return Settings()
src/tutor/domain/__init__.py ADDED
File without changes
src/tutor/domain/models.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core domain entities, persistence- and framework-agnostic.
2
+
3
+ Languages are first-class data (`source_lang` / `target_lang`, ISO 639-1) rather
4
+ than constants: English is the launch target, but the model must not need a
5
+ migration to add Spanish or German later.
6
+ """
7
+
8
+ from datetime import UTC, datetime
9
+ from enum import StrEnum
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ def _new_id() -> str:
17
+ return uuid4().hex
18
+
19
+
20
+ def _now() -> datetime:
21
+ return datetime.now(UTC)
22
+
23
+
24
+ class CEFRLevel(StrEnum):
25
+ A1 = "A1"
26
+ A2 = "A2"
27
+ B1 = "B1"
28
+ B2 = "B2"
29
+ C1 = "C1"
30
+ C2 = "C2"
31
+
32
+ @property
33
+ def rank(self) -> int:
34
+ """0-based ordinal position (A1=0 ... C2=5), for ordinal metrics and adjacency."""
35
+ return list(CEFRLevel).index(self)
36
+
37
+ def distance(self, other: "CEFRLevel") -> int:
38
+ """Absolute level distance; `<= 1` is the classic 'adjacent accuracy' criterion."""
39
+ return abs(self.rank - other.rank)
40
+
41
+
42
+ class ExerciseType(StrEnum):
43
+ READING_QA = "reading_qa" # M1 — comprehension questions on a CEFR-graded text
44
+ DICTATION = "dictation" # M2 — TTS audio, learner types what they hear
45
+ WRITING = "writing" # M3 — free writing, LLM correction with typed errors
46
+ PRONUNCIATION = "pronunciation" # M5 — read-aloud with pronunciation scoring
47
+
48
+
49
+ class Learner(BaseModel):
50
+ id: str = Field(default_factory=_new_id)
51
+ display_name: str
52
+ source_lang: str = "fr"
53
+ target_lang: str = "en"
54
+ created_at: datetime = Field(default_factory=_now)
55
+
56
+
57
+ class Exercise(BaseModel):
58
+ """A generated, cacheable exercise.
59
+
60
+ `payload` is type-specific content (text, questions, audio reference...).
61
+ Generated exercises are content-addressed upstream (hash of source text +
62
+ prompt version) so LLM cost stays ~0 and evals are reproducible.
63
+ """
64
+
65
+ id: str = Field(default_factory=_new_id)
66
+ type: ExerciseType
67
+ target_lang: str
68
+ cefr_level: CEFRLevel
69
+ payload: dict[str, Any] = Field(default_factory=dict)
70
+ created_at: datetime = Field(default_factory=_now)
71
+
72
+
73
+ class Attempt(BaseModel):
74
+ """One learner answer to one exercise, with its scoring/feedback."""
75
+
76
+ id: str = Field(default_factory=_new_id)
77
+ learner_id: str
78
+ exercise_id: str
79
+ response: dict[str, Any] = Field(default_factory=dict)
80
+ score: float | None = Field(default=None, ge=0.0, le=1.0)
81
+ feedback: dict[str, Any] = Field(default_factory=dict)
82
+ created_at: datetime = Field(default_factory=_now)
83
+
84
+
85
+ class ReviewItem(BaseModel):
86
+ """An atomic, schedulable knowledge item (vocab word, recurring error, ...).
87
+
88
+ `scheduler_state` is an opaque dict owned by the SRS engine (FSRS-ready in
89
+ M4) so the domain model is not coupled to one algorithm's parameters.
90
+ """
91
+
92
+ id: str = Field(default_factory=_new_id)
93
+ learner_id: str
94
+ kind: str # "vocab" | "error" | ... (free-form until M4 hardens it)
95
+ content: dict[str, Any] = Field(default_factory=dict)
96
+ due_at: datetime | None = None
97
+ scheduler_state: dict[str, Any] = Field(default_factory=dict)
98
+ created_at: datetime = Field(default_factory=_now)
src/tutor/exercises/__init__.py ADDED
File without changes
src/tutor/ml/__init__.py ADDED
File without changes
src/tutor/ml/cefr/__init__.py ADDED
File without changes
src/tutor/ml/pronunciation/__init__.py ADDED
File without changes
src/tutor/ml/srs/__init__.py ADDED
File without changes
src/tutor/services/__init__.py ADDED
File without changes
src/tutor/services/asr/__init__.py ADDED
File without changes
src/tutor/services/asr/base.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ASR client contract + offline fake.
2
+
3
+ Real implementations land in M2 (faster-whisper tiny/small, int8, on the Space
4
+ CPU) and M5 (remote large model on the local GPU box). The fake keeps the UI
5
+ and tests fully wired in the meantime.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from pydantic import BaseModel
12
+
13
+
14
+ class Transcription(BaseModel):
15
+ text: str
16
+ language: str | None = None
17
+
18
+
19
+ @runtime_checkable
20
+ class ASRClient(Protocol):
21
+ async def transcribe(
22
+ self, audio_path: Path, *, language: str | None = None
23
+ ) -> Transcription: ...
24
+
25
+
26
+ class FakeASRClient:
27
+ def __init__(self, text: str = "(fake transcription)") -> None:
28
+ self._text = text
29
+ self.calls: list[Path] = []
30
+
31
+ async def transcribe(self, audio_path: Path, *, language: str | None = None) -> Transcription:
32
+ self.calls.append(audio_path)
33
+ return Transcription(text=self._text, language=language)
src/tutor/services/llm/__init__.py ADDED
File without changes
src/tutor/services/llm/base.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM client contract.
2
+
3
+ Anything that can turn a chat into text satisfies `LLMClient` — the app, the
4
+ exercise generators and the evals only ever depend on this Protocol, never on
5
+ a vendor SDK (same pattern for ASR / TTS / storage).
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+ from typing import Literal, Protocol, runtime_checkable
10
+
11
+ from pydantic import BaseModel
12
+
13
+ Role = Literal["system", "user", "assistant"]
14
+
15
+
16
+ class ChatMessage(BaseModel):
17
+ role: Role
18
+ content: str
19
+
20
+
21
+ class LLMResponse(BaseModel):
22
+ text: str
23
+ model: str
24
+ input_tokens: int | None = None
25
+ output_tokens: int | None = None
26
+
27
+
28
+ class LLMError(RuntimeError):
29
+ """A completion failed (network, auth, rate limit, provider error...)."""
30
+
31
+
32
+ @runtime_checkable
33
+ class LLMClient(Protocol):
34
+ async def complete(
35
+ self,
36
+ messages: Sequence[ChatMessage],
37
+ *,
38
+ temperature: float = 0.7,
39
+ max_tokens: int = 1024,
40
+ ) -> LLMResponse: ...
src/tutor/services/llm/factory.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider resolution: Settings -> LLMClient."""
2
+
3
+ from tutor.config import Settings
4
+ from tutor.services.llm.base import LLMClient
5
+ from tutor.services.llm.fake import FakeLLMClient
6
+ from tutor.services.llm.openai_compat import OpenAICompatClient
7
+
8
+ GEMINI_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
9
+
10
+ _PRESET_BASE_URLS: dict[str, str | None] = {
11
+ "gemini": GEMINI_OPENAI_BASE_URL,
12
+ "mistral": "https://api.mistral.ai/v1",
13
+ "ollama": "http://localhost:11434/v1", # premium mode: reached over Tailscale in M5
14
+ "openai": None, # SDK default
15
+ }
16
+
17
+
18
+ def create_llm_client(settings: Settings) -> LLMClient:
19
+ if settings.llm_provider == "fake":
20
+ return FakeLLMClient()
21
+
22
+ api_key = settings.llm_api_key.get_secret_value() if settings.llm_api_key else None
23
+ if settings.llm_provider == "ollama":
24
+ api_key = api_key or "ollama" # local servers accept any non-empty key
25
+ if not api_key:
26
+ msg = f"LLM_API_KEY is required for provider '{settings.llm_provider}'"
27
+ raise ValueError(msg)
28
+
29
+ return OpenAICompatClient(
30
+ model=settings.llm_model,
31
+ api_key=api_key,
32
+ base_url=settings.llm_base_url or _PRESET_BASE_URLS[settings.llm_provider],
33
+ timeout_s=settings.llm_timeout_s,
34
+ )
src/tutor/services/llm/fake.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, offline LLM client.
2
+
3
+ Used by the test suite and as the zero-config default provider, so the app
4
+ boots (locally, in CI, on a fresh Space) without any API key.
5
+ """
6
+
7
+ from collections.abc import Sequence
8
+
9
+ from tutor.services.llm.base import ChatMessage, LLMResponse
10
+
11
+
12
+ class FakeLLMClient:
13
+ def __init__(self, responses: Sequence[str] | None = None) -> None:
14
+ self._responses: list[str] = list(responses or [])
15
+ self.calls: list[list[ChatMessage]] = []
16
+
17
+ async def complete(
18
+ self,
19
+ messages: Sequence[ChatMessage],
20
+ *,
21
+ temperature: float = 0.7,
22
+ max_tokens: int = 1024,
23
+ ) -> LLMResponse:
24
+ self.calls.append(list(messages))
25
+ text = self._responses.pop(0) if self._responses else "(fake) pong"
26
+ return LLMResponse(text=text, model="fake", input_tokens=0, output_tokens=0)
src/tutor/services/llm/openai_compat.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One client, four providers.
2
+
3
+ Gemini, Mistral and Ollama all expose OpenAI-compatible chat-completions
4
+ endpoints, so a single implementation parameterised by `base_url` covers the
5
+ whole "light API / premium local" matrix (ADR 0002). Provider-specific
6
+ features (native structured output, etc.) can later get dedicated
7
+ implementations behind the same `LLMClient` Protocol.
8
+ """
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from openai import AsyncOpenAI, OpenAIError
13
+
14
+ from tutor.services.llm.base import ChatMessage, LLMError, LLMResponse
15
+
16
+
17
+ class OpenAICompatClient:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ model: str,
22
+ api_key: str,
23
+ base_url: str | None = None,
24
+ timeout_s: float = 30.0,
25
+ ) -> None:
26
+ self.model = model
27
+ self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=timeout_s)
28
+
29
+ async def complete(
30
+ self,
31
+ messages: Sequence[ChatMessage],
32
+ *,
33
+ temperature: float = 0.7,
34
+ max_tokens: int = 1024,
35
+ ) -> LLMResponse:
36
+ try:
37
+ response = await self._client.chat.completions.create(
38
+ model=self.model,
39
+ messages=[m.model_dump() for m in messages], # type: ignore[misc]
40
+ temperature=temperature,
41
+ max_tokens=max_tokens,
42
+ )
43
+ except OpenAIError as exc:
44
+ raise LLMError(f"LLM call failed (model={self.model}): {exc}") from exc
45
+
46
+ usage = response.usage
47
+ return LLMResponse(
48
+ text=response.choices[0].message.content or "",
49
+ model=response.model,
50
+ input_tokens=usage.prompt_tokens if usage else None,
51
+ output_tokens=usage.completion_tokens if usage else None,
52
+ )
src/tutor/services/storage/__init__.py ADDED
File without changes
src/tutor/services/storage/base.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistence contract.
2
+
3
+ The surface is deliberately minimal at M0 (learners + attempts) and grows with
4
+ real needs: review items and error profiles arrive with M3/M4, when the
5
+ SQLite/Supabase implementations replace the in-memory one behind this same
6
+ Protocol.
7
+ """
8
+
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from tutor.domain.models import Attempt, Learner
12
+
13
+
14
+ @runtime_checkable
15
+ class Repository(Protocol):
16
+ async def save_learner(self, learner: Learner) -> None: ...
17
+
18
+ async def get_learner(self, learner_id: str) -> Learner | None: ...
19
+
20
+ async def save_attempt(self, attempt: Attempt) -> None: ...
21
+
22
+ async def list_attempts(self, learner_id: str) -> list[Attempt]: ...
src/tutor/services/storage/memory.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory Repository: M0 default, and the reference fake for tests.
2
+
3
+ Single-process only (fine for one Gradio app); durable backends (SQLite local,
4
+ Supabase Postgres on the Space) land in M3.
5
+ """
6
+
7
+ from tutor.domain.models import Attempt, Learner
8
+
9
+
10
+ class InMemoryRepository:
11
+ def __init__(self) -> None:
12
+ self._learners: dict[str, Learner] = {}
13
+ self._attempts: dict[str, Attempt] = {}
14
+
15
+ async def save_learner(self, learner: Learner) -> None:
16
+ self._learners[learner.id] = learner
17
+
18
+ async def get_learner(self, learner_id: str) -> Learner | None:
19
+ return self._learners.get(learner_id)
20
+
21
+ async def save_attempt(self, attempt: Attempt) -> None:
22
+ self._attempts[attempt.id] = attempt
23
+
24
+ async def list_attempts(self, learner_id: str) -> list[Attempt]:
25
+ attempts = [a for a in self._attempts.values() if a.learner_id == learner_id]
26
+ return sorted(attempts, key=lambda a: a.created_at)
src/tutor/services/tts/__init__.py ADDED
File without changes
src/tutor/services/tts/base.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TTS client contract + offline fake.
2
+
3
+ Real implementations land in M2: edge-tts as primary (unofficial API, hence
4
+ fragile) plus a local fallback behind this same Protocol (ADR 0001).
5
+
6
+ The fake returns a *valid* WAV (short silence) so audio components and tests
7
+ exercise the real byte path end to end.
8
+ """
9
+
10
+ import io
11
+ import wave
12
+ from typing import Protocol, runtime_checkable
13
+
14
+ from pydantic import BaseModel
15
+
16
+
17
+ class TTSResult(BaseModel):
18
+ audio: bytes
19
+ format: str = "wav"
20
+ voice: str | None = None
21
+
22
+
23
+ @runtime_checkable
24
+ class TTSClient(Protocol):
25
+ async def synthesize(
26
+ self, text: str, *, language: str = "en", voice: str | None = None
27
+ ) -> TTSResult: ...
28
+
29
+
30
+ def _silent_wav(duration_s: float = 0.2, sample_rate: int = 16_000) -> bytes:
31
+ buffer = io.BytesIO()
32
+ with wave.open(buffer, "wb") as wav:
33
+ wav.setnchannels(1)
34
+ wav.setsampwidth(2) # 16-bit PCM
35
+ wav.setframerate(sample_rate)
36
+ wav.writeframes(b"\x00\x00" * int(duration_s * sample_rate))
37
+ return buffer.getvalue()
38
+
39
+
40
+ class FakeTTSClient:
41
+ def __init__(self) -> None:
42
+ self.calls: list[str] = []
43
+
44
+ async def synthesize(
45
+ self, text: str, *, language: str = "en", voice: str | None = None
46
+ ) -> TTSResult:
47
+ self.calls.append(text)
48
+ return TTSResult(audio=_silent_wav(), format="wav", voice=voice or f"fake-{language}")
test ADDED
File without changes
tests/test_app.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+
3
+ from tutor.app.main import build_app
4
+ from tutor.config import Settings
5
+
6
+
7
+ def test_build_app_with_fake_provider() -> None:
8
+ settings = Settings(_env_file=None) # type: ignore[call-arg]
9
+ app = build_app(settings)
10
+ assert isinstance(app, gr.Blocks)
tests/test_config.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import SecretStr
2
+
3
+ from tutor.config import Settings
4
+
5
+
6
+ def _settings(**overrides: object) -> Settings:
7
+ # _env_file=None: ignore any local .env so tests are hermetic.
8
+ return Settings(_env_file=None, **overrides) # type: ignore[arg-type]
9
+
10
+
11
+ def test_defaults_are_offline_safe() -> None:
12
+ settings = _settings()
13
+ assert settings.llm_provider == "fake"
14
+ assert settings.storage_backend == "memory"
15
+ assert settings.port == 7860
16
+
17
+
18
+ def test_env_overrides(monkeypatch) -> None:
19
+ monkeypatch.setenv("LLM_PROVIDER", "gemini")
20
+ monkeypatch.setenv("LLM_MODEL", "gemini-2.5-flash")
21
+ monkeypatch.setenv("LLM_API_KEY", "sk-test")
22
+ settings = _settings()
23
+ assert settings.llm_provider == "gemini"
24
+ assert settings.llm_model == "gemini-2.5-flash"
25
+ assert isinstance(settings.llm_api_key, SecretStr)
26
+
27
+
28
+ def test_api_key_is_not_leaked_in_repr() -> None:
29
+ settings = _settings(llm_api_key=SecretStr("sk-very-secret"))
30
+ assert "sk-very-secret" not in repr(settings)
31
+ assert "sk-very-secret" not in str(settings)
tests/test_llm.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from tutor.config import Settings
4
+ from tutor.services.llm.base import ChatMessage, LLMClient
5
+ from tutor.services.llm.factory import GEMINI_OPENAI_BASE_URL, create_llm_client
6
+ from tutor.services.llm.fake import FakeLLMClient
7
+ from tutor.services.llm.openai_compat import OpenAICompatClient
8
+
9
+
10
+ def _settings(**overrides: object) -> Settings:
11
+ return Settings(_env_file=None, **overrides) # type: ignore[arg-type]
12
+
13
+
14
+ async def test_fake_client_is_deterministic_and_records_calls() -> None:
15
+ client = FakeLLMClient(responses=["first", "second"])
16
+ messages = [ChatMessage(role="user", content="hello")]
17
+
18
+ first = await client.complete(messages)
19
+ second = await client.complete(messages)
20
+ third = await client.complete(messages) # queue exhausted -> default
21
+
22
+ assert (first.text, second.text) == ("first", "second")
23
+ assert third.text == "(fake) pong"
24
+ assert len(client.calls) == 3
25
+ assert client.calls[0][0].content == "hello"
26
+
27
+
28
+ def test_fake_satisfies_protocol() -> None:
29
+ assert isinstance(FakeLLMClient(), LLMClient)
30
+
31
+
32
+ def test_factory_returns_fake_by_default() -> None:
33
+ client = create_llm_client(_settings())
34
+ assert isinstance(client, FakeLLMClient)
35
+
36
+
37
+ def test_factory_requires_api_key_for_remote_providers() -> None:
38
+ with pytest.raises(ValueError, match="LLM_API_KEY"):
39
+ create_llm_client(_settings(llm_provider="gemini"))
40
+
41
+
42
+ def test_factory_builds_openai_compat_client_with_gemini_preset() -> None:
43
+ settings = _settings(llm_provider="gemini", llm_api_key="sk-test", llm_model="gemini-2.5-flash")
44
+ client = create_llm_client(settings)
45
+ assert isinstance(client, OpenAICompatClient)
46
+ assert client.model == "gemini-2.5-flash"
47
+ assert str(client._client.base_url) == GEMINI_OPENAI_BASE_URL
48
+
49
+
50
+ def test_factory_allows_keyless_ollama() -> None:
51
+ client = create_llm_client(_settings(llm_provider="ollama", llm_model="llama3"))
52
+ assert isinstance(client, OpenAICompatClient)
tests/test_services.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import wave
3
+ from pathlib import Path
4
+
5
+ from tutor.domain.models import Attempt, CEFRLevel, Learner
6
+ from tutor.services.asr.base import FakeASRClient
7
+ from tutor.services.storage.memory import InMemoryRepository
8
+ from tutor.services.tts.base import FakeTTSClient
9
+
10
+
11
+ def test_cefr_level_ordering() -> None:
12
+ assert CEFRLevel.A1.rank == 0
13
+ assert CEFRLevel.C2.rank == 5
14
+ assert CEFRLevel.B1.distance(CEFRLevel.B2) == 1
15
+ assert CEFRLevel.A1.distance(CEFRLevel.C2) == 5
16
+
17
+
18
+ async def test_fake_tts_returns_a_valid_wav() -> None:
19
+ result = await FakeTTSClient().synthesize("Hello world", language="en")
20
+ assert result.format == "wav"
21
+ with wave.open(io.BytesIO(result.audio), "rb") as wav:
22
+ assert wav.getnchannels() == 1
23
+ assert wav.getnframes() > 0
24
+
25
+
26
+ async def test_fake_asr_transcribes() -> None:
27
+ client = FakeASRClient(text="bonjour")
28
+ transcription = await client.transcribe(Path("/tmp/whatever.wav"), language="fr")
29
+ assert transcription.text == "bonjour"
30
+ assert transcription.language == "fr"
31
+ assert client.calls == [Path("/tmp/whatever.wav")]
32
+
33
+
34
+ async def test_memory_repository_roundtrip() -> None:
35
+ repo = InMemoryRepository()
36
+ learner = Learner(display_name="Arthur", source_lang="fr", target_lang="en")
37
+ await repo.save_learner(learner)
38
+
39
+ assert await repo.get_learner(learner.id) == learner
40
+ assert await repo.get_learner("missing") is None
41
+
42
+ first = Attempt(learner_id=learner.id, exercise_id="ex1", score=0.5)
43
+ second = Attempt(learner_id=learner.id, exercise_id="ex2", score=1.0)
44
+ await repo.save_attempt(second)
45
+ await repo.save_attempt(first)
46
+
47
+ attempts = await repo.list_attempts(learner.id)
48
+ assert [a.exercise_id for a in attempts] == sorted(
49
+ [a.exercise_id for a in attempts],
50
+ key=lambda ex_id: next(a.created_at for a in attempts if a.exercise_id == ex_id),
51
+ )
52
+ assert await repo.list_attempts("missing") == []
training/README.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # training/
2
+
3
+ Fine-tuning code (M1: CEFR classifier on XLM-R) lives here. It runs on the local GPU box
4
+ (WSL2, RTX 3070) with the `train` dependency group and is **never** part of the runtime
5
+ Docker image. Exported artifacts (ONNX int8) are what the Space serves.
uv.lock ADDED
The diff for this file is too large to render. See raw diff