Spaces:
Runtime error
Runtime error
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
- .dockerignore +18 -0
- .env.example +29 -0
- .github/workflows/ci.yml +63 -0
- .github/workflows/deploy-space.yml +34 -0
- .gitignore +34 -0
- .pre-commit-config.yaml +19 -0
- .python-version +1 -0
- Dockerfile +31 -0
- LICENSE +1 -1
- README.md +101 -1
- docs/adr/0001-stack-and-architecture.md +30 -0
- docs/adr/0002-llm-provider-strategy.md +23 -0
- docs/adr/0003-datasets-and-licensing.md +21 -0
- docs/evals/.gitkeep +0 -0
- evals/README.md +4 -0
- pre-commit-config.yaml +19 -0
- pyproject.toml +62 -0
- scripts/download_data.py +66 -0
- src/tutor/__init__.py +0 -0
- src/tutor/app/__init__.py +0 -0
- src/tutor/app/main.py +76 -0
- src/tutor/config.py +56 -0
- src/tutor/domain/__init__.py +0 -0
- src/tutor/domain/models.py +98 -0
- src/tutor/exercises/__init__.py +0 -0
- src/tutor/ml/__init__.py +0 -0
- src/tutor/ml/cefr/__init__.py +0 -0
- src/tutor/ml/pronunciation/__init__.py +0 -0
- src/tutor/ml/srs/__init__.py +0 -0
- src/tutor/services/__init__.py +0 -0
- src/tutor/services/asr/__init__.py +0 -0
- src/tutor/services/asr/base.py +33 -0
- src/tutor/services/llm/__init__.py +0 -0
- src/tutor/services/llm/base.py +40 -0
- src/tutor/services/llm/factory.py +34 -0
- src/tutor/services/llm/fake.py +26 -0
- src/tutor/services/llm/openai_compat.py +52 -0
- src/tutor/services/storage/__init__.py +0 -0
- src/tutor/services/storage/base.py +22 -0
- src/tutor/services/storage/memory.py +26 -0
- src/tutor/services/tts/__init__.py +0 -0
- src/tutor/services/tts/base.py +48 -0
- test +0 -0
- tests/test_app.py +10 -0
- tests/test_config.py +31 -0
- tests/test_llm.py +52 -0
- tests/test_services.py +52 -0
- training/README.md +5 -0
- 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|