diff --git a/.env.example b/.env.example index 307e3a5..d389f52 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ AWS_DEFAULT_REGION=us-east-1 S3_BUCKET=mon-bucket S3_PREFIX_RAW=evaluation-ecrit/raw S3_PREFIX_GROUND_TRUTH=evaluation-ecrit/ground_truth +# Répertoire S3 où déposer les prédictions finies (export post-run), pour relancer +# notebooks et site Quarto sans réexécuter le pipeline. Jamais dans Git. +S3_PREDICTIONS_PREFIX=s3://projet-production-ecrits-depp/predictions # ─── Service de modèles (vLLM via llm.lab) ────────────────────── # API compatible OpenAI. Pas de vraie clé nécessaire sur llm.lab interne, @@ -20,8 +23,19 @@ S3_PREFIX_GROUND_TRUTH=evaluation-ecrit/ground_truth LLM_BASE_URL=https://llm.lab.sspcloud.fr/api/v1 LLM_API_KEY=dummy -# ─── Suivi d'expériences ──────────────────────────────────────── -MLFLOW_TRACKING_URI=https://mlflow.lab.sspcloud.fr -LANGFUSE_HOST=https://langfuse.lab.sspcloud.fr +# ─── Suivi d'expériences (traces, prompts, métriques) ─────────── +# Structure des traces : session (= un run) → trace (= une copie) → génération. +# Copier les 3 valeurs depuis l'UI Langfuse (Settings → API Keys). +# NB : le SDK Python lit LANGFUSE_BASE_URL (LANGFUSE_HOST est déprécié). +LANGFUSE_BASE_URL=https://langfuse.lab.sspcloud.fr LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= +# Environnement de traçage (sépare/filtre les traces dans l'UI, ex. lors des +# tests vs. runs réels). Repris automatiquement par le SDK Langfuse. +LANGFUSE_TRACING_ENVIRONMENT=development + +# ─── MLflow (uniquement pour le fine-tuning HTR sur GPU) ──────── +# Utilisé par scripts/finetune_htr_scoledit.py (courbes d'entraînement : +# loss/lr par step). Sans objet pour le reste du pipeline (inférence/eval), +# qui trace tout via Langfuse ci-dessus. +MLFLOW_TRACKING_URI=https://mlflow.lab.sspcloud.fr diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a12a56a..b36a50b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Installer Python et dépendances run: | uv python install 3.11 - uv sync --extra dev + uv sync - name: Lint (ruff) run: uv run ruff check src tests scripts diff --git a/.gitignore b/.gitignore index 5428727..b56b358 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ # Exceptions : on autorise les images de documentation !docs/**/*.png +# ─── MLflow (suivi local, ne pas versionner) ─────────────────── +mlflow.db +mlruns/ +mlartifacts/ + # ─── Secrets ──────────────────────────────────────────────────── .env *.key @@ -45,3 +50,13 @@ mlartifacts/ # ─── Notebooks (sorties) ──────────────────────────────────────── *.ipynb_checkpoints/ + +# ─── Quarto (site généré) ─────────────────────────────────────── +website/_site/ +website/.quarto/ +.quarto/ +# Cache d'exécution : contient les résultats calculés (métriques lues sur S3). +# On ne le versionne pas → le rendu réexécute et relit les prédictions depuis S3. +website/_freeze/ +/.quarto/ +**/*.quarto_ipynb diff --git a/CLAUDE.md b/CLAUDE.md index cee2972..1fa697e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ GPU. Baselines : TrOCR, PyLaia, PP-OCRv5. Repli si GPU contraint : MiniCPM-V-2.6 > **Raccourci sans GPU** : `gemma4-26b-moe` est déjà servi sur llm.lab (SSP Cloud) > et est multimodal (texte + image, OCR et reconnaissance d'écriture). C'est le > candidat de première intention pour la méthode C : aucun GPU à privatiser, -> appel via l'API compatible OpenAI. Voir `configs/dictee_gemma4_zeroshot.yaml`. +> appel via l'API compatible OpenAI. Voir `configs/scoring/dictee_REFERENCE.yaml`. > Attention au mode « thinking » de Gemma 4 : si actif, élaguer le bloc de > raisonnement avant de parser le JSON (même précaution que `enable_thinking:false` > sur Qwen3). @@ -117,7 +117,7 @@ evaluation_ecrit/ │ ├── models/ ← interfaces modèles (VLM via vLLM, HTR, base) │ ├── pipeline/ ← orchestration des méthodes A/B/C/D │ ├── evaluation/ ← métriques, calibration, comparaison annotateurs -│ └── utils/ ← logging, MLflow, helpers +│ └── utils/ ← logging, Langfuse, helpers ├── scripts/ ← points d'entrée CLI (run_benchmark, prepare_data…) ├── tests/ ← tests unitaires (pytest) ├── notebooks/ ← exploration (ne pas y mettre de logique réutilisable) @@ -126,34 +126,40 @@ evaluation_ecrit/ ## 8. Conventions de code -- **Python ≥ 3.11**, gestion des dépendances avec **uv** (ou pip + venv). -- **Formatage et lint** : `ruff` (formatage + lint). Lancer `ruff check` et - `ruff format` avant chaque commit. +- **Python ≥ 3.11**, gestion des dépendances avec **uv** exclusivement. +- **Formatage et lint** : `ruff` (formatage + lint). Lancer `uv run ruff check` et + `uv run ruff format` avant chaque commit. - **Typage** : annoter toutes les fonctions publiques. `mypy` en CI (non bloquant au début). - **Docstrings** en français, style court (une ligne de résumé + Args/Returns si utile). - **Pas de chemin en dur** : tout passe par `configs/*.yaml` et `src/.../config.py`. - **Pas de secret dans le code** : identifiants S3, tokens → variables d'environnement (`.env`, jamais commité). -- **Reproductibilité** : chaque expérience = un fichier de config versionné + un run - MLflow. Fixer les graines aléatoires. +- **Reproductibilité** : chaque expérience = un fichier de config versionné + un + run tracé dans Langfuse. Structure des traces : **session** (= un lancement, + `session_id` = `config.name` + horodatage, donc une session neuve à chaque + lancement) → **trace** (= une copie, avec entrée/sortie et score d'accord) → + **génération** (= appel LLM). Les paramètres du run sont propagés en tags/metadata + (dont le tag `run:` qui regroupe les lancements d'une même config) et les + métriques agrégées sont enregistrées en Scores + metadata (voir `utils/tracking.py`). Le fine-tuning HTR sur GPU reste suivi via MLflow + (courbes d'entraînement, hors périmètre Langfuse). Fixer les graines aléatoires. - **Commits** : messages clairs en français, format `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`). ## 9. Commandes utiles ```bash -# Installer l'environnement -uv sync # ou: pip install -e ".[dev]" +# Installer l'environnement (le groupe dev — ruff, mypy, pytest — est inclus d'office) +uv sync # Qualité de code -ruff check src tests # lint -ruff format src tests # formatage -pytest # tests -mypy src # typage +uv run ruff check src tests # lint +uv run ruff format src tests # formatage +uv run pytest # tests +uv run mypy src # typage # Lancer un benchmark à partir d'une config -python scripts/run_benchmark.py --config configs/dictee_gemma4_zeroshot.yaml +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml ``` ### Runs longs — utiliser screen ou nohup @@ -174,7 +180,7 @@ mkdir -p logs navigateur et revenir le lendemain. ```bash screen -S dictee # créer la session -python scripts/run_benchmark.py --config configs/dictee_gemma4_cot.yaml +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml # Détacher : Ctrl+A puis D (le job continue en arrière-plan) screen -r dictee # se rattacher plus tard screen -ls # lister les sessions @@ -183,10 +189,10 @@ screen -ls # lister les session **Option 2 — nohup.** Sans interface interactive, log dans un fichier. ```bash mkdir -p logs -nohup python scripts/run_benchmark.py --config configs/dictee_gemma4_cot.yaml \ - > logs/dictee_gemma4_cot.log 2>&1 & -echo $! > logs/dictee_gemma4_cot.pid # noter le PID pour arrêter plus tard -tail -f logs/dictee_gemma4_cot.log # suivre le log en direct +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml \ + > logs/dictee_REFERENCE.log 2>&1 & +echo $! > logs/dictee_REFERENCE.pid # noter le PID pour arrêter plus tard +tail -f logs/dictee_REFERENCE.log # suivre le log en direct ``` ### Checkpointing et reprise après crash diff --git a/README.md b/README.md index 51d79ac..78e503b 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ l'aide de modèles multimodaux open weight, et **comparer rigoureusement** le codage automatique à celui d'un correcteur expert. Collaboration **DEPP × SSP Lab (INSEE)**. -> Contexte complet et décisions : voir **[CLAUDE.md](CLAUDE.md)**, la -> **[note méthodologique](docs/note_methodologique.md)** et les +> Contexte complet et décisions : voir **[CLAUDE.md](CLAUDE.md)** et les > **[décisions](docs/decisions.md)** dans [`docs/`](docs/). --- @@ -30,11 +29,11 @@ Le projet implémente **deux architectures** derrière la même interface `Score donc évaluées par le même code de métriques (comparaison rigoureuse) : - **`end_to_end` (approche 2)** : un VLM lit l'image ET code en une seule passe. - Approche par défaut. Config : `configs/dictee_gemma4_zeroshot.yaml`. + Approche par défaut. Config : `configs/scoring/dictee_REFERENCE.yaml`. - **`two_stage` (approche 1)** : étape 1 = transcription HTR (lecture de l'image en texte brut, fautes comprises) ; étape 2 = codage du texte transcrit (sans image, éventuellement par un modèle texte plus léger via `model_stage2`). Isole lecture - et jugement. Config : `configs/dictee_gemma4_2stages.yaml`. L'approche se choisit + et jugement. Config : `configs/scoring/dictee_REFERENCE.yaml`. L'approche se choisit via le champ `approach` du YAML. ### Évaluation dédiée de la transcription (HTR) sur Scoledit @@ -43,10 +42,30 @@ Indépendamment du codage, on peut mesurer la **fidélité de lecture** d'un mod l'écriture manuscrite d'enfants via le corpus **Scoledit** (transcriptions de référence humaines, fautes préservées). Métriques CER/WER (bruts et normalisés). Cela permet de comparer les modèles sur la seule lecture et de distinguer les -erreurs de lecture de celles de jugement. Config : `configs/htr_gemma4_scoledit.yaml`, +erreurs de lecture de celles de jugement. Config : `configs/htr/htr_REFERENCE.yaml`, script : `scripts/run_htr_benchmark.py`, analyse : `notebooks/05_analyse_transcription_htr.ipynb`. +### Fine-tuning HTR (phase ultérieure) + +Pour améliorer la fidélité de lecture sur l'écriture d'enfants, on peut +**spécialiser** un VLM par fine-tuning **QLoRA** (LoRA en 4 bits) sur le corpus +Scoledit multi-niveaux (CP→CM2), transcriptions humaines fautes préservées. La +sortie est un adaptateur léger (~50-200 Mo) qui se charge par-dessus le modèle de +base. Nécessite un **GPU H100**. Suivi via **MLflow** (et non Langfuse). Config : +`configs/finetune/finetune_REFERENCE.yaml`, script : `scripts/finetune_htr_scoledit.py`. + +### Documentation pédagogique (site Quarto) + +Un **site Quarto** (dossier [`website/`](website/)) présente le projet pour un +public statisticien novice en IA : architecture, résultats des deux approches, +explication détaillée des métriques d'évaluation, et fine-tuning. + +```bash +quarto preview website # aperçu local avec rechargement à chaud +quarto render website # génère le site statique dans website/_site/ +``` + --- ## Démarrage rapide (SSP Cloud / VSCode) @@ -56,33 +75,77 @@ script : `scripts/run_htr_benchmark.py`, analyse : ```bash git clone evaluation_dictee cd evaluation_dictee -python -m pip install -e ".[dev]" # layout src/ : l'installation est obligatoire +uv sync ``` -> Le paquet s'appelle `evaluation_dictee`. Sans `pip install -e .`, les imports -> `from evaluation_dictee...` échouent (le dossier `src/` n'est pas dans le PYTHONPATH). +> Le paquet s'appelle `evaluation_dictee`. `uv sync` installe toutes les dépendances +> (y compris le groupe `dev`) et configure le mode éditable automatiquement, ce qui +> rend les imports `from evaluation_dictee...` disponibles. + +### 2. Configurer les accès (Vault Onyxia + repli `.env`) + +Toute la configuration sensible passe par des **variables d'environnement** ; le +code les lit via `Secrets` (Pydantic, `src/evaluation_dictee/config.py`). **Aucun +secret n'est jamais écrit dans le code ni dans les YAML.** Deux façons de fournir +ces variables selon le contexte : + +#### Sur le SSP Cloud (recommandé) : le Vault Onyxia + +Onyxia intègre un **Vault** (HashiCorp) personnel : un coffre-fort où stocker ses +secrets une fois, puis les **injecter automatiquement comme variables d'environnement** +dans chaque service qu'on lance. On ne recopie ainsi jamais de token en clair. + +1. **Stocker les secrets** dans le Vault : `Mon compte` → `Vault` (SecretVault). + Créer un secret pour le projet (p. ex. `evaluation_dictee`) avec les clés : + + | Clé | Valeur | + |-----|--------| + | `LLM_BASE_URL` | `https://llm.lab.sspcloud.fr/api/v1` | + | `LLM_API_KEY` | ton token llm.lab | + | `LANGFUSE_BASE_URL` | `https://langfuse.lab.sspcloud.fr` | + | `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` | depuis l'UI Langfuse | + | `MLFLOW_TRACKING_URI` | `https://mlflow.lab.sspcloud.fr` (fine-tuning) | + +2. **Injecter dans le service** : au lancement d'un service (VSCode, Jupyter…), + section `Vault`, référencer ce secret pour qu'Onyxia expose ces clés comme + variables d'environnement dans le conteneur. Elles sont alors disponibles sans + fichier `.env`. + +3. **Alternative en ligne de commande** — les services Onyxia ont déjà `VAULT_ADDR` + et `VAULT_TOKEN` positionnés, donc le CLI `vault` fonctionne directement : + + ```bash + vault kv get /evaluation_dictee # lire les secrets + # (les identifiants S3 AWS_* sont déjà injectés par Onyxia, rien à faire) + ``` -### 2. Configurer les accès +> Les identifiants **S3** (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +> `AWS_SESSION_TOKEN`, `AWS_S3_ENDPOINT`) sont **automatiquement injectés** par +> Onyxia dans chaque service : rien à configurer pour accéder au stockage. -Sur le SSP Cloud (Onyxia), les identifiants S3 sont généralement déjà dans -l'environnement. Pour le modèle, il faut un token llm.lab : +#### En local (hors Onyxia) : le fichier `.env` + +Hors SSP Cloud, ou pour un test rapide, `Secrets` retombe sur un fichier `.env` +local (jamais commité, voir `.gitignore`) : ```bash cp .env.example .env -# éditer .env : -# LLM_BASE_URL=https://llm.lab.sspcloud.fr/api/v1 (vérifier le chemin exact) -# LLM_API_KEY= +# éditer .env : LLM_API_KEY, LANGFUSE_*, et si besoin AWS_* / MLFLOW_* ``` +`.env.example` documente toutes les variables attendues. Le code ne fait **aucune +différence** entre une variable injectée par le Vault et une variable lue depuis +`.env` : dans les deux cas, elle arrive par l'environnement. + Vérifier l'accès aux données et au modèle : ```bash # S3 accessible ? -python -c "from evaluation_dictee.data.loaders import load_labels; \ +uv run uv run python -c "from evaluation_dictee.data.loaders import load_labels; \ print(len(load_labels('s3://projet-production-ecrits-depp/resultat_dictee_2015.csv')), 'copies')" # modèle accessible ? -python -c "from openai import OpenAI; from evaluation_dictee.config import Secrets; \ +uv run uv run python -c "from openai import OpenAI; from evaluation_dictee.config import Secrets; \ s=Secrets(); c=OpenAI(base_url=s.llm_base_url, api_key=s.llm_api_key); \ print(c.chat.completions.create(model='gemma4-26b-moe', \ messages=[{'role':'user','content':'Dis bonjour'}], max_tokens=10).choices[0].message.content)" @@ -92,11 +155,13 @@ messages=[{'role':'user','content':'Dis bonjour'}], max_tokens=10).choices[0].me **Commande de base** (test rapide, terminal foreground) : ```bash -python scripts/run_benchmark.py --config configs/dictee_gemma4_zeroshot.yaml +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml ``` -Cela produit `data/processed/dictee_gemma4_zeroshot_predictions.jsonl` -(une ligne par item × copie) et journalise les métriques dans MLflow. +Cela produit `data/processed/dictee_REFERENCE_predictions.jsonl` +(une ligne par item × copie) et journalise tout dans Langfuse : une **session** +par run, une **trace** par copie (entrée/sortie + score d'accord), les appels LLM +en générations imbriquées, et les métriques agrégées du run en Scores et metadata. **Pour un run complet (long)**, utiliser une session détachable — voir la section « Runs longs (screen / nohup) » plus bas dans ce README. @@ -106,25 +171,61 @@ reprend automatiquement où il s'était arrêté : si un run est interrompu (déconnexion, erreur API, kernel tué), il suffit de **relancer la même commande** et il saute les copies déjà traitées. Voir « Runs longs » pour les détails. +**Vitesse : évaluation en parallèle.** Les copies sont évaluées concurremment +(le endpoint vLLM batche les requêtes) — réglé par `concurrency` dans le YAML +(défaut **8**). C'est le principal levier de temps mural : passer de 1 à N copies +en parallèle divise d'autant la durée tant que le serveur suit. Monter (16, 32…) +si l'endpoint tient, redescendre en cas de timeouts / erreurs 429. Seul le scoring +est parallélisé : l'écriture du JSONL et la reprise restent inchangées. + **Pour l'évaluation de la transcription (HTR)** sur Scoledit : ```bash -python scripts/run_htr_benchmark.py --config configs/htr_gemma4_scoledit.yaml +uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml ``` -Cela produit `data/processed/htr_gemma4_scoledit_htr_predictions.jsonl` et affiche +Cela produit `data/processed/htr_REFERENCE_htr_predictions.jsonl` et affiche le CER/WER moyens. Analyse dans `notebooks/05_analyse_transcription_htr.ipynb`. +La transcription est elle aussi **parallélisée** (champ `concurrency` du YAML HTR, +défaut 8) ; l'ordre des échantillons en sortie est préservé. + +**Exporter les prédictions vers S3.** Le pipeline écrit en local (append + fsync +par copie, pour la reprise sur crash). Une fois un run terminé, on pousse le JSONL +vers le répertoire `predictions/` du bucket S3, afin de **relancer les notebooks et +le site Quarto sans réexécuter le pipeline**. Rien n'est commité dans Git. + +```bash +# scoring — end_to_end OU two_stage (même format, c'est le `name` qui distingue) : +uv run scripts/export_predictions.py --config configs/scoring/dictee_REFERENCE.yaml + +# transcription HTR seule : +uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr + +# équivalent via la CLI installée : +eval-ecrit export configs/scoring/dictee_REFERENCE.yaml +``` + +Destination : `$S3_PREDICTIONS_PREFIX/_predictions.jsonl` (défaut +`s3://projet-production-ecrits-depp/predictions`, surchargeable par `--dest-prefix`). **Pour le fine-tuning** d'un modèle de transcription (nécessite un GPU H100) : ```bash -python scripts/finetune_htr_scoledit.py --config configs/finetune_htr_gemma4.yaml +uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml ``` Voir la documentation détaillée dans le script pour les prérequis d'installation (`unsloth`, `trl`, `bitsandbytes`). ### 4. Analyser les résultats -Trois notebooks, à ouvrir dans **`notebooks/`** et à exécuter cellule par cellule : +Trois notebooks, à ouvrir dans **`notebooks/`** et à exécuter cellule par cellule. +Installer d'abord les dépendances notebooks (JupyterLab, matplotlib) puis lancer +JupyterLab via uv : + +```bash +uv sync --extra notebooks # une seule fois +uv run jupyter lab # ouvre l'interface +``` + | Notebook | Ce qu'il fait | Prérequis | |----------|---------------|-----------| @@ -168,7 +269,7 @@ which screen && echo "OK" || echo "absent" # Créer une session détachable et lancer le run : screen -S dictee -python scripts/run_benchmark.py --config configs/dictee_gemma4_cot.yaml +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml # Détacher : Ctrl+A puis D (le job continue en arrière-plan) # Rattacher : screen -r dictee @@ -181,18 +282,18 @@ python scripts/run_benchmark.py --config configs/dictee_gemma4_cot.yaml ```bash mkdir -p logs # créer le dossier si pas encore fait -nohup python scripts/run_benchmark.py --config configs/dictee_gemma4_cot.yaml \ - > logs/dictee_gemma4_cot.log 2>&1 & -echo $! > logs/dictee_gemma4_cot.pid # noter le PID pour arrêter plus tard +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml \ + > logs/dictee_REFERENCE.log 2>&1 & +echo $! > logs/dictee_REFERENCE.pid # noter le PID pour arrêter plus tard # Suivre le log en direct : -tail -f logs/dictee_gemma4_cot.log +tail -f logs/dictee_REFERENCE.log # Vérifier que le process tourne : -ps -p $(cat logs/dictee_gemma4_cot.pid) +ps -p $(cat logs/dictee_REFERENCE.pid) # Arrêter proprement (le checkpointing sauvegardera l'état) : -kill $(cat logs/dictee_gemma4_cot.pid) +kill $(cat logs/dictee_REFERENCE.pid) ``` ### Surveillance de l'avancement @@ -202,13 +303,13 @@ un signal de vie plus fiable que la barre de progression : ```bash # Compter les copies déjà traitées dans le JSONL (une copie = ~83 lignes) : -wc -l data/processed/dictee_gemma4_cot_predictions.jsonl +wc -l data/processed/dictee_REFERENCE_predictions.jsonl # Suivre le compteur en direct (mise à jour toutes les 5 s) : -watch -n 5 "wc -l data/processed/dictee_gemma4_cot_predictions.jsonl" +watch -n 5 "wc -l data/processed/dictee_REFERENCE_predictions.jsonl" # Lister les copies en échec (à retenter au prochain lancement) : -cat data/processed/dictee_gemma4_cot_failed_copies.txt +cat data/processed/dictee_REFERENCE_failed_copies.txt ``` ### Reprise après crash — mode d'emploi @@ -229,8 +330,10 @@ Conséquences pratiques : ## Configurer une expérience -Un fichier YAML dans `configs/` = une expérience reproductible. Exemple commenté : -[`configs/dictee_gemma4_zeroshot.yaml`](configs/dictee_gemma4_zeroshot.yaml). +Un fichier YAML dans `configs/` = une expérience reproductible. Les configs sont +rangées par famille (`scoring/`, `htr/`, `finetune/`) et documentées dans +[`configs/README.md`](configs/README.md). Modèle exhaustivement commenté à copier : +[`configs/scoring/dictee_REFERENCE.yaml`](configs/scoring/dictee_REFERENCE.yaml). | Champ | Rôle | |-------|------| @@ -240,7 +343,7 @@ Un fichier YAML dans `configs/` = une expérience reproductible. Exemple comment | `data.grid_path` | grille de codage JSON (`configs/grille_dictee_2015.json`) | | `data.limit` | nombre de copies (mettre `null` pour tout le corpus) | | `grid.scheme` | `simplifiee` (1/9/0) ou `complete` (1/3/4/5/9/0) | -| `prompt.method` | `C` end-to-end (image → code), voir note méthodologique | +| `prompt.method` | `C` end-to-end (image → code) | | `prompt.read_final_state` | règle des ratures : lire l'état final corrigé | --- @@ -252,13 +355,12 @@ evaluation_dictee/ ├── CLAUDE.md ← contexte du projet pour humains et IA ├── README.md ← ce fichier ├── pyproject.toml ← dépendances + config ruff/mypy/pytest -├── configs/ +├── configs/ ← configs de référence, une par famille (voir configs/README.md) +│ ├── README.md ← guide de paramétrage (toutes les familles) │ ├── grille_dictee_2015.json ← grille de codage (mot attendu + fautes connues) -│ ├── dictee_gemma4_zeroshot.yaml ← approche end-to-end (1 étape) -│ ├── dictee_gemma4_2stages.yaml ← approche 2 étapes (HTR + codage texte) -│ ├── dictee_gemma4_cot.yaml ← 1 étape avec chain-of-thought -│ ├── htr_gemma4_scoledit.yaml ← évaluation HTR sur corpus Scoledit -│ └── finetune_htr_gemma4.yaml ← fine-tuning HTR (QLoRA) +│ ├── scoring/dictee_REFERENCE.yaml ← codage dictée (run_benchmark.py) +│ ├── htr/htr_REFERENCE.yaml ← évaluation HTR Scoledit (run_htr_benchmark.py) +│ └── finetune/finetune_REFERENCE.yaml ← fine-tuning HTR QLoRA (finetune_htr_scoledit.py) ├── src/evaluation_dictee/ │ ├── config.py ← configs validées (Pydantic) + secrets (.env) │ ├── data/ ← chargement images (S3, TIFF 1 bit) + grille + labels @@ -282,7 +384,7 @@ evaluation_dictee/ │ │ ├── htr_metrics.py ← CER, WER (bruts et normalisés) │ │ ├── htr_benchmark.py ← run HTR + agrégation métriques │ │ └── visual_diff.py ← HTML des pires / N aléatoires -│ └── utils/ ← logging, suivi MLflow +│ └── utils/ ← logging, suivi Langfuse (traces, prompts, scores) ├── scripts/ │ ├── run_benchmark.py ← point d'entrée CLI (approches 1 et 2 étapes) │ ├── run_htr_benchmark.py ← point d'entrée CLI pour l'évaluation HTR @@ -291,8 +393,9 @@ evaluation_dictee/ │ ├── 03_analyse_resultats.ipynb ← analyse statistique + export DEPP │ ├── 04_diagnostic.ipynb ← inspection copie par copie │ └── 05_analyse_transcription_htr.ipynb ← analyse HTR +├── website/ ← site Quarto (archi, résultats, métriques, fine-tuning) ├── tests/ ← 83 tests unitaires (pytest) -└── docs/ ← note méthodologique, cadrage, décisions, grille +└── docs/ ← décisions, grille de codage, schéma du pipeline ``` --- @@ -302,11 +405,11 @@ evaluation_dictee/ Avant chaque commit : ```bash -ruff format src tests scripts # formatage automatique -ruff check src tests scripts # lint (attrape les erreurs courantes) -pytest # lance toute la suite de tests -pytest tests/test_alignment.py -v # tester UN fichier précis -pytest -k "chain_of_thought" # tests dont le nom matche un motif +uv run ruff format src tests scripts # formatage automatique +uv run ruff check src tests scripts # lint (attrape les erreurs courantes) +uv run pytest # lance toute la suite de tests +uv run pytest tests/test_alignment.py -v # tester UN fichier précis +uv run pytest -k "chain_of_thought" # tests dont le nom matche un motif ``` **Ne jamais committer** les données (`data/`), les checkpoints (`checkpoints/`), @@ -319,25 +422,34 @@ README, avec ses prérequis et son contexte d'usage. ```bash # ─────────── Installation & configuration (une seule fois) ─────────── -uv sync # environnement Python -# ou : pip install -e ".[dev]" -cp .env.example .env && nano .env # renseigner LLM_API_KEY et S3 +uv sync # environnement Python (groupe dev inclus d'office) +# Secrets : sur Onyxia, via le Vault (Mon compte > Vault, injecté comme variables +# d'env). En local, repli sur un .env : +cp .env.example .env && nano .env # renseigner LLM_API_KEY, LANGFUSE_*, S3 + +# ─────────── Configuration Langfuse (une seule fois) ─────────── +# --env-file .env : Langfuse lit ses clés dans os.environ, que .env n'alimente pas seul. +uv run --env-file .env add-langfuse-prompt # pousse les prompts d'évaluation +uv run --env-file .env add-langfuse-models # enregistre le coût théorique/1M tokens + # (prix GPU amorti, ajustables dans + # utils/add_langfuse_models.py) # ─────────── Vérifier que tout marche ─────────── -python -c "from evaluation_dictee.data.loaders import load_labels; \ +uv run python -c "from evaluation_dictee.data.loaders import load_labels; \ print(len(load_labels('s3://projet-production-ecrits-depp/resultat_dictee_2015.csv')), 'copies')" -pytest -q # lancer les tests +uv run pytest -q # lancer les tests -# ─────────── Benchmarks : approches 1 et 2 étapes ─────────── -python scripts/run_benchmark.py --config configs/dictee_gemma4_zeroshot.yaml -python scripts/run_benchmark.py --config configs/dictee_gemma4_2stages.yaml -python scripts/run_benchmark.py --config configs/dictee_gemma4_cot.yaml # chain-of-thought +# ─────────── Benchmark scoring dictée ─────────── +# Config de référence prête à l'emploi (approche end_to_end). Pour comparer une +# autre approche/variante (two_stage, chain-of-thought, autre modèle), copier la +# référence et ajuster (voir configs/README.md). +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml # ─────────── Évaluation de la transcription HTR (Scoledit) ─────────── -python scripts/run_htr_benchmark.py --config configs/htr_gemma4_scoledit.yaml +uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml # ─────────── Fine-tuning HTR (GPU H100 requis) ─────────── -python scripts/finetune_htr_scoledit.py --config configs/finetune_htr_gemma4.yaml +uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml # ─────────── Runs longs (session détachable) ─────────── mkdir -p logs # toujours créer d'abord @@ -347,22 +459,27 @@ which screen && screen -S dictee # puis Ctrl+A D pour détacher # screen -r dictee pour rattacher # Option B : nohup (toujours dispo, sans interface interactive) -nohup python scripts/run_benchmark.py --config configs/dictee_gemma4_cot.yaml \ - > logs/dictee_gemma4_cot.log 2>&1 & +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml \ + > logs/dictee_REFERENCE.log 2>&1 & # ─────────── Surveillance d'un run en cours ─────────── -tail -f logs/dictee_gemma4_cot.log -watch -n 5 "wc -l data/processed/dictee_gemma4_cot_predictions.jsonl" +tail -f logs/dictee_REFERENCE.log +watch -n 5 "wc -l data/processed/dictee_REFERENCE_predictions.jsonl" # ─────────── Analyse des résultats ─────────── -jupyter lab notebooks/03_analyse_resultats.ipynb # analyse statistique + export DEPP -jupyter lab notebooks/04_diagnostic.ipynb # inspection copie par copie -jupyter lab notebooks/05_analyse_transcription_htr.ipynb # analyse HTR +uv sync --extra notebooks # une seule fois (JupyterLab + matplotlib) +uv run jupyter lab notebooks/03_analyse_resultats.ipynb # analyse statistique + export DEPP +uv run jupyter lab notebooks/04_diagnostic.ipynb # inspection copie par copie +uv run jupyter lab notebooks/05_analyse_transcription_htr.ipynb # analyse HTR + +# ─────────── Site de documentation (Quarto) ─────────── +quarto preview website # aperçu local (rechargement à chaud) +quarto render website # génère website/_site/ # ─────────── Qualité de code (avant tout commit) ─────────── -ruff format src tests scripts -ruff check src tests scripts -pytest +uv run ruff format src tests scripts +uv run ruff check src tests scripts +uv run pytest ``` --- diff --git a/configs/README.md b/configs/README.md new file mode 100644 index 0000000..685f622 --- /dev/null +++ b/configs/README.md @@ -0,0 +1,159 @@ +# `configs/` — configurations d'expériences + +Toute expérience du projet se pilote par un fichier **YAML** (aucun paramètre +codé en dur ailleurs). Un fichier = un run reproductible, validé au chargement +par un schéma [Pydantic](../src/evaluation_dictee/config.py). + +Ce dossier contient **trois familles** de configs, chacune consommée par un +script différent, plus la grille de codage (une donnée, pas une config). + +Chaque famille fournit **une config de référence** (`*_REFERENCE.yaml`) : un +fichier valide, prêt à lancer, et exhaustivement commenté. C'est le point de +départ — on le copie pour créer sa propre expérience (voir plus bas). + +``` +configs/ +├── README.md ← ce guide +├── grille_dictee_2015.json ← grille de codage (mot attendu + fautes/item) +├── scoring/dictee_REFERENCE.yaml ← codage de la dictée → run_benchmark.py +├── htr/htr_REFERENCE.yaml ← transcription seule → run_htr_benchmark.py +└── finetune/finetune_REFERENCE.yaml ← fine-tuning LoRA (GPU) → finetune_htr_scoledit.py +``` + +--- + +## Démarrage rapide + +```bash +# 1. Scoring dictée (codage image → codes). Commence par un petit run. +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml + +# 2. Éval de transcription (CER/WER) sur Scoledit. +uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml + +# 3. Fine-tuning HTR (à lancer sur un service GPU). +uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml +``` + +> **Astuce paramétrage** : dans presque tous les fichiers, **`limit`** borne le +> nombre de copies (mettre `5`–`20` pour un test, `null` pour tout le corpus) et +> **`model.name`** est le seul champ à changer pour tester un autre modèle. + +--- + +## Famille 1 — Scoring dictée (`scoring/`) + +C'est la famille principale. Schéma : `ExperimentConfig`. Le fichier +**`dictee_REFERENCE.yaml`** documente **chaque paramètre** avec ses valeurs +possibles et par défaut : commence par le lire, puis copie-le pour ta config. + +### Les champs, en un coup d'œil + +| Chemin | Rôle | Valeurs / défaut | +|---|---|---| +| `name` | nom unique du run (session Langfuse + préfixe des sorties) | libre, unique | +| `seed` | reproductibilité | `42` | +| `approach` | 1 passe ou 2 étapes | `end_to_end` \| `two_stage` | +| `model.name` | modèle servi par vLLM (nom exact llm.lab) | ex. `gemma4-26b-moe` | +| `model.kind` | modalité | `vlm` \| `llm` \| `htr` | +| `model.temperature` | échantillonnage (0 = déterministe) | `0.0` | +| `model.max_tokens` | plafond de génération (marge anti-troncature) | `2048`, **mettre `8192`** | +| `model.max_retries` | essais si réponse non parsable | `2` | +| `model.disable_thinking` | coupe le `` des modèles Qwen3/R1 | `true` | +| `model.structured_output` | force un JSON conforme au schéma (anti « non transcrite ») | `true` | +| `data.corpus` | corpus | `dictee` \| `production_ecrite` | +| `data.images_path` | imagettes (local ou `s3://…`) | requis | +| `data.labels_path` | codes experts = vérité terrain (local ou `s3://…`) | requis | +| `data.grid_path` | grille JSON | `configs/grille_dictee_2015.json` | +| `data.limit` | nb de copies (`null` = tout) | `null` | +| `grid.scheme` | modalités des codes | `simplifiee` (1/erreur/0) \| `complete` | +| `prompt.method` | méthode A/B/C/D | `C` | +| `prompt.n_few_shot` | exemples dans le prompt | `0` | +| `prompt.enforce_faithful` | consigne anti-sur-correction | `true` | +| `prompt.read_final_state` | ratures : lire l'état final | `true` | +| `prompt.chain_of_thought` | verbalise la comparaison avant le code | `false` | + +### Décliner en variantes (à partir de la référence) + +`dictee_REFERENCE.yaml` couvre l'approche end-to-end zéro-shot. Les autres +variantes se créent en copiant la référence et en changeant peu de champs : + +| Variante | Champs à changer | +|---|---| +| Autre modèle | `model.name` (+ `disable_thinking: true` si modèle *thinking* : Qwen3, R1…) | +| Chain-of-thought | `prompt.chain_of_thought: true` | +| Deux étapes (HTR + codage) | `approach: two_stage` + ajouter le bloc `model_stage2` | +| Run de fumée (traçage) | `data.limit: 5` et un `name` dédié | + +> **Comparaison équitable end-to-end vs two-stage** : garder le *même* modèle des +> deux côtés isole l'effet de l'architecture (1 vs 2 étapes) de celui du modèle. + +--- + +## Famille 2 — Transcription HTR (`htr/`) + +Mesure la fidélité de lecture (CER/WER) sur le corpus Scoledit, indépendamment +du codage. Modèle commenté à copier : **`htr_REFERENCE.yaml`**. Consommée par +`run_htr_benchmark.py`, qui lit le YAML comme un simple dictionnaire — **les clés +attendues sont donc `scans_dir` / `annotations_dir`** (et non `*_path`) : + +```yaml +name: htr_REFERENCE +model: + name: gemma4-26b-moe # ← seul champ à changer pour tester un autre VLM + kind: vlm + temperature: 0.0 + max_tokens: 2048 + max_retries: 2 +data: + scans_dir: s3://…/scoledit/scans/CE1/ + annotations_dir: s3://…/scoledit/annotation/CE1/ + limit: 10 # null = tout le corpus +read_final_state: true # ratures : lire l'état final +``` + +--- + +## Famille 3 — Fine-tuning LoRA (`finetune/`) + +Fine-tuning HTR (QLoRA) sur Scoledit, **à lancer depuis un service GPU** du SSP +Cloud (H100 recommandé). Schéma propre (dataclass dans le script), distinct des +deux autres : blocs `base_model`, `data` (`scans_root`/`annotations_root`, +niveaux, ratios de split), `lora` (rang, couches ciblées) et `training` +(epochs, batch, LR, scheduler…). Suivi via **MLflow** (et non Langfuse). Modèle +commenté à copier : **`finetune_REFERENCE.yaml`** (tous les champs + leurs +défauts). + +--- + +## Créer sa propre config (recette) + +1. **Copier** le modèle adapté à ta famille : + ```bash + cp configs/scoring/dictee_REFERENCE.yaml configs/scoring/dictee_MONMODELE_zeroshot.yaml + ``` +2. **Renommer** le champ `name` (unique — sinon tu écrases le checkpoint d'un + autre run) et régler `model.name` sur le nom exact affiché sur + [llm.lab.sspcloud.fr](https://llm.lab.sspcloud.fr). +3. **Vérifier** `data.images_path` / `labels_path`, et mettre `data.limit` à une + petite valeur pour un premier essai. +4. **Lancer** et itérer : + ```bash + uv run scripts/run_benchmark.py --config configs/scoring/dictee_MONMODELE_zeroshot.yaml + ``` +5. Passer `limit: null` une fois la chaîne validée (voir le README racine pour + les runs longs : `screen`/`nohup`, checkpointing, reprise après crash). + +--- + +## Bon à savoir + +- **Secrets** : aucun identifiant dans les YAML. Les accès S3 et le modèle + passent par des variables d'environnement (`.env`, jamais commité — voir + `.env.example`). +- **Données sensibles** : écritures d'élèves mineurs. Les microdonnées ne + quittent jamais le SSP Cloud ; aucune image dans Git (`data/` est ignoré). +- **`max_tokens`** : une copie fait 83 items. En JSON (et *a fortiori* en CoT), + prévoir `8192` pour éviter une troncature qui casse le parsing. +- **Modèles « thinking »** (Qwen3, DeepSeek-R1, QwQ) : toujours + `disable_thinking: true`, sinon le bloc `` casse le JSON. diff --git a/configs/dictee_gemma4_2stages.yaml b/configs/dictee_gemma4_2stages.yaml deleted file mode 100644 index dc4d4f6..0000000 --- a/configs/dictee_gemma4_2stages.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# Expérience : APPROCHE 1 (deux étapes) sur la dictée 2015. -# Étape 1 (HTR) : gemma4 lit l'image et transcrit le texte brut. -# Étape 2 (codage) : un modèle code le texte transcrit (ici aussi gemma4 ; -# on pourrait mettre un modèle texte seul plus léger via model_stage2). -# -# Pour une comparaison équitable avec l'approche end-to-end -# (dictee_gemma4_zeroshot.yaml), garder le MÊME modèle des deux côtés : on isole -# ainsi l'effet de l'architecture (1 vs 2 étapes) de l'effet du modèle. - -name: dictee_gemma4_2stages -seed: 42 -approach: two_stage - -model: # étape 1 : HTR (doit être multimodal) - name: gemma4-26b-moe - kind: vlm - temperature: 0.0 - max_tokens: 4096 - max_retries: 2 - -model_stage2: # étape 2 : codage du texte (peut être texte seul) - name: gemma4-26b-moe # ex. remplaçable par un LLM texte plus léger - kind: llm - temperature: 0.0 - max_tokens: 8192 # 83 items × ~60 tokens JSON, marge pour éviter la troncature - max_retries: 2 - -data: - corpus: dictee - images_path: s3://projet-production-ecrits-depp/dictee_2015/ - labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv - grid_path: configs/grille_dictee_2015.json - limit: 10 - -grid: - scheme: simplifiee - -prompt: - method: C - n_few_shot: 0 - enforce_faithful: true - read_final_state: true diff --git a/configs/dictee_gemma4_cot.yaml b/configs/dictee_gemma4_cot.yaml deleted file mode 100644 index 64c5a54..0000000 --- a/configs/dictee_gemma4_cot.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# Expérience : approche end-to-end AVEC chain-of-thought pour améliorer le codage. -# Le modèle doit écrire un champ "comparaison" verbalisant la différence -# lue-attendue AVANT de choisir le code. Rend le raisonnement inspectable et -# réduit les erreurs bien lues mais mal codées (« mis » vs « mit »). - -name: dictee_gemma4_cot -seed: 42 -approach: end_to_end - -model: - name: gemma4-26b-moe - kind: vlm - temperature: 0.0 - max_tokens: 8192 # + 1 champ par item : marge nécessaire - max_retries: 2 - -data: - corpus: dictee - images_path: s3://projet-production-ecrits-depp/dictee_2015/ - labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv - grid_path: configs/grille_dictee_2015.json - limit: null - -grid: - scheme: simplifiee - -prompt: - method: C - n_few_shot: 0 - enforce_faithful: true - read_final_state: true - chain_of_thought: true # ← active la verbalisation de la comparaison diff --git a/configs/dictee_gemma4_zeroshot.yaml b/configs/dictee_gemma4_zeroshot.yaml deleted file mode 100644 index 519e559..0000000 --- a/configs/dictee_gemma4_zeroshot.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Expérience : dictée 2015, gemma4-26b-moe servi sur llm.lab (SSP Cloud). -# Modèle multimodal accessible directement via API : aucun GPU à privatiser. -# Données lues directement sur S3. - -name: dictee_gemma4_zeroshot -seed: 42 - -model: - # Nom exact tel qu'affiché sur https://llm.lab.sspcloud.fr - name: gemma4-26b-moe - kind: vlm - temperature: 0.0 - max_tokens: 4096 - request_logprobs: true - -data: - corpus: dictee - # Imagettes et corrections directement sur S3 (s3fs lit les identifiants - # depuis l'environnement Onyxia / le fichier .env). - images_path: s3://projet-production-ecrits-depp/dictee_2015/ - labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv - # Grille de codage versionnée dans le dépôt (texte de référence + fautes connues). - grid_path: configs/grille_dictee_2015.json - limit: 10 # commencer petit ; mettre null pour tout le corpus - -grid: - # "simplifiee" : le modèle ET les experts codent en 1/9/0 (3/4/5 regroupés en 9). - # "complete" : codage en 1/3/4/5/9/0 (distingue lexical/grammatical/mixte). - # Le prompt du modèle et le prétraitement des codes experts s'adaptent - # automatiquement à ce choix : les deux côtés codent dans le même jeu de modalités. - scheme: simplifiee - -prompt: - method: C # end-to-end (image -> code) - n_few_shot: 0 - enforce_faithful: true - read_final_state: true diff --git a/configs/dictee_qwen3-6-35b-moe_cot.yaml b/configs/dictee_qwen3-6-35b-moe_cot.yaml deleted file mode 100644 index 99b3eff..0000000 --- a/configs/dictee_qwen3-6-35b-moe_cot.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# Benchmark dictée — Qwen3.6-35B-A3B (VLM) avec chain-of-thought -# -# Qwen3.6-35B-A3B est un modèle thinking (génère ... par défaut). -# disable_thinking: true désactive ce comportement pour garder un JSON propre. -# chain_of_thought: true active la verbalisation via le champ "comparaison" dans -# le JSON — c'est différent du thinking natif : c'est ton prompt qui le demande. -# -# Vérifier d'abord le nom exact du modèle sur llm.lab : -# python3 -c "from openai import OpenAI; import os; c = OpenAI( -# base_url=os.environ['LLM_BASE_URL'], api_key=os.environ['LLM_API_KEY']); -# [print(m.id) for m in c.models.list()]" - -name: dictee_qwen3-6-35b-moe_cot -seed: 42 -approach: end_to_end - -model: - name: qwen3-6-35b-moe - kind: vlm - temperature: 0.0 - max_tokens: 8192 # plus grand que gemma4 : CoT + 83 items - max_retries: 2 - disable_thinking: true # ← OBLIGATOIRE pour Qwen3 : évite ... - # qui casse le parsing JSON et multiplie la latence - -data: - corpus: dictee - images_path: s3://projet-production-ecrits-depp/dictee_2015/ - labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv - grid_path: configs/grille_dictee_2015.json - limit: null # null = toutes les copies ; mettre 50 pour un test - -grid: - scheme: simplifiee - -prompt: - method: C - chain_of_thought: true # champ "comparaison" dans le JSON (ton implémentation) - enforce_faithful: true - read_final_state: true - n_few_shot: 0 \ No newline at end of file diff --git a/configs/dictee_qwen3vl_cot.yaml b/configs/dictee_qwen3vl_cot.yaml deleted file mode 100644 index 46d8fec..0000000 --- a/configs/dictee_qwen3vl_cot.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Expérience : approche end-to-end AVEC chain-of-thought pour améliorer le codage. -# Le modèle doit écrire un champ "comparaison" verbalisant la différence -# lue-attendue AVANT de choisir le code. Rend le raisonnement inspectable et -# réduit les erreurs bien lues mais mal codées (« mis » vs « mit »). - -name: dictee_qwen3vl_cot -seed: 42 -approach: end_to_end - -model: - name: qwen3-vl - kind: vlm - temperature: 0.0 - max_tokens: 8192 # + 1 champ par item : marge nécessaire - max_retries: 2 - disable_thinking: true # ← OBLIGATOIRE pour Qwen3 : évite ... - # qui casse le parsing JSON et multiplie la latence - -data: - corpus: dictee - images_path: s3://projet-production-ecrits-depp/dictee_2015/ - labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv - grid_path: configs/grille_dictee_2015.json - limit: null - -grid: - scheme: simplifiee - -prompt: - method: C - n_few_shot: 0 - enforce_faithful: true - read_final_state: true - chain_of_thought: true # ← active la verbalisation de la comparaison diff --git a/configs/dictee_qwen3vl_zeroshot.yaml b/configs/dictee_qwen3vl_zeroshot.yaml deleted file mode 100644 index 09915de..0000000 --- a/configs/dictee_qwen3vl_zeroshot.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Expérience : dictée 2015, qwen3-vl servi sur llm.lab (SSP Cloud). -# Modèle multimodal accessible directement via API : aucun GPU à privatiser. -# Données lues directement sur S3. - -name: qwen3vl_zeroshot -seed: 42 - -model: - # Nom exact tel qu'affiché sur https://llm.lab.sspcloud.fr - name: qwen3-vl - kind: vlm - temperature: 0.0 - max_tokens: 4096 - request_logprobs: true - -data: - corpus: dictee - # Imagettes et corrections directement sur S3 (s3fs lit les identifiants - # depuis l'environnement Onyxia / le fichier .env). - images_path: s3://projet-production-ecrits-depp/dictee_2015/ - labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv - # Grille de codage versionnée dans le dépôt (texte de référence + fautes connues). - grid_path: configs/grille_dictee_2015.json - limit: 10 # commencer petit ; mettre null pour tout le corpus - -grid: - # "simplifiee" : le modèle ET les experts codent en 1/9/0 (3/4/5 regroupés en 9). - # "complete" : codage en 1/3/4/5/9/0 (distingue lexical/grammatical/mixte). - # Le prompt du modèle et le prétraitement des codes experts s'adaptent - # automatiquement à ce choix : les deux côtés codent dans le même jeu de modalités. - scheme: simplifiee - -prompt: - method: C # end-to-end (image -> code) - n_few_shot: 0 - enforce_faithful: true - read_final_state: true diff --git a/configs/finetune/finetune_REFERENCE.yaml b/configs/finetune/finetune_REFERENCE.yaml new file mode 100644 index 0000000..dfc4cdd --- /dev/null +++ b/configs/finetune/finetune_REFERENCE.yaml @@ -0,0 +1,78 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# CONFIG DE RÉFÉRENCE — fine-tuning HTR (QLoRA) sur Scoledit +# ═══════════════════════════════════════════════════════════════════════════ +# +# Ce fichier documente TOUS les paramètres, avec leurs valeurs par défaut +# (entre crochets). Sert de MODÈLE À COPIER. Tout champ omis reprend son défaut : +# en pratique, seuls `name`, `base_model` et quelques hyperparamètres changent. +# +# ⚠ À lancer depuis un service GPU du SSP Cloud (H100 80 Go recommandé). +# Schéma de validation : FinetuneConfig (scripts/finetune_htr_scoledit.py). +# +# Prérequis : +# uv pip install unsloth trl peft transformers bitsandbytes \ +# accelerate datasets pillow s3fs pyyaml pydantic mlflow +# Lancer : +# uv run scripts/finetune_htr_scoledit.py --config configs/finetune/.yaml +# +# Voir configs/README.md pour la logique d'ensemble. +# ═══════════════════════════════════════════════════════════════════════════ + +# Nom UNIQUE du run (run MLflow + dossier de sortie). REQUIS. +name: htr_REFERENCE_finetune_v1 + +# Modèle de base à fine-tuner (identifiant HuggingFace). [défaut : unsloth/gemma-4-27b-it] +base_model: unsloth/gemma-4-27b-it +# Charger en 4-bit (QLoRA) : ~20 Go VRAM au lieu de ~60. [défaut : true] +# Sur H100 80 Go, mettre false pour du LoRA plein (meilleure qualité, +VRAM). +load_in_4bit: true + +# ── Données Scoledit (multi-niveaux + split train/val/test) ───────────────── +data: + # Racines S3 ; les sous-dossiers CP/, CE1/, …, CM2/ sont ajoutés au runtime. + scans_root: s3://projet-production-ecrits-depp/scoledit/scans # [défaut : celui-ci] + annotations_root: s3://projet-production-ecrits-depp/scoledit/annotation # [défaut : celui-ci] + # Niveaux inclus dans l'entraînement. [défaut : [CP, CE1, CE2, CM1, CM2]] + levels: [CP, CE1, CE2, CM1, CM2] + # Répartition : train_ratio + val_ratio, le reste = test (jamais vu). + train_ratio: 0.70 # [défaut : 0.70] + val_ratio: 0.15 # [défaut : 0.15] + seed: 42 # [défaut : 42] + # Limiter le nombre total d'échantillons. null = tout. [défaut : null] + limit: null # mettre 100 pour un test rapide + +# ── Hyperparamètres LoRA ──────────────────────────────────────────────────── +lora: + r: 16 # rang ; 16 = compromis qualité/mémoire, monter à 32 si stagnation [défaut : 16] + lora_alpha: 32 # échelle des adapteurs, typiquement 2×r [défaut : 32] + lora_dropout: 0.05 # [défaut : 0.05] + # Cibler vision ET langage est essentiel pour l'HTR (adapter la vue enfantine + # ET la génération de texte fidèle). + finetune_vision_layers: true # [défaut : true] + finetune_language_layers: true # [défaut : true] + finetune_attention_modules: true # [défaut : true] + finetune_mlp_modules: true # [défaut : true] + +# ── Hyperparamètres d'entraînement ────────────────────────────────────────── +training: + output_dir: checkpoints/htr_REFERENCE_finetune_v1 # [défaut : checkpoints/htr_gemma4_scoledit] + num_train_epochs: 3 # point de départ ; ajuster selon la loss val [défaut : 3] + per_device_train_batch_size: 2 # [défaut : 2] + per_device_eval_batch_size: 2 # [défaut : 2] + gradient_accumulation_steps: 8 # batch effectif = 2 × 8 = 16 [défaut : 8] + learning_rate: 2.0e-4 # [défaut : 2e-4] + warmup_ratio: 0.1 # [défaut : 0.1] + lr_scheduler_type: cosine # cosine | linear | constant [défaut : cosine] + weight_decay: 0.01 # [défaut : 0.01] + logging_steps: 10 # [défaut : 10] + eval_steps: 200 # [défaut : 200] + save_steps: 200 # [défaut : 200] + save_total_limit: 3 # ne garder que les 3 derniers checkpoints [défaut : 3] + bf16: true # H100 supporte bfloat16 nativement [défaut : true] + gradient_checkpointing: true # −30 % VRAM [défaut : true] + report_to: mlflow # mlflow | none | tensorboard [défaut : mlflow] + max_seq_length: 2048 # [défaut : 2048] + +# Règle des ratures : lire l'état FINAL. Le prompt d'entraînement DOIT être +# identique à celui d'inférence. [défaut : true] +read_final_state: true diff --git a/configs/finetune_htr_gemma4.yaml b/configs/finetune_htr_gemma4.yaml deleted file mode 100644 index 024ffb0..0000000 --- a/configs/finetune_htr_gemma4.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# Fine-tuning HTR (transcription) sur Scoledit tous niveaux CP → CM2. -# À lancer depuis un service GPU du SSP Cloud (H100 80 Go recommandé) : -# python scripts/finetune_htr_scoledit.py --config configs/finetune_htr_gemma4.yaml -# -# Prérequis d'installation : -# pip install unsloth trl peft transformers bitsandbytes \ -# accelerate datasets pillow s3fs pyyaml pydantic mlflow - -name: htr_gemma4_scoledit_finetune_v1 - -# Modèle de base : gemma4 27B, chargé en 4-bit (QLoRA). -# Sur H100 80 Go, tu peux passer à load_in_4bit=false pour du LoRA plein -# (meilleure qualité, +30 Go VRAM). -base_model: unsloth/gemma-4-27b-it -load_in_4bit: true - -data: - # Racines S3 (les sous-dossiers CP/, CE1/, ..., CM2/ sont ajoutés au runtime). - scans_root: s3://projet-production-ecrits-depp/scoledit/scans - annotations_root: s3://projet-production-ecrits-depp/scoledit/annotation - levels: [CP, CE1, CE2, CM1, CM2] - train_ratio: 0.70 # 70 % train, 15 % val, 15 % test (test intact) - val_ratio: 0.15 - seed: 42 - limit: null # null = toutes les copies ; mettre 100 pour tester - -lora: - r: 16 # 16 = compromis qualité/mémoire ; monter à 32 si stagnation - lora_alpha: 32 - lora_dropout: 0.05 - # Cibler vision ET langage : essentiel pour l'HTR (adapter la vue enfantine - # ET la génération de texte fidèle). - finetune_vision_layers: true - finetune_language_layers: true - finetune_attention_modules: true - finetune_mlp_modules: true - -training: - output_dir: checkpoints/htr_gemma4_scoledit_v1 - num_train_epochs: 3 # 3 epochs = point de départ ; ajuster selon la loss val - per_device_train_batch_size: 2 - per_device_eval_batch_size: 2 - gradient_accumulation_steps: 8 # batch effectif = 16 - learning_rate: 2.0e-4 - warmup_ratio: 0.1 - lr_scheduler_type: cosine - weight_decay: 0.01 - logging_steps: 10 - eval_steps: 200 - save_steps: 200 - save_total_limit: 3 - bf16: true # H100 supporte bfloat16 nativement - gradient_checkpointing: true - report_to: mlflow # suivi dans user--mlflow.user.lab.sspcloud.fr - max_seq_length: 2048 - -read_final_state: true # en cas de rature, lire l'état final diff --git a/configs/htr/htr_REFERENCE.yaml b/configs/htr/htr_REFERENCE.yaml new file mode 100644 index 0000000..ef6aaa8 --- /dev/null +++ b/configs/htr/htr_REFERENCE.yaml @@ -0,0 +1,45 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# CONFIG DE RÉFÉRENCE — évaluation de la TRANSCRIPTION (HTR) sur Scoledit +# ═══════════════════════════════════════════════════════════════════════════ +# +# Mesure la fidélité de lecture d'un modèle (CER/WER), indépendamment du codage. +# Ce fichier documente TOUS les champs lus. Sert de MODÈLE À COPIER : duplique-le, +# renomme `name`, ajuste `model.name` et les chemins, et lance. +# +# ⚠ Ce script lit le YAML comme un simple dictionnaire (pas via ExperimentConfig). +# Les clés de `data` sont donc `scans_dir` / `annotations_dir` (et NON `*_path`). +# Seul le bloc `model` est validé par le schéma ModelConfig. +# +# Lancer : uv run scripts/run_htr_benchmark.py --config configs/htr/.yaml +# Voir configs/README.md pour la logique d'ensemble. +# ═══════════════════════════════════════════════════════════════════════════ + +# Nom UNIQUE du run (préfixe des fichiers de sortie). REQUIS. +name: htr_REFERENCE + +# Nombre d'échantillons transcrits EN PARALLÈLE (le endpoint vLLM batche les +# requêtes concurrentes). Principal levier de temps mural. [défaut : 8] +# 1 = séquentiel. Monter si le serveur tient, redescendre en cas de timeouts. +concurrency: 8 + +# ── Modèle de transcription ───────────────────────────────────────────────── +# Validé par ModelConfig, mais l'HTR n'exploite QUE les champs ci-dessous +# (la sortie est du texte libre, pas du JSON) : +model: + name: gemma4-26b-moe # ← seul champ à changer pour tester un autre VLM + kind: vlm # doit être multimodal pour lire l'image + temperature: 0.0 # 0 = déterministe + max_tokens: 2048 # une transcription est courte ; 2048 suffit + max_retries: 2 # essais si la réponse est vide + # Les autres champs ModelConfig (request_logprobs, disable_thinking, + # structured_output) sont acceptés mais SANS EFFET en transcription. + +# ── Données Scoledit ──────────────────────────────────────────────────────── +# ⚠ clés `scans_dir` / `annotations_dir` (pas `*_path`). Local ou s3://. +data: + scans_dir: s3://projet-production-ecrits-depp/scoledit/scans/CE1/ + annotations_dir: s3://projet-production-ecrits-depp/scoledit/annotation/CE1/ + limit: 10 # nb d'échantillons ; null = tout le corpus + +# Règle des ratures : lire l'état FINAL (version corrigée par l'élève). [défaut : true] +read_final_state: true diff --git a/configs/htr_gemma4_scoledit.yaml b/configs/htr_gemma4_scoledit.yaml deleted file mode 100644 index f810f11..0000000 --- a/configs/htr_gemma4_scoledit.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Évaluation de la TRANSCRIPTION (HTR) sur le corpus Scoledit. -# Mesure la fidélité de lecture du modèle (CER/WER), indépendamment du codage. -# Pour changer de modèle : modifier uniquement `model.name`. - -name: htr_gemma4_scoledit - -model: - name: gemma4-26b-moe # ← seul champ à changer pour tester un autre VLM - kind: vlm - temperature: 0.0 - max_tokens: 2048 - max_retries: 2 - -data: - scans_dir: s3://projet-production-ecrits-depp/scoledit/scans/CE1/ - annotations_dir: s3://projet-production-ecrits-depp/scoledit/annotation/CE1/ - limit: 10 # null pour tout le corpus - -read_final_state: true # en cas de rature, lire l'état final diff --git a/configs/htr_qwen3vl_scoledit.yaml b/configs/htr_qwen3vl_scoledit.yaml deleted file mode 100644 index a521c21..0000000 --- a/configs/htr_qwen3vl_scoledit.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Évaluation de la TRANSCRIPTION (HTR) sur le corpus Scoledit. -# Mesure la fidélité de lecture du modèle (CER/WER), indépendamment du codage. -# Pour changer de modèle : modifier uniquement `model.name`. - -name: htr_qwen3vl_scoledit - -model: - name: qwen3-vl # ← seul champ à changer pour tester un autre VLM - kind: vlm - temperature: 0.0 - max_tokens: 2048 - max_retries: 2 - -data: - scans_dir: s3://projet-production-ecrits-depp/scoledit/scans/CE1/ - annotations_dir: s3://projet-production-ecrits-depp/scoledit/annotation/CE1/ - limit: 10 # null pour tout le corpus - -read_final_state: true # en cas de rature, lire l'état final diff --git a/configs/scoring/dictee_REFERENCE.yaml b/configs/scoring/dictee_REFERENCE.yaml new file mode 100644 index 0000000..9b20820 --- /dev/null +++ b/configs/scoring/dictee_REFERENCE.yaml @@ -0,0 +1,119 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# CONFIG DE RÉFÉRENCE — scoring de la dictée (méthode C, end-to-end) +# ═══════════════════════════════════════════════════════════════════════════ +# +# Ce fichier documente TOUS les paramètres disponibles, avec leurs valeurs +# possibles et leurs valeurs par défaut. Il est volontairement exhaustif et +# sert de MODÈLE À COPIER : pour une nouvelle expérience, duplique-le, renomme +# le fichier + le champ `name`, et ne garde que les champs que tu changes +# (tout champ omis reprend sa valeur par défaut, indiquée ici entre crochets). +# +# Schéma de validation : ExperimentConfig (src/evaluation_dictee/config.py). +# Lancer : uv run scripts/run_benchmark.py --config configs/scoring/.yaml +# +# Voir configs/README.md pour la logique d'ensemble. +# ═══════════════════════════════════════════════════════════════════════════ + +# Nom UNIQUE du run. Sert à la fois de nom de session Langfuse et de préfixe des +# fichiers de sortie (data/processed/_predictions.jsonl). Deux runs de même +# nom partagent le checkpoint : changer le nom pour repartir de zéro. +# Convention : dictee__ (ex. dictee_gemma4_zeroshot). +name: dictee_REFERENCE + +# Graine aléatoire, pour la reproductibilité. [défaut : 42] +seed: 42 + +# Nombre de copies évaluées EN PARALLÈLE. [défaut : 8] +# Le endpoint vLLM batche les requêtes concurrentes : passer de 1 (séquentiel) à N +# divise le temps mural tant que le serveur suit (débit borné par le GPU). Monter +# (16, 32…) si l'endpoint tient ; redescendre en cas de timeouts / 429. 1 = ancien +# comportement séquentiel. +concurrency: 8 + +# Approche d'évaluation : [défaut : end_to_end] +# end_to_end : un VLM lit l'image ET code en une seule passe (méthode C). +# two_stage : étape 1 = transcription (HTR), étape 2 = codage du texte. +# two_stage exige le bloc `model_stage2` ci-dessous. +approach: end_to_end + +# ── Modèle (étape unique en end_to_end, ou étape 1 = HTR en two_stage) ──────── +model: + # Nom EXACT du modèle tel que servi par vLLM / affiché sur llm.lab.sspcloud.fr. + name: gemma4-26b-moe + # Modalité attendue : [défaut : vlm] + # vlm = image + texte (obligatoire pour lire l'image en méthode C) ; + # llm = texte seul (utile pour l'étape 2 du two_stage) ; htr = HTR classique. + kind: vlm + # Température d'échantillonnage. 0.0 = déterministe, recommandé. [défaut : 0.0] + temperature: 0.0 + # Plafond de tokens générés. 83 items en JSON ⇒ prévoir de la marge pour éviter + # une troncature (finish_reason=length). 8192 convient ; +CoT ⇒ garder 8192. + max_tokens: 8192 # [défaut : 2048] + # Demander les log-probs (sert à estimer la confiance par item). [défaut : true] + request_logprobs: true + # Nombre de tentatives si la réponse est vide/non parsable (0 = aucun retry). + # Après épuisement, la copie est marquée « non transcrite » et exclue des + # métriques de performance. [défaut : 2] + max_retries: 2 + # Désactiver le mode « thinking » natif (bloc ...) des modèles + # Qwen3 / DeepSeek-R1 / QwQ : sinon le JSON est cassé et la latence explose. + # OBLIGATOIRE pour tout modèle thinking ; sans effet sur gemma4/Qwen2.5-VL. + disable_thinking: true # [défaut : true] + # Forcer une sortie JSON conforme au schéma (décodage contraint de vLLM, + # response_format json_schema). Le modèle ne peut PAS produire de texte hors + # JSON (note, commentaire) → supprime les « non transcrites » dues au parsing. + # Mettre à false seulement si l'endpoint ne supporte pas json_schema. [défaut : true] + structured_output: true + +# ── Modèle de l'ÉTAPE 2 (codage textuel), UNIQUEMENT si approach: two_stage ─── +# Peut être un modèle texte seul (kind: llm), plus léger que l'étape 1. +# Omettre ce bloc en end_to_end (ou réutilise `model` si absent en two_stage). +model_stage2: + name: gemma4-26b-moe + kind: llm + temperature: 0.0 + max_tokens: 8192 + +# ── Données ─────────────────────────────────────────────────────────────── +data: + # Corpus visé : [défaut : dictee] + # dictee (texte de référence connu) ou production_ecrite (libre). + corpus: dictee + # Dossier des imagettes. Chemin local OU préfixe S3. + images_path: s3://projet-production-ecrits-depp/dictee_2015/ + # CSV des codes de l'annotateur expert (vérité de terrain). Local ou S3. + labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv + # Grille de codage JSON (mot attendu + fautes connues par item). Versionnée + # dans le dépôt ; chemin relatif à la racine du projet. [défaut : celui-ci] + grid_path: configs/grille_dictee_2015.json + # Nombre de copies à traiter. null = tout le corpus. [défaut : null] + # Commencer petit (5–20) pour valider la chaîne, puis passer à null. + limit: null + +# ── Grille de codage ──────────────────────────────────────────────────────── +grid: + # Jeu de modalités des codes : [défaut : simplifiee] + # simplifiee : 1 / erreur / 0 (3+4+5 regroupés en « erreur ») — CIBLE. + # complete : 1 / 3 / 4 / 5 / 9 / 0 (distingue lexical/grammatical/mixte). + # Le modèle ET les codes experts sont ramenés au MÊME jeu, automatiquement. + scheme: simplifiee + +# ── Stratégie de prompting ──────────────────────────────────────────────── +prompt: + # Méthode d'évaluation : [défaut : C] + # A = HTR classique → LLM texte ; B = VLM transcrit → LLM texte ; + # C = end-to-end (image+référence+grille → codes) — PRIVILÉGIÉE ; + # D = fine-tuning (phase ultérieure). + method: C + # Nombre d'exemples annotés injectés dans le prompt (few-shot). [défaut : 0] + n_few_shot: 0 + # Consigne anti-sur-correction : interdit au modèle de « corriger » + # silencieusement ce que l'élève a écrit. [défaut : true] + enforce_faithful: true + # Règle des ratures (tranchée par la DEPP) : lire l'état FINAL, + # c.-à-d. la version corrigée par l'élève. [défaut : true] + read_final_state: true + # Chain-of-thought : force un champ « comparaison » (différence lue↔attendue) + # AVANT le code. Rend le raisonnement inspectable, réduit les erreurs bien + # lues mais mal codées (« mis » vs « mit »). Coûte des tokens. [défaut : false] + chain_of_thought: false diff --git a/configs/scoring/dictee_end2end.yaml b/configs/scoring/dictee_end2end.yaml new file mode 100644 index 0000000..98d4428 --- /dev/null +++ b/configs/scoring/dictee_end2end.yaml @@ -0,0 +1,45 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Run scoring dictée — MÉTHODE C (end-to-end) +# ═══════════════════════════════════════════════════════════════════════════ +# Un VLM lit l'image ET code chaque item en une seule passe. +# Dérivé de dictee_REFERENCE.yaml : voir ce fichier pour la doc exhaustive de +# chaque paramètre (les champs omis reprennent leur valeur par défaut). +# +# Lancer : uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml +# Sortie : data/processed/dictee_end2end_predictions.jsonl (approach="end_to_end") +# ═══════════════════════════════════════════════════════════════════════════ + +name: dictee_end2end +seed: 42 +concurrency: 8 + +approach: end_to_end + +model: + name: gemma4-26b-moe + kind: vlm + temperature: 0.0 + max_tokens: 8192 + request_logprobs: true + max_retries: 2 + disable_thinking: true + structured_output: true + +# Pas de bloc model_stage2 en end_to_end (une seule passe). + +data: + corpus: dictee + images_path: s3://projet-production-ecrits-depp/dictee_2015/ + labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv + grid_path: configs/grille_dictee_2015.json + limit: null + +grid: + scheme: simplifiee + +prompt: + method: C + n_few_shot: 0 + enforce_faithful: true + read_final_state: true + chain_of_thought: false diff --git a/configs/scoring/dictee_two_stage.yaml b/configs/scoring/dictee_two_stage.yaml new file mode 100644 index 0000000..86d24f2 --- /dev/null +++ b/configs/scoring/dictee_two_stage.yaml @@ -0,0 +1,52 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Run scoring dictée — MÉTHODE B (two-stage : transcription puis codage) +# ═══════════════════════════════════════════════════════════════════════════ +# Étape 1 : un VLM transcrit l'image (raw_transcription). +# Étape 2 : un LLM texte code chaque item à partir de la transcription + grille. +# Dérivé de dictee_REFERENCE.yaml : voir ce fichier pour la doc exhaustive de +# chaque paramètre (les champs omis reprennent leur valeur par défaut). +# +# Lancer : uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml +# Sortie : data/processed/dictee_two_stage_predictions.jsonl (approach="two_stage") +# ═══════════════════════════════════════════════════════════════════════════ + +name: dictee_two_stage +seed: 42 +concurrency: 8 + +approach: two_stage + +# Étape 1 = transcription (doit être multimodal pour lire l'image). +model: + name: gemma4-26b-moe + kind: vlm + temperature: 0.0 + max_tokens: 8192 + request_logprobs: true + max_retries: 2 + disable_thinking: true + structured_output: true + +# Étape 2 = codage textuel (modèle texte seul, obligatoire en two_stage). +model_stage2: + name: gemma4-26b-moe + kind: llm + temperature: 0.0 + max_tokens: 8192 + +data: + corpus: dictee + images_path: s3://projet-production-ecrits-depp/dictee_2015/ + labels_path: s3://projet-production-ecrits-depp/resultat_dictee_2015.csv + grid_path: configs/grille_dictee_2015.json + limit: null + +grid: + scheme: simplifiee + +prompt: + method: B + n_few_shot: 0 + enforce_faithful: true + read_final_state: true + chain_of_thought: false diff --git a/notebooks/05_analyse_transcription_htr.ipynb b/notebooks/05_analyse_transcription_htr.ipynb index 41aeafe..26ef385 100644 --- a/notebooks/05_analyse_transcription_htr.ipynb +++ b/notebooks/05_analyse_transcription_htr.ipynb @@ -19,7 +19,7 @@ "| 5 | Comparaison visuelle référence / hypothèse mot à mot |\n", "\n", "**Prérequis** : avoir lancé\n", - "`python scripts/run_htr_benchmark.py --config configs/htr_gemma4_scoledit.yaml`\n", + "`uv run scripts/run_htr_benchmark.py --config configs/htr_gemma4_scoledit.yaml`\n", "qui produit `data/processed/_htr_predictions.jsonl`.\n", "\n", "**Rappel méthodologique** : la référence Scoledit préserve les fautes de l'élève.\n", diff --git a/pyproject.toml b/pyproject.toml index 1a423d9..0ee8b3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,9 +22,8 @@ dependencies = [ "httpx>=0.27", # Métriques "scikit-learn>=1.4", - # Suivi d'expériences - "mlflow>=2.12", - "langfuse>=2.30", + # Suivi d'expériences (traces LLM, prompts, scores) + "langfuse>=4,<5", # Confort "tqdm>=4.66", "rich>=13.7", @@ -32,6 +31,22 @@ dependencies = [ ] [project.optional-dependencies] +# Dépendances lourdes, installées seulement sur les machines GPU +gpu = [ + "torch>=2.3", + "transformers>=4.45", + "vllm>=0.6", + "qwen-vl-utils>=0.0.8", +] +# Exploration en notebooks (analyse des résultats, diagnostics) +notebooks = [ + "jupyterlab>=4.6.1", + "matplotlib>=3.11.1", +] + +# Outillage de développement (lint, typage, tests) — NON publié avec le paquet. +# Le groupe `dev` est installé automatiquement par un simple `uv sync`. +[dependency-groups] dev = [ "ruff>=0.4", "mypy>=1.10", @@ -40,16 +55,11 @@ dev = [ "types-pyyaml", "pandas-stubs", ] -# Dépendances lourdes, installées seulement sur les machines GPU -gpu = [ - "torch>=2.3", - "transformers>=4.45", - "vllm>=0.6", - "qwen-vl-utils>=0.0.8", -] [project.scripts] eval-ecrit = "evaluation_dictee.cli:app" +add-langfuse-prompt = "evaluation_dictee.utils.add_langfuse_prompt:push_prompts" +add-langfuse-models = "evaluation_dictee.utils.add_langfuse_models:push_models" [build-system] requires = ["hatchling"] diff --git a/scripts/_generate_notebook_03.py b/scripts/_generate_notebook_03.py index a581019..2b963d7 100644 --- a/scripts/_generate_notebook_03.py +++ b/scripts/_generate_notebook_03.py @@ -1,14 +1,4 @@ -"""Regénère notebooks/03_analyse_resultats.ipynb — version consolidée. - -Intègre les évolutions récentes : -- IC analytiques (Wilson + delta method pour le kappa) -- Prévalence par item corrigée du design effect de Kish -- Distributions à 3 panneaux (total / fautes / absents) + courbe de survie -- Métriques accord/kappa/rappel/précision par niveau de difficulté -- Comparaison multi-modèles pour construire un score de confiance -- Stratégie de renvoi basée sur le désaccord multi-modèles (avec IC) -- Export HTML sélectif pour la DEPP -""" +"""Regénère notebooks/03_analyse_resultats.ipynb (analyse consolidée du benchmark).""" # ruff: noqa: E501 from __future__ import annotations @@ -65,7 +55,7 @@ def code(source: str, tag: str | None = None) -> dict: "from scipy import stats as scistats\n" "from scipy.stats import norm as scinorm\n" "from sklearn.metrics import cohen_kappa_score\n\n" - "from evaluation_dictee.data.reference import load_grid\n" + "from evaluation_dictee.data.grid import load_grid\n" "from evaluation_dictee.evaluation.report import (\n" " disagreement_decomposition, load_predictions,\n" " per_copy_metrics, per_item_metrics,\n" diff --git a/scripts/export_predictions.py b/scripts/export_predictions.py new file mode 100644 index 0000000..31d777e --- /dev/null +++ b/scripts/export_predictions.py @@ -0,0 +1,70 @@ +"""Exporte les prédictions d'un run vers S3 (répertoire predictions/). + +Le benchmark écrit les prédictions en local (data/processed/_predictions.jsonl). +Ce script les pousse vers S3 pour pouvoir relancer les notebooks et le site Quarto +sans réexécuter le pipeline. Aucune donnée n'est commitée dans Git. + +Usage : + # scoring — end_to_end OU two_stage (même format, c'est le `name` qui distingue) : + uv run scripts/export_predictions.py --config configs/scoring/dictee_REFERENCE.yaml + + # transcription HTR seule : + uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr + + # ou directement par nom de run : + uv run scripts/export_predictions.py --run-name dictee_gemma4_zeroshot +""" + +from __future__ import annotations + +import argparse + +import yaml + +from evaluation_dictee.config import Secrets +from evaluation_dictee.utils.logging import get_logger +from evaluation_dictee.utils.s3_export import export_run + +logger = get_logger(__name__) + + +def _run_name_from_config(config_path: str) -> str: + """Lit le champ `name` d'un YAML de run (scoring ou HTR).""" + with open(config_path, encoding="utf-8") as f: + return str(yaml.safe_load(f)["name"]) + + +def main() -> None: + """Résout le nom du run et exporte son fichier de prédictions vers S3.""" + parser = argparse.ArgumentParser( + description="Exporte les prédictions d'un run vers S3 (répertoire predictions/)." + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--config", help="YAML du run (le nom est lu dans le champ `name`).") + source.add_argument("--run-name", help="Nom du run (préfixe du fichier de prédictions).") + parser.add_argument( + "--htr", + action="store_true", + help="Exporte le fichier HTR (_htr_predictions.jsonl) au lieu du scoring.", + ) + parser.add_argument( + "--source-dir", + default="data/processed", + help="Dossier local des prédictions. [défaut : data/processed]", + ) + parser.add_argument( + "--dest-prefix", + default=None, + help="Préfixe S3 de destination. [défaut : S3_PREDICTIONS_PREFIX / config]", + ) + args = parser.parse_args() + + run_name = args.run_name or _run_name_from_config(args.config) + dest_prefix = args.dest_prefix or Secrets().s3_predictions_prefix + + dest = export_run(run_name, dest_prefix, source_dir=args.source_dir, htr=args.htr) + logger.info("OK — disponible sur S3 : %s", dest) + + +if __name__ == "__main__": + main() diff --git a/scripts/finetune_htr_scoledit.py b/scripts/finetune_htr_scoledit.py index ed7f7ae..97a56d5 100644 --- a/scripts/finetune_htr_scoledit.py +++ b/scripts/finetune_htr_scoledit.py @@ -1,24 +1,9 @@ -"""Fine-tune un VLM gemma4 pour la transcription (HTR) sur Scoledit tous niveaux. +"""Fine-tune un VLM gemma4 (QLoRA) pour la transcription HTR sur Scoledit tous niveaux. -Objectif : améliorer la fidélité de lecture du modèle sur l'écriture manuscrite -d'enfants du CP au CM2, en fine-tunant avec QLoRA (LoRA en 4-bit). - -Corpus : Scoledit CP, CE1, CE2, CM1, CM2 (scans + transcriptions humaines TEI). -Le modèle apprend à reproduire fidèlement le texte de l'élève, FAUTES COMPRISES — -c'est la lecture qu'on entraîne, pas la correction orthographique. - -Sortie : un adaptateur LoRA (~50-200 Mo) qui se charge par-dessus le modèle de -base à l'inférence. Le fichier de configuration YAML pilote tous les -hyperparamètres pour la reproductibilité. - -Prérequis (à installer sur le service GPU Onyxia) : - pip install unsloth trl peft transformers bitsandbytes accelerate +Nécessite une H100/A100 80 Go du SSP Cloud, avec au préalable : + uv pip install unsloth trl peft transformers bitsandbytes accelerate datasets pillow s3fs pyyaml pydantic mlflow - -Usage : - python scripts/finetune_htr_scoledit.py --config configs/finetune_htr_gemma4.yaml - -Ce script s'exécute sur une H100/A100 80 Go du SSP Cloud. Ne pas lancer sans GPU. +Usage : uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml """ from __future__ import annotations @@ -50,18 +35,13 @@ class DataConfig(BaseModel): """Localisation des données Scoledit multi-niveaux et split train/val/test.""" - # Racine des scans (contient les sous-dossiers CP, CE1, CE2, CM1, CM2). scans_root: str = "s3://projet-production-ecrits-depp/scoledit/scans" - # Racine des annotations (idem). annotations_root: str = "s3://projet-production-ecrits-depp/scoledit/annotation" - # Niveaux à inclure dans l'entraînement. levels: list[str] = Field(default_factory=lambda: ["CP", "CE1", "CE2", "CM1", "CM2"]) - # Répartition des splits : 70 % train, 15 % val, 15 % test. train_ratio: float = 0.70 val_ratio: float = 0.15 - # Le reste va dans le test set (jamais vu pendant l'entraînement). + # Le reste (15 %) va dans le test set, jamais vu pendant l'entraînement. seed: int = 42 - # Limiter le nombre total d'échantillons (utile pour un test rapide). limit: int | None = None @@ -104,20 +84,18 @@ class FinetuneConfig(BaseModel): """Configuration complète du fine-tuning.""" name: str = Field(..., description="Nom unique du run (MLflow + dossier).") - # Modèle de base à fine-tuner (identifiant HuggingFace). - base_model: str = "unsloth/gemma-4-27b-it" + base_model: str = "unsloth/gemma-4-27b-it" # identifiant HuggingFace # Chargement en 4-bit (QLoRA) : ~20 Go VRAM au lieu de 60. load_in_4bit: bool = True data: DataConfig = Field(default_factory=DataConfig) lora: LoraConfig = Field(default_factory=LoraConfig) training: TrainingConfig = Field(default_factory=TrainingConfig) - # Prompt fixe utilisé pour transcrire (identique à celui d'inférence !). - # Point crucial : le prompt d'entraînement et d'inférence DOIVENT matcher. + # Le prompt d'entraînement et d'inférence DOIVENT être identiques. read_final_state: bool = True -# Résolution des annotations forward-declared (nécessaire avec -# `from __future__ import annotations` + Pydantic + Literal). +# Résolution des annotations forward-declared, nécessaire avec +# `from __future__ import annotations` + Pydantic + Literal. TrainingConfig.model_rebuild() FinetuneConfig.model_rebuild() @@ -135,7 +113,7 @@ def load_finetune_config(path: str | Path) -> FinetuneConfig: @dataclass class DatasetSplit: - """Contient les trois splits train/val/test.""" + """Les trois splits train/val/test.""" train: list[ScoledtSample] val: list[ScoledtSample] @@ -152,14 +130,7 @@ def _list_annotations(fs, annotations_dir: str) -> list[str]: def load_scoledit_all_levels(cfg: DataConfig) -> list[ScoledtSample]: - """Charge les échantillons Scoledit pour tous les niveaux configurés. - - Args: - cfg: config des données (chemins, niveaux, limite). - - Returns: - Liste unifiée d'échantillons (avec le niveau conservé pour la stratification). - """ + """Charge les échantillons Scoledit pour tous les niveaux configurés.""" samples: list[ScoledtSample] = [] fs, _ = fsspec.core.url_to_fs(cfg.annotations_root) for level in cfg.levels: @@ -192,11 +163,10 @@ def load_scoledit_all_levels(cfg: DataConfig) -> list[ScoledtSample]: def stratified_split(samples: list[ScoledtSample], cfg: DataConfig) -> DatasetSplit: - """Split train/val/test STRATIFIÉ par niveau scolaire. + """Split train/val/test stratifié par niveau scolaire. - Assure que chaque niveau (CP, CE1, ..., CM2) soit représenté dans les trois - splits selon les mêmes proportions. Sans stratification, un niveau minoritaire - pourrait n'apparaître que dans le train, biaisant l'évaluation. + Sans stratification, un niveau minoritaire pourrait n'apparaître que dans le + train, biaisant l'évaluation. """ rng = random.Random(cfg.seed) by_level: dict[str, list[ScoledtSample]] = {} @@ -220,7 +190,7 @@ def stratified_split(samples: list[ScoledtSample], cfg: DataConfig) -> DatasetSp n - n_train - n_val, n, ) - rng.shuffle(train) # mélanger les niveaux + rng.shuffle(train) # mélanger les niveaux entre eux rng.shuffle(val) return DatasetSplit(train=train, val=val, test=test) @@ -248,25 +218,13 @@ def _load_image_from_s3_or_local(path: str) -> Image.Image: with fsspec.open(path, "rb") as f: data = f.read() img = Image.open(io.BytesIO(data)) - # Convertir en RGB (les VLM attendent 3 canaux ; les JPEG le sont déjà) - if img.mode != "RGB": + if img.mode != "RGB": # les VLM attendent 3 canaux img = img.convert("RGB") return img def sample_to_conversation(s: ScoledtSample) -> dict: - """Convertit un échantillon Scoledit en conversation multimodale. - - Format compatible SFTTrainer (Unsloth / TRL) : image + prompt utilisateur, - puis réponse assistant contenant le JSON de transcription. Le modèle - apprend à imiter la réponse assistant. - - Args: - s: échantillon Scoledit (image + référence humaine). - - Returns: - Dict au format {"messages": [...]} attendu par le trainer. - """ + """Convertit un échantillon Scoledit en conversation multimodale (format SFTTrainer).""" reponse_attendue = json.dumps({"transcription": s.reference}, ensure_ascii=False) return { "messages": [ @@ -295,11 +253,7 @@ def build_hf_dataset(samples: list[ScoledtSample]): def run_finetuning(cfg: FinetuneConfig) -> None: - """Enchaîne : chargement modèle, données, entraînement, sauvegarde adapteur. - - Args: - cfg: configuration validée du fine-tuning. - """ + """Enchaîne : chargement modèle, données, entraînement, sauvegarde adapteur.""" # Imports paresseux : ces libs sont lourdes et pas installées côté CPU from trl import SFTConfig, SFTTrainer from unsloth import FastVisionModel diff --git a/scripts/run_benchmark.py b/scripts/run_benchmark.py index 60b4bf0..b6351f7 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -1,19 +1,16 @@ -"""Point d'entrée simple pour lancer un benchmark. +"""Point d'entrée pour lancer un benchmark de scoring. -Pour les débutant·e·s : ce script est volontairement minimal. Il lit une config, -construit le modèle, lance le benchmark et affiche les métriques. Suivre le fil -de `run_benchmark` (dans src/.../pipeline/benchmark.py) pour comprendre le projet. - -Usage : - python scripts/run_benchmark.py --config configs/dictee_qwen7b_zeroshot.yaml +Usage : uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml """ from __future__ import annotations import argparse +from langfuse import get_client + from evaluation_dictee.config import Secrets, load_config -from evaluation_dictee.data.reference import load_grid +from evaluation_dictee.data.grid import load_grid from evaluation_dictee.evaluation.calibration import referral_curve from evaluation_dictee.models.factory import build_scorer from evaluation_dictee.pipeline.benchmark import run_benchmark @@ -24,7 +21,7 @@ def main() -> None: - """Parse les arguments, lance le benchmark, affiche métriques et calibration.""" + """Lance le benchmark et affiche métriques et calibration.""" parser = argparse.ArgumentParser(description="Lance un benchmark d'évaluation.") parser.add_argument("--config", required=True, help="Chemin du fichier YAML.") args = parser.parse_args() @@ -40,17 +37,31 @@ def main() -> None: api_key=secrets.llm_api_key, ) - with experiment_run(config): - result = run_benchmark(config, scorer) - log_metrics( - { - "raw_agreement": result.metrics.raw_agreement, - "cohen_kappa": result.metrics.cohen_kappa, - } - ) + try: + with experiment_run(config): + result = run_benchmark(config, scorer) + log_metrics( + config, + { + "raw_agreement": result.metrics.raw_agreement, + "cohen_kappa": result.metrics.cohen_kappa, + "n_items": result.metrics.n_items, + "n_blank": len(result.blank_copies), + "n_non_transcribed": len(result.non_transcribed), + }, + ) + finally: + # Langfuse envoie les traces en asynchrone : flush obligatoire sinon les + # dernières traces sont perdues si le script se termine avant l'envoi. + get_client().flush() logger.info("Accord brut : %.1f%%", result.metrics.raw_agreement * 100) logger.info("Kappa de Cohen : %.3f", result.metrics.cohen_kappa) + logger.info( + "Copies vierges auto-codées « 0 » : %d | copies non transcrites (exclues) : %d", + len(result.blank_copies), + len(result.non_transcribed), + ) logger.info("Courbe de renvoi humain :") for point in referral_curve(result.y_true, result.y_pred, result.confidences): diff --git a/scripts/run_htr_benchmark.py b/scripts/run_htr_benchmark.py index efa30b0..30ad1d0 100644 --- a/scripts/run_htr_benchmark.py +++ b/scripts/run_htr_benchmark.py @@ -1,7 +1,7 @@ """Point d'entrée : évaluer la transcription (HTR) d'un modèle sur Scoledit. Usage : - python scripts/run_htr_benchmark.py --config configs/htr_gemma4_scoledit.yaml + uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml """ from __future__ import annotations @@ -19,7 +19,7 @@ def main() -> None: - """Charge la config, transcrit le corpus Scoledit, affiche CER/WER.""" + """Transcrit le corpus Scoledit et affiche CER/WER.""" parser = argparse.ArgumentParser(description="Évalue la transcription HTR.") parser.add_argument("--config", required=True, help="Chemin du fichier YAML.") args = parser.parse_args() @@ -48,7 +48,12 @@ def main() -> None: read_final_state=cfg.get("read_final_state", True), ) - result = run_htr_benchmark(samples, transcriber, run_name=cfg["name"]) + result = run_htr_benchmark( + samples, + transcriber, + run_name=cfg["name"], + concurrency=cfg.get("concurrency", 8), + ) logger.info("CER moyen : %.1f%%", result.mean_cer * 100) logger.info("WER moyen : %.1f%%", result.mean_wer * 100) diff --git a/src/evaluation_dictee/cli.py b/src/evaluation_dictee/cli.py index facd79b..6065b72 100644 --- a/src/evaluation_dictee/cli.py +++ b/src/evaluation_dictee/cli.py @@ -1,19 +1,22 @@ -"""Interface en ligne de commande du projet. +"""Interface en ligne de commande, exposée via `eval-ecrit` (voir pyproject.toml). -Exposée via la commande `eval-ecrit` (voir pyproject.toml [project.scripts]). -Exemple : `eval-ecrit benchmark configs/dictee_qwen7b_zeroshot.yaml` +Exemple : `eval-ecrit benchmark configs/scoring/dictee_REFERENCE.yaml` """ from __future__ import annotations import typer +import yaml +from langfuse import get_client from rich.console import Console from rich.table import Table from evaluation_dictee.config import Secrets, load_config -from evaluation_dictee.data.reference import load_grid +from evaluation_dictee.data.grid import load_grid +from evaluation_dictee.evaluation.metrics import ScoringMetrics from evaluation_dictee.models.factory import build_scorer from evaluation_dictee.pipeline.benchmark import run_benchmark +from evaluation_dictee.utils.s3_export import export_run from evaluation_dictee.utils.tracking import experiment_run, log_metrics app = typer.Typer(help="Évaluation automatique de la production d'écrit (DEPP × SSP Lab).") @@ -24,8 +27,11 @@ def benchmark(config_path: str) -> None: """Lance un benchmark à partir d'un fichier de configuration YAML. + Charge la config et les secrets, construit le scorer, exécute le run (tracé + dans Langfuse), journalise les métriques puis les affiche. + Args: - config_path: chemin vers la config de l'expérience. + config_path: Chemin du fichier YAML de configuration du run. """ config = load_config(config_path) secrets = Secrets() @@ -40,21 +46,71 @@ def benchmark(config_path: str) -> None: api_key=secrets.llm_api_key, ) - with experiment_run(config): - result = run_benchmark(config, scorer) - log_metrics( - { - "raw_agreement": result.metrics.raw_agreement, - "cohen_kappa": result.metrics.cohen_kappa, - "n_items": result.metrics.n_items, - } - ) + try: + with experiment_run(config): + result = run_benchmark(config, scorer) + log_metrics( + config, + { + "raw_agreement": result.metrics.raw_agreement, + "cohen_kappa": result.metrics.cohen_kappa, + "n_items": result.metrics.n_items, + }, + ) + finally: + # Flush explicite : Langfuse envoie en asynchrone, sinon les dernières traces sont perdues. + get_client().flush() _print_metrics(result.metrics) -def _print_metrics(metrics) -> None: # type: ignore[no-untyped-def] - """Affiche les métriques dans un tableau lisible.""" +@app.command() +def export( + config_path: str | None = typer.Argument( + None, help="YAML du run (le nom est lu dans le champ `name`)." + ), + run_name: str | None = typer.Option( + None, "--run-name", help="Nom du run (alternative à config_path)." + ), + htr: bool = typer.Option( + False, "--htr", help="Exporte le fichier HTR au lieu du fichier de scoring." + ), + source_dir: str = typer.Option("data/processed", help="Dossier local des prédictions."), + dest_prefix: str | None = typer.Option( + None, help="Préfixe S3 de destination. [défaut : S3_PREDICTIONS_PREFIX]" + ), +) -> None: + """Exporte les prédictions d'un run vers S3 (répertoire predictions/). + + Le pipeline écrit les prédictions en local ; cette commande pousse le JSONL + fini vers S3 pour relancer notebooks et site Quarto sans réexécuter le run. + Fournir SOIT un YAML de run, SOIT `--run-name`. + + Args: + config_path: Chemin du YAML du run (le nom est lu dans `name`). + run_name: Nom du run, alternative au YAML. + htr: Exporte `_htr_predictions.jsonl` (transcription seule). + source_dir: Dossier local des prédictions. + dest_prefix: Préfixe S3 de destination (sinon celui des secrets/env). + """ + if not (config_path or run_name): + raise typer.BadParameter("Fournir un YAML de run ou --run-name.") + + if run_name is None: + with open(config_path, encoding="utf-8") as f: + run_name = str(yaml.safe_load(f)["name"]) + + prefix = dest_prefix or Secrets().s3_predictions_prefix + dest = export_run(run_name, prefix, source_dir=source_dir, htr=htr) + console.print(f"[green]Exporté sur S3 :[/green] {dest}") + + +def _print_metrics(metrics: ScoringMetrics) -> None: + """Affiche les métriques dans un tableau lisible. + + Args: + metrics: Métriques de scoring à présenter. + """ table = Table(title="Résultats du benchmark") table.add_column("Métrique") table.add_column("Valeur", justify="right") diff --git a/src/evaluation_dictee/config.py b/src/evaluation_dictee/config.py index 4027abe..1e73504 100644 --- a/src/evaluation_dictee/config.py +++ b/src/evaluation_dictee/config.py @@ -1,8 +1,4 @@ -"""Chargement et validation des configurations d'expérience. - -Toute la configuration passe par des fichiers YAML (dossier `configs/`) validés -par Pydantic. Aucun chemin ni paramètre ne doit être codé en dur ailleurs. -""" +"""Chargement et validation des configurations d'expérience (YAML validé par Pydantic).""" from __future__ import annotations @@ -15,10 +11,7 @@ class Secrets(BaseSettings): - """Secrets lus depuis les variables d'environnement (ou le fichier .env). - - Ne contient jamais de valeur en clair dans le code. Voir .env.example. - """ + """Secrets lus depuis les variables d'environnement (ou le fichier .env).""" model_config = SettingsConfigDict(env_file=".env", extra="ignore") @@ -26,55 +19,49 @@ class Secrets(BaseSettings): aws_secret_access_key: str = "" aws_s3_endpoint: str = "minio.lab.sspcloud.fr" s3_bucket: str = "" + # Répertoire S3 où sont déposées les prédictions finies (JAMAIS dans Git : les + # transcriptions sont des données d'élèves mineurs). Sert à relancer notebooks + # et site Quarto sans réexécuter le pipeline. Surchargé par S3_PREDICTIONS_PREFIX. + s3_predictions_prefix: str = "s3://projet-production-ecrits-depp/predictions" llm_base_url: str = "https://llm.lab.sspcloud.fr/api/v1" llm_api_key: str = "dummy" - mlflow_tracking_uri: str = "" - class ModelConfig(BaseModel): """Paramètres du modèle à interroger.""" - # Nom du modèle servi par vLLM (ex: "Qwen/Qwen2.5-VL-7B-Instruct") - name: str - # "vlm" = modèle multimodal image+texte ; "llm" = texte seul ; "htr" = HTR classique + name: str # nom du modèle servi par vLLM (ex: "Qwen/Qwen2.5-VL-7B-Instruct") + # "vlm" = multimodal image+texte ; "llm" = texte seul ; "htr" = HTR classique kind: Literal["vlm", "llm", "htr"] = "vlm" temperature: float = 0.0 max_tokens: int = 2048 - # Demander les log-probabilités pour estimer la confiance par item - request_logprobs: bool = True - # Nombre de tentatives si la réponse est vide/non parsable (0 = pas de retry). - # Une copie restée sans transcription après tous les essais est marquée - # "non transcrite" et exclue du calcul des métriques de performance. + request_logprobs: bool = True # log-probs pour estimer la confiance par item + # 0 = pas de retry. Copie sans transcription après tous les essais = exclue des métriques. max_retries: int = 2 - # Désactiver le mode « thinking » natif des modèles Qwen3 / DeepSeek-R1. - # Ces modèles génèrent par défaut un bloc ... avant la réponse, - # ce qui casse le parsing JSON et multiplie la latence par 5 à 10. - # Mettre à True pour tout modèle thinking : Qwen3-235B-A22B, Qwen3.6-35B-A3B, - # QwQ, DeepSeek-R1, etc. - # Sans effet sur les modèles classiques (gemma4, Qwen2.5-VL, ...). + # Désactive le bloc des modèles thinking (Qwen3, DeepSeek-R1, QwQ) qui casse + # le parsing JSON et multiplie la latence. Sans effet sur gemma4, Qwen2.5-VL... disable_thinking: bool = True + # Force une sortie JSON conforme au schéma (décodage contraint vLLM), supprimant les + # copies non parsables. Mettre à False si l'endpoint ne supporte pas json_schema. + structured_output: bool = True class DataConfig(BaseModel): """Localisation et périmètre des données.""" corpus: Literal["dictee", "production_ecrite"] = "dictee" - # Dossier des imagettes. Accepte un chemin local OU un préfixe S3, ex : - # s3://projet-production-ecrits-depp/dictee_2015/ - images_path: str - # Fichier CSV des codes de l'annotateur expert. Comme la dictée n'a qu'un seul - # annotateur, ce fichier fait office de vérité de terrain (gold standard). - # Accepte un chemin local OU S3, ex : - # s3://projet-production-ecrits-depp/resultat_dictee_2015.csv + images_path: str # chemin local OU préfixe S3 (s3://.../dictee_2015/) + # CSV des codes experts, faisant office de gold standard (annotateur unique). Local ou S3. labels_path: str - # Grille de codage au format JSON (versionnée dans configs/). Contient, par - # item, le mot attendu et les fautes connues. Sert à construire le prompt et - # à fournir le texte de référence (plus besoin d'un fichier .txt séparé). + # Grille JSON (versionnée dans configs/) : mot attendu + fautes connues par item. grid_path: str = "configs/grille_dictee_2015.json" - # Limiter le nombre de copies (utile pour les tests rapides) - limit: int | None = None + limit: int | None = None # limiter le nombre de copies (tests rapides) + # Seuil de densité d'encre en dessous duquel une copie est jugée VIERGE (l'élève + # n'a rien écrit : seul le pré-imprimé marque ~2%). Ces copies sont codées "0" + # (absent) sur tous les items, sans appel modèle, identiquement pour toutes les + # méthodes — évite que l'end-to-end hallucine la référence. 0 pour désactiver. + blank_ink_threshold: float = 0.025 class GridConfig(BaseModel): @@ -91,25 +78,24 @@ class PromptConfig(BaseModel): n_few_shot: int = 0 # nombre d'exemples annotés dans le prompt enforce_faithful: bool = True # consigne anti-sur-correction read_final_state: bool = True # règle des ratures : lire l'état final - # Chain-of-thought : force le modèle à écrire un champ "comparaison" AVANT - # le code, pour verbaliser la différence lue-attendue. Rend le raisonnement - # inspectable et réduit les erreurs bien lues mais mal codées (« mis » vs « mit »). + # Force un champ "comparaison" avant le code (verbalise la différence lue-attendue). chain_of_thought: bool = False class ExperimentConfig(BaseModel): """Configuration complète d'une expérience (un fichier YAML = un run).""" - name: str = Field(..., description="Nom unique du run, utilisé dans MLflow") + name: str = Field(..., description="Nom unique du run, utilisé comme nom de trace Langfuse") seed: int = 42 - # Approche d'évaluation : - # - "end_to_end" (approche 2) : un VLM lit l'image ET code en une passe. - # - "two_stage" (approche 1) : étape 1 HTR (transcription) puis étape 2 - # codage du texte transcrit. Permet d'isoler lecture et jugement. + # Nombre de copies évaluées EN PARALLÈLE (le endpoint vLLM batche les requêtes + # concurrentes). 1 = séquentiel (ancien comportement). Monter tant que le serveur + # suit (débit borné par le GPU) ; redescendre en cas d'erreurs/timeout. + concurrency: int = Field(default=8, ge=1) + # "end_to_end" : un VLM lit l'image ET code en une passe. + # "two_stage" : étape 1 HTR (transcription) puis étape 2 codage (isole lecture/jugement). approach: Literal["end_to_end", "two_stage"] = "end_to_end" model: ModelConfig - # Modèle de l'ÉTAPE 2 (codage textuel) pour l'approche two_stage. Peut être - # un modèle texte seul, plus léger. Si absent, on réutilise `model`. + # Modèle de l'étape 2 (codage textuel) en two_stage ; si absent, on réutilise `model`. model_stage2: ModelConfig | None = None data: DataConfig grid: GridConfig = GridConfig() @@ -120,10 +106,10 @@ def load_config(path: str | Path) -> ExperimentConfig: """Charge et valide une configuration d'expérience depuis un fichier YAML. Args: - path: chemin vers le fichier YAML. + path: Chemin du fichier YAML de configuration. Returns: - La configuration validée. + La configuration d'expérience validée. """ with open(path, encoding="utf-8") as f: raw = yaml.safe_load(f) @@ -138,40 +124,32 @@ def load_config(path: str | Path) -> ExperimentConfig: class HTRDataConfig(BaseModel): """Localisation des données Scoledit pour l'évaluation HTR.""" - # Dossier des images de copies (local ou s3://.../scans/CE1/). - scans_path: str - # Dossier des transcriptions JSON de référence (local ou s3://.../annotation/CE1/). - annotations_path: str - # Limiter le nombre d'échantillons (utile pour les tests rapides). - limit: int | None = None + scans_path: str # local ou s3://.../scans/CE1/ + annotations_path: str # transcriptions JSON de référence, local ou s3://.../annotation/CE1/ + limit: int | None = None # limiter le nombre d'échantillons (tests rapides) class HTRExperimentConfig(BaseModel): - """Configuration complète d'une expérience de transcription (HTR). - - Volontairement distincte d'`ExperimentConfig` car la structure des données - (scans + annotations JSON) et les métriques (CER/WER) diffèrent de celles - du codage de dictée. Mêmes principes toutefois : validation Pydantic, - un fichier YAML = un run reproductible. - """ + """Configuration d'une expérience HTR : données et métriques (CER/WER) spécifiques.""" name: str = Field(..., description="Nom unique du run.") seed: int = 42 + # Nombre d'échantillons transcrits EN PARALLÈLE (cf. `concurrency` du scoring). + concurrency: int = Field(default=8, ge=1) model: ModelConfig data: HTRDataConfig - # Consigne HTR : en cas de rature, lire l'état final (corrigé par l'élève). - read_final_state: bool = True + read_final_state: bool = True # en cas de rature, lire l'état final def load_htr_config(path: str | Path) -> HTRExperimentConfig: """Charge et valide une configuration HTR depuis un fichier YAML. Args: - path: chemin vers le fichier YAML. + path: Chemin du fichier YAML de configuration HTR. Returns: - La configuration HTR validée. + La configuration d'expérience HTR validée. """ with open(path, encoding="utf-8") as f: raw = yaml.safe_load(f) - return HTRExperimentConfig.model_validate(raw) \ No newline at end of file + return HTRExperimentConfig.model_validate(raw) diff --git a/src/evaluation_dictee/data/grid.py b/src/evaluation_dictee/data/grid.py index f9a08dc..c691825 100644 --- a/src/evaluation_dictee/data/grid.py +++ b/src/evaluation_dictee/data/grid.py @@ -1,89 +1,55 @@ -"""Définition de la grille de codage de la dictée et logique de simplification. +"""Chargement de la grille de codage de la dictée (JSON versionné dans configs/). -Voir CLAUDE.md §3. La grille complète distingue 6 codes ; la grille simplifiée -(cible principale du projet) regroupe les erreurs de mots en un seul code. +Le texte de référence est désormais dérivé de la grille. """ from __future__ import annotations -# Codes de la grille complète -CODE_CORRECT = "1" -CODE_ERR_LEXICALE = "3" -CODE_ERR_GRAMMATICALE = "4" -CODE_ERR_LES_DEUX = "5" -CODE_ERR_PONCT = "9" # documenté pour la ponctuation (et observé sur des mots) -CODE_ABSENT = "0" +import json +from dataclasses import dataclass -# Codes de la grille simplifiée -SIMPLE_CORRECT = "1" -SIMPLE_ERREUR = "9" # toute erreur, quel qu'en soit le type -SIMPLE_ABSENT = "0" -# Ensemble des codes considérés comme « une erreur » (hors absent/correct) -_CODES_ERREUR = {CODE_ERR_LEXICALE, CODE_ERR_GRAMMATICALE, CODE_ERR_LES_DEUX, CODE_ERR_PONCT} +@dataclass +class GridItem: + """Un item de la grille : mot attendu, type et fautes connues.""" -# Alphabet de codes attendu pour chaque schéma (après normalisation). -# Sert de référence pour vérifier que modèle et experts codent dans le même jeu. -ALLOWED_CODES = { - "simplifiee": {SIMPLE_CORRECT, SIMPLE_ERREUR, SIMPLE_ABSENT}, # {1, 9, 0} - "complete": { - CODE_CORRECT, - CODE_ERR_LEXICALE, - CODE_ERR_GRAMMATICALE, - CODE_ERR_LES_DEUX, - CODE_ERR_PONCT, - CODE_ABSENT, # {1,3,4,5,9,0} - }, -} + item_id: str + attendu: str + type: str # "mot" ou "ponctuation" + ex_lexicale: list[str] + ex_grammaticale: list[str] + ex_les_deux: list[str] -def allowed_codes(scheme: str) -> set[str]: - """Renvoie l'ensemble des codes valides après normalisation pour un schéma. +@dataclass +class DictationGrid: + """Grille complète de la dictée.""" - Args: - scheme: "simplifiee" ou "complete". - - Returns: - L'ensemble des codes attendus (ex. {"1", "9", "0"} pour simplifiee). - - Raises: - ValueError: si le schéma est inconnu. - """ - if scheme not in ALLOWED_CODES: - raise ValueError(f"Schéma inconnu : {scheme!r}. Attendu : {set(ALLOWED_CODES)}.") - return ALLOWED_CODES[scheme] - - -def to_simplified(code: str) -> str: - """Convertit un code de la grille complète vers la grille simplifiée 1/9/0. - - Args: - code: code de la grille complète ("1", "3", "4", "5", "9" ou "0"). - - Returns: - "1" (correct), "9" (erreur quelconque) ou "0" (absent). - """ - code = code.strip() - if code == CODE_ABSENT: - return SIMPLE_ABSENT - if code == CODE_CORRECT: - return SIMPLE_CORRECT - if code in _CODES_ERREUR: - return SIMPLE_ERREUR - # Code inattendu : on le renvoie tel quel pour qu'il soit visible dans l'analyse - return code + reference_text: str + items: list[GridItem] -def normalize(code: str, scheme: str) -> str: - """Normalise un code selon le schéma cible. +def load_grid(path: str) -> DictationGrid: + """Charge la grille de codage depuis un fichier JSON. Args: - code: code brut. - scheme: "simplifiee" ou "complete". + path: Chemin du fichier JSON de la grille. Returns: - Le code normalisé. + La grille désérialisée : texte de référence et liste des items. """ - if scheme == "simplifiee": - return to_simplified(code) - return code.strip() + with open(path, encoding="utf-8") as f: + raw = json.load(f) + + items = [ + GridItem( + item_id=it["id"], + attendu=it["attendu"], + type=it["type"], + ex_lexicale=it.get("ex_lexicale", []), + ex_grammaticale=it.get("ex_grammaticale", []), + ex_les_deux=it.get("ex_les_deux", []), + ) + for it in raw["items"] + ] + return DictationGrid(reference_text=raw["reference_text"], items=items) diff --git a/src/evaluation_dictee/data/loaders.py b/src/evaluation_dictee/data/loaders.py index 843f802..63c8903 100644 --- a/src/evaluation_dictee/data/loaders.py +++ b/src/evaluation_dictee/data/loaders.py @@ -1,12 +1,7 @@ """Chargement des imagettes et des labels experts (local ou S3). -Points d'attention : -- Les fichiers fournis par la DEPP sont des TIFF bi-level 1 bit (extension - trompeuse ".png"). On les normalise systématiquement en niveaux de gris. -- Les chemins peuvent être locaux OU sur S3 (préfixe "s3://"). L'accès S3 passe - par fsspec/s3fs, qui lit les identifiants depuis les variables d'environnement - (AWS_ACCESS_KEY_ID, etc., voir .env). Sur le SSP Cloud, ces variables sont déjà - injectées par Onyxia. +Les fichiers DEPP sont des TIFF 1 bit à extension trompeuse ".png" : on les +normalise systématiquement en niveaux de gris. """ from __future__ import annotations @@ -16,19 +11,13 @@ from dataclasses import dataclass, field import fsspec +import numpy as np from PIL import Image @dataclass class Copy: - """Une copie d'élève : son identifiant, son image et les codes experts. - - Attributes: - copy_id: identifiant de la copie (nom de fichier image). - image_path: chemin (local ou s3://) de l'image. - expert_codes: codes de l'annotateur par item (vérité de terrain). - item_ids: identifiants des items. - """ + """Une copie d'élève : son identifiant, son image et les codes experts.""" copy_id: str image_path: str @@ -37,37 +26,58 @@ class Copy: def _join(base: str, name: str) -> str: - """Concatène un dossier (local ou s3://) et un nom de fichier.""" + """Concatène un dossier (local ou s3://) et un nom de fichier. + + Args: + base: Dossier de base, avec ou sans slash final. + name: Nom du fichier à ajouter. + + Returns: + Le chemin complet `base/name` (un seul slash de séparation). + """ return base.rstrip("/") + "/" + name def load_image(path: str) -> Image.Image: - """Charge une image (locale ou S3) en niveaux de gris. - - Gère les TIFF 1 bit déguisés en .png. La conversion en "L" (8 bits) évite - les erreurs de décodage en aval et homogénéise le format d'entrée. + """Charge une image (locale ou S3) en niveaux de gris (gère les TIFF 1 bit déguisés en .png). Args: - path: chemin de l'image (local ou s3://...). + path: Chemin local ou S3 de l'image. Returns: - L'image en mode niveaux de gris. + L'image convertie en niveaux de gris (mode "L"). """ with fsspec.open(path, "rb") as f: img = Image.open(io.BytesIO(f.read())) return img.convert("L") +def ink_ratio(image: Image.Image) -> float: + """Proportion de pixels sombres (encre) d'une image en niveaux de gris. + + Sert à repérer les formulaires vierges (l'élève n'a rien écrit) : seul le + pré-imprimé (titre, lignes, cadre, pied de page) marque quelques pour cent. + + Args: + image: Image PIL (convertie en "L" si besoin). + + Returns: + Fraction de pixels d'intensité < 128, entre 0.0 et 1.0. + """ + arr = np.asarray(image.convert("L")) + return float((arr < 128).mean()) + + def load_labels(csv_path: str) -> dict[str, dict[str, str]]: - """Charge les codes de l'annotateur depuis le CSV de correction (local ou S3). + """Charge les codes de l'annotateur depuis le CSV de correction (séparateur ';', local ou S3). - Le CSV a une colonne d'index, une colonne nom d'image, puis une colonne par item. + Structure : colonne d'index, colonne nom d'image, puis une colonne par item. Args: - csv_path: chemin du CSV (séparateur ';'). Local ou s3://... + csv_path: Chemin local ou S3 du CSV de correction. Returns: - Dictionnaire {copy_id: {item_id: code}}. + Un dictionnaire `copy_id -> {item_id: code}`, valeurs nettoyées des espaces. """ labels: dict[str, dict[str, str]] = {} with fsspec.open(csv_path, "rt", encoding="utf-8") as f: @@ -83,7 +93,14 @@ def load_labels(csv_path: str) -> dict[str, dict[str, str]]: def _exists(path: str) -> bool: - """Teste l'existence d'un fichier local ou S3.""" + """Teste l'existence d'un fichier local ou S3. + + Args: + path: Chemin local ou S3 à vérifier. + + Returns: + True si le fichier existe, False sinon. + """ fs, _, paths = fsspec.get_fs_token_paths(path) return bool(paths) and fs.exists(paths[0]) @@ -95,13 +112,15 @@ def load_dataset( ) -> list[Copy]: """Construit la liste des copies à partir des images et du CSV de labels. + Ne retient que les copies dont l'image existe réellement dans `images_dir`. + Args: - images_dir: dossier des imagettes (local ou s3://...). - labels_csv: chemin du CSV de codes annotateur (local ou s3://...). - limit: nombre maximal de copies à charger (None = toutes). + images_dir: Dossier (local ou S3) contenant les imagettes. + labels_csv: Chemin du CSV des codes experts. + limit: Nombre maximal de copies à charger (toutes si None). Returns: - Liste de Copy, ordonnée par identifiant. + La liste des copies trouvées, triées par identifiant. """ labels = load_labels(labels_csv) diff --git a/src/evaluation_dictee/data/reference.py b/src/evaluation_dictee/data/reference.py index 6d6808a..f64a401 100644 --- a/src/evaluation_dictee/data/reference.py +++ b/src/evaluation_dictee/data/reference.py @@ -1,57 +1,86 @@ -"""Chargement de la grille de codage de la dictée (JSON versionné dans configs/). - -La grille fournit, pour chaque item, le mot attendu et des exemples de fautes -connues par type. Elle remplace l'ancien fichier reference_dictee_2015.txt : -le texte de référence est désormais dérivé de la grille. -""" +"""Codes de la dictée et simplification 6 codes → 1/9/0 (cible principale, CLAUDE.md §3).""" from __future__ import annotations -import json -from dataclasses import dataclass +# Codes de la grille complète +CODE_CORRECT = "1" +CODE_ERR_LEXICALE = "3" +CODE_ERR_GRAMMATICALE = "4" +CODE_ERR_LES_DEUX = "5" +CODE_ERR_PONCT = "9" # documenté pour la ponctuation (et observé sur des mots) +CODE_ABSENT = "0" + +# Codes de la grille simplifiée +SIMPLE_CORRECT = "1" +SIMPLE_ERREUR = "9" # toute erreur, quel qu'en soit le type +SIMPLE_ABSENT = "0" +# Ensemble des codes considérés comme « une erreur » (hors absent/correct) +_CODES_ERREUR = {CODE_ERR_LEXICALE, CODE_ERR_GRAMMATICALE, CODE_ERR_LES_DEUX, CODE_ERR_PONCT} -@dataclass -class GridItem: - """Un item de la grille : mot attendu, type et fautes connues.""" +# Alphabet de codes attendu pour chaque schéma (après normalisation). +# Sert de référence pour vérifier que modèle et experts codent dans le même jeu. +ALLOWED_CODES = { + "simplifiee": {SIMPLE_CORRECT, SIMPLE_ERREUR, SIMPLE_ABSENT}, # {1, 9, 0} + "complete": { + CODE_CORRECT, + CODE_ERR_LEXICALE, + CODE_ERR_GRAMMATICALE, + CODE_ERR_LES_DEUX, + CODE_ERR_PONCT, + CODE_ABSENT, # {1,3,4,5,9,0} + }, +} - item_id: str - attendu: str - type: str # "mot" ou "ponctuation" - ex_lexicale: list[str] - ex_grammaticale: list[str] - ex_les_deux: list[str] +def allowed_codes(scheme: str) -> set[str]: + """Renvoie l'ensemble des codes valides après normalisation pour un schéma. + + Args: + scheme: Schéma de codage cible ("simplifiee" ou "complete"). -@dataclass -class DictationGrid: - """Grille complète de la dictée.""" + Returns: + L'ensemble des codes autorisés pour ce schéma. + + Raises: + ValueError: Si le schéma est inconnu. + """ + if scheme not in ALLOWED_CODES: + raise ValueError(f"Schéma inconnu : {scheme!r}. Attendu : {set(ALLOWED_CODES)}.") + return ALLOWED_CODES[scheme] - reference_text: str - items: list[GridItem] + +def to_simplified(code: str) -> str: + """Convertit un code de la grille complète vers la grille simplifiée 1/9/0. + + Args: + code: Code de la grille complète (les espaces sont ignorés). + + Returns: + Le code simplifié (1/9/0) ; un code inattendu est renvoyé tel quel. + """ + code = code.strip() + if code == CODE_ABSENT: + return SIMPLE_ABSENT + if code == CODE_CORRECT: + return SIMPLE_CORRECT + if code in _CODES_ERREUR: + return SIMPLE_ERREUR + # Code inattendu : renvoyé tel quel pour rester visible dans l'analyse + return code -def load_grid(path: str) -> DictationGrid: - """Charge la grille de codage depuis un fichier JSON. +def normalize(code: str, scheme: str) -> str: + """Normalise un code selon le schéma cible. Args: - path: chemin du JSON (typiquement configs/grille_dictee_2015.json). + code: Code brut à normaliser. + scheme: Schéma cible ("simplifiee" applique la conversion 1/9/0, + sinon le code est seulement débarrassé de ses espaces). Returns: - La grille (texte de référence + items). + Le code normalisé selon le schéma. """ - with open(path, encoding="utf-8") as f: - raw = json.load(f) - - items = [ - GridItem( - item_id=it["id"], - attendu=it["attendu"], - type=it["type"], - ex_lexicale=it.get("ex_lexicale", []), - ex_grammaticale=it.get("ex_grammaticale", []), - ex_les_deux=it.get("ex_les_deux", []), - ) - for it in raw["items"] - ] - return DictationGrid(reference_text=raw["reference_text"], items=items) + if scheme == "simplifiee": + return to_simplified(code) + return code.strip() diff --git a/src/evaluation_dictee/evaluation/calibration.py b/src/evaluation_dictee/evaluation/calibration.py index 882097b..c754803 100644 --- a/src/evaluation_dictee/evaluation/calibration.py +++ b/src/evaluation_dictee/evaluation/calibration.py @@ -1,9 +1,4 @@ -"""Calibration de la confiance et courbe de renvoi humain. - -Livrable décisionnel central du projet (CLAUDE.md §4) : pour chaque seuil de -confiance, on mesure la part d'items renvoyés à un correcteur humain et le taux -d'erreur résiduel sur les items conservés (auto-validés par le modèle). -""" +"""Calibration de la confiance et courbe de renvoi humain (livrable décisionnel, CLAUDE.md §4).""" from __future__ import annotations @@ -12,14 +7,7 @@ @dataclass class ReferralPoint: - """Un point de la courbe renvoi-humain / erreur-résiduelle. - - Attributes: - threshold: seuil de confiance ; les items sous ce seuil sont renvoyés. - human_referral_rate: part d'items renvoyés à l'humain. - residual_error_rate: taux d'erreur sur les items auto-validés. - n_auto_validated: nombre d'items auto-validés. - """ + """Un point de la courbe renvoi-humain / erreur-résiduelle.""" threshold: float human_referral_rate: float @@ -33,20 +21,19 @@ def referral_curve( confidences: list[float | None], thresholds: list[float] | None = None, ) -> list[ReferralPoint]: - """Calcule la courbe « taux de renvoi humain vs taux d'erreur résiduel ». + """Courbe « taux de renvoi humain vs taux d'erreur résiduel ». - Les items dont la confiance est strictement inférieure au seuil sont - « renvoyés à l'humain » et exclus du calcul d'erreur résiduelle. Les items - sans confiance disponible sont toujours renvoyés. + Un item est renvoyé si sa confiance est < seuil ou absente (None). Args: y_true: codes experts. - y_pred: codes prédits. - confidences: confiance par item (None = toujours renvoyé). - thresholds: seuils à tester (par défaut 0.0, 0.1, ..., 1.0). + y_pred: codes prédits, alignés sur y_true. + confidences: confiance de chaque prédiction (None = pas de confiance). + thresholds: seuils à balayer ; par défaut 0.0 à 1.0 par pas de 0.1. Returns: - Liste de points de la courbe, un par seuil. + Un point par seuil : taux de renvoi humain, erreur résiduelle sur les + items auto-validés et nombre d'items auto-validés. """ if thresholds is None: thresholds = [i / 10 for i in range(11)] @@ -83,15 +70,7 @@ def referral_curve( @dataclass class ReliabilityBin: - """Une tranche de confiance pour le diagramme de fiabilité. - - Attributes: - bin_lower: borne inférieure de la tranche de confiance. - bin_upper: borne supérieure. - mean_confidence: confiance moyenne des items de la tranche. - accuracy: taux d'accord observé dans la tranche. - n: nombre d'items dans la tranche. - """ + """Une tranche de confiance pour le diagramme de fiabilité.""" bin_lower: float bin_upper: float @@ -108,17 +87,17 @@ def reliability_bins( ) -> list[ReliabilityBin]: """Regroupe les items par tranche de confiance et mesure l'accord réel. - Un modèle bien calibré a accuracy ≈ mean_confidence dans chaque tranche - (les points du diagramme de fiabilité sont sur la diagonale). + Un modèle bien calibré a accuracy ≈ mean_confidence dans chaque tranche. Args: y_true: codes experts. - y_pred: codes prédits. - confidences: confiance par item (None = ignoré). - n_bins: nombre de tranches entre 0 et 1. + y_pred: codes prédits, alignés sur y_true. + confidences: confiance de chaque prédiction (None = item ignoré). + n_bins: nombre de tranches de confiance sur [0, 1]. Returns: - Une liste de tranches non vides. + Les tranches non vides, chacune avec ses bornes, la confiance moyenne, + l'accord observé et l'effectif. """ bins: list[ReliabilityBin] = [] for b in range(n_bins): @@ -127,7 +106,7 @@ def reliability_bins( for t, p, c in zip(y_true, y_pred, confidences, strict=True): if c is None: continue - # dernière tranche inclusive à droite pour capter c == 1.0 + # Dernière tranche inclusive à droite pour capter c == 1.0 if (lo <= c < hi) or (b == n_bins - 1 and c == hi): confs.append(c) corrects.append(t == p) @@ -152,18 +131,18 @@ def expected_calibration_error( ) -> float: """ECE : écart moyen pondéré entre confiance annoncée et accord observé. - 0 = parfaitement calibré. Au-delà de ~0.1, le score de confiance n'est pas - fiable tel quel et doit être recalibré avant d'être utilisé pour le seuil - de renvoi humain. + 0 = parfaitement calibré. Au-delà de ~0.1, la confiance doit être recalibrée + avant usage pour le seuil de renvoi humain. Args: y_true: codes experts. - y_pred: codes prédits. - confidences: confiance par item. - n_bins: nombre de tranches. + y_pred: codes prédits, alignés sur y_true. + confidences: confiance de chaque prédiction (None = item ignoré). + n_bins: nombre de tranches de confiance sur [0, 1]. Returns: - L'ECE (entre 0 et 1). + L'ECE (écart absolu accord/confiance pondéré par les effectifs), ou NaN + si aucun item n'a de confiance. """ bins = reliability_bins(y_true, y_pred, confidences, n_bins) n_total = sum(b.n for b in bins) diff --git a/src/evaluation_dictee/evaluation/diagnostics.py b/src/evaluation_dictee/evaluation/diagnostics.py index 1bd75a3..ec2bc9a 100644 --- a/src/evaluation_dictee/evaluation/diagnostics.py +++ b/src/evaluation_dictee/evaluation/diagnostics.py @@ -1,18 +1,7 @@ -"""Diagnostic des écarts entre le modèle et l'annotateur. +"""Diagnostic des écarts modèle/annotateur : d'où viennent les désaccords. -Ce module ne calcule pas des métriques agrégées (voir report.py) mais cherche à -EXPLIQUER d'où viennent les désaccords, et à pointer les copies à inspecter -visuellement. Il répond à des questions précises : - -- Y a-t-il un effet de POSITION ? (l'accord se dégrade-t-il vers la fin de la - dictée — signature d'un décalage propagé par un oubli de mot) -- Le modèle est-il SUR-CONFIANT ? (confiance élevée sur des prédictions fausses) -- Quelles copies concentrent les désaccords (cas types à regarder) ? -- Le motif des désaccords ressemble-t-il à un DÉCALAGE (run de codes 9/0 - consécutifs) plutôt qu'à des erreurs isolées ? - -Toutes les fonctions prennent le DataFrame de prédictions chargé par -report.load_predictions et renvoient des DataFrames. +Pointe les copies à inspecter (effet de position, sur-confiance, décalages). +Complète report.py, qui agrège ; ici on cherche les causes. """ from __future__ import annotations @@ -21,14 +10,15 @@ def add_position(df: pd.DataFrame, ordered_item_ids: list[str]) -> pd.DataFrame: - """Ajoute une colonne `position` (rang de l'item dans la dictée, 1..N). + """Ajoute les colonnes `position` (rang dans la dictée, 1..N) et `correct`. Args: - df: prédictions (copy_id, item_id, y_true, y_pred, confidence). - ordered_item_ids: item_ids dans l'ordre du texte (depuis la grille). + df: prédictions à l'item (colonnes item_id, y_true, y_pred). + ordered_item_ids: item_ids dans l'ordre de la dictée (définit le rang). Returns: - Copie du DataFrame avec colonnes `position` et `correct` (booléen). + Une copie du DataFrame avec `position` (rang de l'item) et `correct` + (y_true == y_pred). """ rang = {iid: i + 1 for i, iid in enumerate(ordered_item_ids)} out = df.copy() @@ -42,16 +32,15 @@ def accuracy_by_position( ) -> pd.DataFrame: """Accord moyen par tranche de position dans la dictée. - Un accord qui chute en fin de dictée est la signature d'un décalage propagé - (oubli de mot non géré, fusion, ligne sautée...). + Une chute en fin de dictée signe un décalage propagé (oubli de mot, ligne sautée). Args: - df: prédictions. - ordered_item_ids: ordre des items. + df: prédictions à l'item. + ordered_item_ids: item_ids dans l'ordre de la dictée. n_bins: nombre de tranches de position. Returns: - DataFrame par tranche : position moyenne, accord, effectif. + Un DataFrame, une ligne par tranche : position moyenne, accord et effectif. """ d = add_position(df, ordered_item_ids) d["tranche"] = pd.cut(d["position"], bins=n_bins) @@ -64,32 +53,29 @@ def accuracy_by_position( def position_accuracy_correlation(df: pd.DataFrame, ordered_item_ids: list[str]) -> float: - """Corrélation entre position et justesse (négative = dégradation en fin). + """Corrélation de Pearson position/justesse (négative = dégradation en fin). Args: - df: prédictions. - ordered_item_ids: ordre des items. + df: prédictions à l'item. + ordered_item_ids: item_ids dans l'ordre de la dictée. Returns: - Coefficient de corrélation de Pearson entre position et `correct`. + Le coefficient de corrélation de Pearson entre position et justesse. """ d = add_position(df, ordered_item_ids) return float(d["position"].corr(d["correct"].astype(float))) def per_copy_position_effect(df: pd.DataFrame, ordered_item_ids: list[str]) -> pd.DataFrame: - """Pour chaque copie, compare l'accord 1re moitié vs 2de moitié de la dictée. - - Une chute marquée en 2de moitié sur une copie donnée suggère un décalage - déclenché en cours de copie (typiquement un mot oublié par l'élève). + """Compare, par copie, l'accord 1re vs 2de moitié (une chute signe un décalage en cours). Args: - df: prédictions. - ordered_item_ids: ordre des items. + df: prédictions à l'item. + ordered_item_ids: item_ids dans l'ordre de la dictée. Returns: - DataFrame par copie : accord_moitie1, accord_moitie2, chute (m1 - m2), - trié par chute décroissante (les plus suspectes en tête). + Un DataFrame indexé par copy_id : accord de chaque moitié et `chute` + (moitié 1 − moitié 2), trié par chute décroissante. """ d = add_position(df, ordered_item_ids) milieu = len(ordered_item_ids) / 2 @@ -111,17 +97,16 @@ def per_copy_position_effect(df: pd.DataFrame, ordered_item_ids: list[str]) -> p def longest_disagreement_run(df: pd.DataFrame, ordered_item_ids: list[str]) -> pd.DataFrame: """Plus longue séquence de désaccords CONSÉCUTIFS par copie. - Des erreurs isolées dispersées = vraies erreurs de codage. - Une longue séquence ininterrompue de désaccords = signature d'un décalage - (tous les items après un oubli sont faux jusqu'à un éventuel recalage). + Erreurs isolées = vraies erreurs de codage ; longue séquence = signature d'un décalage. Args: - df: prédictions. - ordered_item_ids: ordre des items. + df: prédictions à l'item. + ordered_item_ids: item_ids dans l'ordre de la dictée. Returns: - DataFrame par copie : n_desaccords, plus_longue_sequence, - position_debut_sequence. Trié par plus_longue_sequence décroissante. + Un DataFrame indexé par copy_id : nombre de désaccords, longueur de la + plus longue séquence consécutive et sa position de début, trié par + longueur décroissante. """ d = add_position(df, ordered_item_ids).sort_values(["copy_id", "position"]) rows = [] @@ -155,17 +140,15 @@ def longest_disagreement_run(df: pd.DataFrame, ordered_item_ids: list[str]) -> p def overconfident_errors(df: pd.DataFrame, seuil_confiance: float = 0.9) -> pd.DataFrame: """Prédictions FAUSSES annoncées avec une confiance élevée. - Ces cas sont les plus dangereux : le modèle se trompe sans le signaler, donc - le renvoi humain ne les rattrape pas. Beaucoup de tels cas = confiance - inexploitable (cohérent avec un ECE élevé). + Cas les plus dangereux : le renvoi humain ne les rattrape pas. En nombre = + confiance inexploitable (ECE élevé). Args: - df: prédictions. - seuil_confiance: seuil au-dessus duquel la confiance est dite « élevée ». + df: prédictions à l'item (colonnes y_true, y_pred, confidence). + seuil_confiance: seuil au-delà duquel une erreur est jugée sur-confiante. Returns: - DataFrame des prédictions fausses et sur-confiantes (copy_id, item_id, - y_true, y_pred, confidence), triées par confiance décroissante. + Les lignes fausses de confiance >= seuil, triées par confiance décroissante. """ faux = df[df["y_true"] != df["y_pred"]] surconf = faux[faux["confidence"].fillna(0) >= seuil_confiance] @@ -175,23 +158,16 @@ def overconfident_errors(df: pd.DataFrame, seuil_confiance: float = 0.9) -> pd.D def disagreement_hotspots( df: pd.DataFrame, ordered_item_ids: list[str], top: int = 5 ) -> dict[str, pd.DataFrame]: - """Sélectionne des copies-types à inspecter visuellement. - - Renvoie plusieurs « palmarès » complémentaires, chacun éclairant un mécanisme - d'erreur différent : - - `pire_accord` : copies au plus faible accord global. - - `plus_longue_sequence` : copies avec le plus long run de désaccords - (suspicion de décalage). - - `plus_forte_chute` : copies dont l'accord s'effondre en 2de moitié. - - `sur_confiance` : copies cumulant le plus d'erreurs sur-confiantes. + """Sélectionne des copies-types à inspecter (un palmarès par mécanisme d'erreur). Args: - df: prédictions. - ordered_item_ids: ordre des items. - top: nombre de copies par palmarès. + df: prédictions à l'item. + ordered_item_ids: item_ids dans l'ordre de la dictée. + top: nombre de copies retenues par palmarès. Returns: - Dictionnaire de DataFrames (un par palmarès). + Un dict de DataFrames, un par mécanisme : `pire_accord`, + `plus_longue_sequence`, `plus_forte_chute` et `sur_confiance`. """ d = add_position(df, ordered_item_ids) @@ -227,31 +203,25 @@ def simulate_word_omission_shift( ) -> dict[str, float]: """Teste l'effet d'un décalage dû à un mot oublié, sur des codes synthétiques. - Outil pédagogique/de test : on part d'un codage parfait (le modèle reproduit - l'expert), puis on simule un modèle qui, à partir de `omission_position`, - décale toutes ses prédictions d'un cran (comme s'il n'avait pas vu qu'un mot - était absent et avait aligné le mot suivant sur la position courante). - - On mesure l'accord résultant : s'il s'effondre, cela démontre qu'un simple - oubli non géré peut détruire l'évaluation de toute la fin de copie. + Outil pédagogique : démontre qu'un oubli non géré peut détruire l'évaluation + de toute la fin de copie. Args: - expert_codes: codes experts d'une copie (vérité), dans l'ordre. - omission_position: index (0-based) où l'élève a omis un mot. + expert_codes: codes experts de référence. + omission_position: index du mot supposé oublié (déclenche le décalage). Returns: - {"accord_sans_decalage": 1.0, "accord_avec_decalage": x} où x illustre - la chute provoquée par le décalage. + Un dict : accord sans décalage, accord avec décalage, position de + l'omission et nombre d'items situés après l'omission. """ n = len(expert_codes) - # Modèle parfait : copie exacte parfait = list(expert_codes) accord_parfait = sum(a == b for a, b in zip(expert_codes, parfait, strict=True)) / n # Modèle décalé : à partir de l'omission, tout glisse d'un cran decale = list(expert_codes[:omission_position]) - decale += list(expert_codes[omission_position + 1 :]) # saute la position omise - decale += ["1"] # complète à droite (valeur arbitraire) + decale += list(expert_codes[omission_position + 1 :]) + decale += ["1"] decale = decale[:n] accord_decale = sum(a == b for a, b in zip(expert_codes, decale, strict=True)) / n diff --git a/src/evaluation_dictee/evaluation/html_report.py b/src/evaluation_dictee/evaluation/html_report.py index 660bdc5..4597fc0 100644 --- a/src/evaluation_dictee/evaluation/html_report.py +++ b/src/evaluation_dictee/evaluation/html_report.py @@ -1,16 +1,6 @@ -"""Génère un rapport HTML autonome à partir du notebook d'analyse. +"""Exporte un HTML autonome du notebook d'analyse en ne gardant que les sections choisies. -Le notebook 03 est organisé en sections repérées par un tag de cellule -(ex. `section:synthese`, `section:prevalence_item`, ...). Ce module l'exécute -puis en exporte un HTML dans lequel on ne garde que les sections choisies. - -Cas d'usage : partager un rapport ciblé avec l'équipe DEPP (dictée) au SSMEN, -sans exposer les cellules de code brutes ni les sections de diagnostic interne. - -Deux entrées principales : -- `list_sections(notebook_path)` : liste des sections disponibles avec leur tag. -- `build_html_report(notebook_path, selected_tags, output_path, ...)` : exécute - le notebook et exporte les sections sélectionnées en un HTML autonome. +Les sections sont repérées par un tag de cellule `section:`. """ from __future__ import annotations @@ -24,25 +14,27 @@ from nbconvert.preprocessors import ExecutePreprocessor, TagRemovePreprocessor from traitlets.config import Config -# Prefix conventionnel pour les tags de section. _SECTION_PREFIX = "section:" @dataclass class SectionInfo: - """Description d'une section repérable du notebook. - - Attributes: - tag: identifiant technique (ex. "section:prevalence_item"). - title: titre humain lu dans la première cellule markdown de la section. - """ + """Description d'une section repérable du notebook.""" tag: str title: str def _find_first_title(cell: nbformat.NotebookNode) -> str: - """Extrait le premier titre markdown non vide de la cellule.""" + """Extrait le premier titre markdown non vide de la cellule. + + Args: + cell: cellule de notebook à inspecter. + + Returns: + Le premier titre markdown (sans les `#`), ou à défaut la première ligne + tronquée à 80 caractères. + """ src = "".join(cell.source) if isinstance(cell.source, list) else cell.source for line in src.splitlines(): line = line.strip() @@ -52,17 +44,13 @@ def _find_first_title(cell: nbformat.NotebookNode) -> str: def list_sections(notebook_path: str | Path) -> list[SectionInfo]: - """Liste les sections d'un notebook, dans l'ordre d'apparition. - - Une section est un ensemble de cellules dont la PREMIÈRE porte un tag - `section:`. Le titre humain est extrait du markdown de cette première - cellule pour l'affichage. + """Liste les sections d'un notebook (première cellule taguée), dans l'ordre d'apparition. Args: - notebook_path: chemin du .ipynb. + notebook_path: chemin du notebook `.ipynb`. Returns: - Sections détectées, avec leur tag et leur titre humain. + Les sections repérées (tag `section:` et titre), sans doublon. """ nb = nbformat.read(str(notebook_path), as_version=4) sections: list[SectionInfo] = [] @@ -78,19 +66,17 @@ def list_sections(notebook_path: str | Path) -> list[SectionInfo]: def _filter_by_sections( nb: nbformat.NotebookNode, selected_tags: list[str] ) -> nbformat.NotebookNode: - """Ne garde dans le notebook que les cellules des sections sélectionnées. + """Ne garde que les cellules des sections sélectionnées. - La règle : chaque cellule appartient à la DERNIÈRE section rencontrée avant - elle (elle prolonge la section jusqu'à la prochaine cellule taguée - `section:...`). Les cellules AVANT la première section sont conservées - (imports, paramètres) car indispensables à l'affichage. + Chaque cellule appartient à la dernière section rencontrée. Les cellules avant + la 1re section (imports, paramètres) sont toujours conservées. Args: - nb: notebook chargé. - selected_tags: liste de tags de section à conserver. + nb: notebook à filtrer. + selected_tags: tags de section à conserver. Returns: - Une copie du notebook filtrée. + Une copie du notebook ne contenant que l'en-tête et les sections choisies. """ filtered = copy.deepcopy(nb) kept_cells: list[nbformat.NotebookNode] = [] @@ -104,8 +90,7 @@ def _filter_by_sections( current_section = section_tags[0] seen_first_section = True - # Cellules d'en-tête (avant la 1ère section) : toujours conservées. - # Sinon : conservées seulement si la section courante est sélectionnée. + # En-tête (avant la 1re section) toujours gardé ; sinon selon la sélection if not seen_first_section or (current_section in selected_tags): kept_cells.append(cell) @@ -114,7 +99,15 @@ def _filter_by_sections( def _run_notebook(nb: nbformat.NotebookNode, notebook_dir: Path) -> nbformat.NotebookNode: - """Exécute le notebook (nécessaire pour que les sorties apparaissent en HTML).""" + """Exécute le notebook (nécessaire pour que les sorties apparaissent en HTML). + + Args: + nb: notebook à exécuter. + notebook_dir: répertoire de travail du kernel (chemins relatifs). + + Returns: + Le notebook exécuté, sorties de cellules incluses. + """ ep = ExecutePreprocessor(timeout=1800, kernel_name="python3") ep.preprocess(nb, {"metadata": {"path": str(notebook_dir)}}) return nb @@ -125,10 +118,18 @@ def _export_html( hide_code: bool = True, template_name: str = "lab", ) -> str: - """Convertit le notebook exécuté en HTML autonome (assets inlinés).""" + """Convertit le notebook exécuté en HTML autonome (assets inlinés). + + Args: + nb: notebook (idéalement déjà exécuté) à convertir. + hide_code: si True, masque les cellules de code et les invites. + template_name: template nbconvert à utiliser. + + Returns: + Le corps HTML du notebook. + """ c = Config() - # Retirer les cellules explicitement taguées "hide" (utile pour cellules - # techniques comme le choix de sections lui-même). + # Retire les cellules taguées "hide"/"remove_cell" (cellules techniques) c.TagRemovePreprocessor.remove_cell_tags = {"hide", "remove_cell"} if hide_code: c.TagRemovePreprocessor.remove_input_tags = {"hide_input"} @@ -137,7 +138,7 @@ def _export_html( exporter = HTMLExporter(config=c, template_name=template_name) exporter.register_preprocessor(TagRemovePreprocessor(config=c), True) if hide_code: - exporter.exclude_input = True # masque les cellules de code + exporter.exclude_input = True exporter.exclude_input_prompt = True exporter.exclude_output_prompt = True @@ -155,17 +156,14 @@ def build_html_report( """Exécute le notebook et exporte les sections choisies en HTML. Args: - notebook_path: chemin du notebook source. - selected_tags: tags des sections à inclure (ex. ["section:synthese", - "section:prevalence_item"]). - output_path: chemin du fichier HTML à produire. - hide_code: si True, masque les cellules de code dans le rapport final - (recommandé pour envoi à des non-développeurs). - execute: si True, exécute le notebook avant export (nécessaire si les - sorties ne sont pas déjà présentes dans le fichier source). + notebook_path: chemin du notebook `.ipynb`. + selected_tags: tags de section à conserver. + output_path: chemin du fichier HTML à écrire. + hide_code: si True, masque les cellules de code dans la sortie. + execute: si True, exécute le notebook avant l'export. Returns: - Le chemin du HTML produit. + Le chemin du fichier HTML écrit. """ notebook_path = Path(notebook_path) nb = nbformat.read(str(notebook_path), as_version=4) @@ -192,22 +190,20 @@ def bootstrap_prevalence_ci( ): """Bootstrap groupé par copie de la prévalence d'erreur par item. - Ré-échantillonne les COPIES (pas les items individuels) pour respecter la - structure hiérarchique de la donnée. Renvoie pour chaque item l'IC à `level` - de la prévalence d'erreur (proportion d'items non codés « 1 »). + Ré-échantillonne les COPIES (pas les items) pour respecter la structure + hiérarchique de la donnée. Args: - df: DataFrame long avec colonnes copy_id, item_id, y_true et/ou y_pred. - item_col: nom de la colonne d'items. - label_col: colonne dont on calcule la prévalence (y_true = expert, - y_pred = modèle). - n_boot: nombre de ré-échantillonnages. - level: niveau de confiance. - seed: reproductibilité. + df: prédictions à l'item (colonne copy_id requise). + item_col: colonne identifiant l'item. + label_col: colonne des codes ; « erreur » = code != "1". + n_boot: nombre de tirages bootstrap. + level: niveau de confiance de l'intervalle. + seed: graine du générateur aléatoire (reproductibilité). Returns: - DataFrame indexé par item_id, avec colonnes `estimate`, `lo`, `hi` - (pourcentages). + Un DataFrame indexé par item : prévalence d'erreur estimée (%) et bornes + basse/haute de l'intervalle bootstrap. """ import numpy as np import pandas as pd @@ -216,10 +212,8 @@ def bootstrap_prevalence_ci( copies = df["copy_id"].unique() items = sorted(df[item_col].unique()) - # Estimation ponctuelle est = df.groupby(item_col)[label_col].apply(lambda s: (s != "1").mean() * 100) - # Bootstrap samples = np.zeros((n_boot, len(items))) for b in range(n_boot): sampled_copies = rng.choice(copies, size=len(copies), replace=True) diff --git a/src/evaluation_dictee/evaluation/metrics.py b/src/evaluation_dictee/evaluation/metrics.py index 01da585..9fa95c4 100644 --- a/src/evaluation_dictee/evaluation/metrics.py +++ b/src/evaluation_dictee/evaluation/metrics.py @@ -1,8 +1,4 @@ -"""Métriques de scoring : accord brut, kappa de Cohen, matrice de confusion. - -Ces métriques reproduisent celles utilisées dans le test pilote (CLAUDE.md §5). -On s'appuie sur scikit-learn pour le kappa et la matrice de confusion. -""" +"""Métriques de scoring : accord brut, kappa de Cohen, matrice de confusion (CLAUDE.md §5).""" from __future__ import annotations @@ -13,15 +9,7 @@ @dataclass class ScoringMetrics: - """Métriques d'accord entre prédictions et codes experts. - - Attributes: - n_items: nombre d'items comparés. - raw_agreement: proportion d'items identiques. - cohen_kappa: kappa de Cohen (accord corrigé du hasard). - labels: liste ordonnée des codes apparaissant. - confusion: matrice de confusion (lignes = experts, colonnes = prédictions). - """ + """Métriques d'accord entre prédictions et codes experts.""" n_items: int raw_agreement: float @@ -34,11 +22,12 @@ def compute_scoring_metrics(y_true: list[str], y_pred: list[str]) -> ScoringMetr """Calcule les métriques d'accord entre codes experts et codes prédits. Args: - y_true: codes experts (normalisés). - y_pred: codes prédits (normalisés, même longueur que y_true). + y_true: codes de l'annotateur expert. + y_pred: codes prédits par le modèle, alignés item par item sur y_true. Returns: - Les métriques d'accord. + Les métriques agrégées (effectif, accord brut, kappa de Cohen, labels + et matrice de confusion). Raises: ValueError: si les deux listes n'ont pas la même longueur ou sont vides. diff --git a/src/evaluation_dictee/evaluation/multi_model.py b/src/evaluation_dictee/evaluation/multi_model.py index 96a79d2..e549c34 100644 --- a/src/evaluation_dictee/evaluation/multi_model.py +++ b/src/evaluation_dictee/evaluation/multi_model.py @@ -1,24 +1,8 @@ """Comparaison multi-modèles pour construire un score de confiance par copie. -Principe : quand plusieurs modèles indépendants s'accordent sur un item, la -prédiction est fiable ; quand ils divergent, c'est un signal d'incertitude qui -appelle un renvoi humain. Le désaccord inter-modèles est un bien meilleur -prédicteur de confiance que le score annoncé par un modèle unique (qui est en -pratique quasi-constant à 1.0 dans nos benchmarks — inexploitable). - -Workflow type : - 1. Lancer N runs indépendants (mêmes copies, modèles/prompts différents), - chacun produit son `_predictions.jsonl`. - 2. `load_multi_runs([run1, run2, ...])` charge les N runs et les joint sur - (copy_id, item_id) pour construire une prédiction par modèle par ligne. - 3. `agreement_per_item()` calcule le nb de modèles d'accord sur chaque item. - 4. `confidence_score()` agrège au niveau copie : proportion d'items où tous - les modèles s'accordent = score de confiance de la copie. - 5. `referral_curve_multi()` compare, pour chaque seuil de renvoi, l'accord - vs un expert humain (si dispo) sur les copies retenues. - -Structure agnostique du nombre de modèles : marche de N=1 (score = confiance -d'un modèle unique) à N quelconque. +Le désaccord inter-modèles prédit bien mieux l'incertitude que la confiance d'un +modèle unique (quasi-constante à 1.0 dans nos benchmarks, inexploitable). Marche +de N=1 à N quelconque. """ from __future__ import annotations @@ -37,25 +21,19 @@ def load_multi_runs( ) -> pd.DataFrame: """Charge N runs indépendants et les joint sur (copy_id, item_id). - Chaque run doit avoir été produit par `run_benchmark.py` et posséder son - fichier `_predictions.jsonl` dans `output_dir`. + Le premier run de la liste fournit les colonnes communes (y_true, etc.). Args: - run_names: identifiants des runs à comparer (ex. ["dictee_gemma4_zeroshot", - "dictee_gemma4_cot"]). Le premier de la liste sert de référence pour - les colonnes partagées (y_true, transcription_ref, etc.). - output_dir: dossier contenant les JSONL de prédictions. + run_names: noms des runs à charger (fichiers `_predictions.jsonl`). + output_dir: dossier contenant les fichiers de prédictions. Returns: - DataFrame avec les colonnes : - - copy_id, item_id : clé de jointure - - y_true : code expert (identique dans tous les runs) - - y_pred__, y_pred__, ... : code prédit par chaque modèle - - conf__, conf__, ... : confiance de chaque modèle + Un DataFrame joint (inner) sur (copy_id, item_id), avec y_true et une + paire de colonnes `y_pred__` / `conf__` par run. Raises: - FileNotFoundError: si un run est absent. - ValueError: si les runs ne portent pas sur les mêmes (copy_id, item_id). + ValueError: si run_names est vide ou si aucun item n'est commun aux runs. + FileNotFoundError: si un fichier de prédictions est absent. """ output_dir = Path(output_dir) if not run_names: @@ -67,21 +45,18 @@ def load_multi_runs( if not path.exists(): raise FileNotFoundError(f"Prédictions manquantes : {path}") df_run = load_predictions(path) - # Alias des colonnes propres au modèle renamed = df_run.rename(columns={"y_pred": f"y_pred__{run}", "confidence": f"conf__{run}"}) dfs[run] = renamed - # Le premier run fournit les colonnes communes (y_true, item_id, copy_id) ref = dfs[run_names[0]][["copy_id", "item_id", "y_true"]].copy() - # Joint successivement les prédictions de chaque run merged = ref for run in run_names: cols = ["copy_id", "item_id", f"y_pred__{run}", f"conf__{run}"] merged = merged.merge( dfs[run][cols], on=["copy_id", "item_id"], - how="inner", # on ne garde que les (copy_id, item_id) présents PARTOUT + how="inner", # ne garde que les (copy_id, item_id) présents PARTOUT ) if merged.empty: @@ -96,22 +71,18 @@ def agreement_per_item(df_multi: pd.DataFrame) -> pd.DataFrame: """Ajoute au DataFrame multi-runs les colonnes de désaccord par item. Args: - df_multi: sortie de `load_multi_runs`. + df_multi: DataFrame issu de `load_multi_runs` (colonnes `y_pred__`). Returns: - Le DataFrame enrichi de : - - n_modeles : nombre total de modèles - - modal_pred : prédiction majoritaire parmi les modèles - - n_accord_modeles : nombre de modèles alignés sur la modale - - unanimite : True si tous les modèles s'accordent - - modele_vs_expert__ : True si ce modèle est d'accord avec l'expert + Une copie du DataFrame enrichie de : `modal_pred` (code majoritaire), + `n_accord_modeles`, `n_modeles`, `unanimite`, et un booléen + `modele_vs_expert__` par run (prédiction == expert). """ pred_cols = [c for c in df_multi.columns if c.startswith("y_pred__")] n_modeles = len(pred_cols) out = df_multi.copy() - # Prédiction majoritaire et effectif d'accord def _modal_and_count(row): counts = Counter(row[c] for c in pred_cols) modal, n_acc = counts.most_common(1)[0] @@ -123,7 +94,6 @@ def _modal_and_count(row): out["n_modeles"] = n_modeles out["unanimite"] = out["n_accord_modeles"] == n_modeles - # Accord de chaque modèle avec l'expert for c in pred_cols: run = c.removeprefix("y_pred__") out[f"modele_vs_expert__{run}"] = out[c] == out["y_true"] @@ -135,15 +105,12 @@ def confidence_score(df_agree: pd.DataFrame) -> pd.DataFrame: """Agrège au niveau copie un score de confiance basé sur le désaccord. Args: - df_agree: sortie de `agreement_per_item()`. + df_agree: DataFrame issu de `agreement_per_item`. Returns: - DataFrame indexé par copy_id : - - n_items : nb d'items de la copie - - pct_unanime : % d'items où tous les modèles s'accordent - - accord_moyen_mod : nb moyen de modèles alignés sur la modale (max = N) - - n_desaccord_max : nb d'items où l'accord inter-modèles est minimum - - score_confiance : pct_unanime, mais renommé pour la lisibilité (0-100) + Un DataFrame indexé par copy_id : effectif, part d'items unanimes, + accord inter-modèles moyen, nombre d'items sous la majorité et + `score_confiance` (part d'items unanimes), trié par score décroissant. """ n_modeles = int(df_agree["n_modeles"].iloc[0]) rows = [] @@ -151,8 +118,7 @@ def confidence_score(df_agree: pd.DataFrame) -> pd.DataFrame: n = len(grp) n_unan = int(grp["unanimite"].sum()) accord_moy = float(grp["n_accord_modeles"].mean()) - # Combien d'items ont l'accord MINIMAL possible (n_accord = ceil(N/2), ie - # dispersion maximale possible sur un item catégoriel) ? + # Items où l'accord inter-modèles est en dessous de la majorité (dispersion max) seuil_min = (n_modeles // 2) + 1 n_dis_max = int((grp["n_accord_modeles"] < seuil_min).sum()) if n_modeles > 1 else 0 rows.append( @@ -173,24 +139,19 @@ def referral_curve_multi( conf: pd.DataFrame, reference_run: str, ) -> pd.DataFrame: - """Courbe de renvoi humain : pour chaque seuil de confiance, quel accord ? - - Simule une stratégie : « renvoyer les copies dont le score de confiance est - inférieur à τ ». Pour chaque τ, calcule le % de copies renvoyées et l'accord - résiduel entre le modèle de RÉFÉRENCE et l'expert sur les copies retenues. + """Courbe de renvoi humain : renvoie les copies dont le score < τ, mesure l'accord retenu. Args: - df_agree: DataFrame item-niveau enrichi par `agreement_per_item()`. - conf: scores de confiance par copie (sortie de `confidence_score()`). - reference_run: run dont on mesure l'accord modèle-expert sur les copies - retenues (typiquement le modèle qu'on envisage de mettre en production). + df_agree: DataFrame issu de `agreement_per_item` (contient le run de référence). + conf: scores de confiance par copie issus de `confidence_score`. + reference_run: run dont l'accord avec l'expert sert de référence. Returns: - DataFrame par seuil τ : - - seuil_confiance : τ (%), copies renvoyées si score_confiance < τ - - pct_copies_renvoyees - - pct_accord_retenues : accord modèle-expert sur les copies retenues (%) - - n_copies_retenues + Un DataFrame, une ligne par seuil τ (0 à 100 par pas de 5) : part de + copies renvoyées, accord des copies retenues, nombre de copies retenues. + + Raises: + ValueError: si le run de référence est absent de df_agree. """ pred_col = f"y_pred__{reference_run}" if pred_col not in df_agree.columns: @@ -199,7 +160,6 @@ def referral_curve_multi( f"Colonnes disponibles : {[c for c in df_agree.columns if c.startswith('y_pred__')]}" ) - # Table par copie : score de confiance + accord modèle-expert de la copie copies = conf.copy() accord_par_copie = df_agree.groupby("copy_id").apply( lambda g: (g[pred_col] == g["y_true"]).mean() * 100 @@ -229,14 +189,11 @@ def disagreement_type_summary(df_agree: pd.DataFrame) -> pd.DataFrame: """Répartition des items selon le niveau d'accord inter-modèles. Args: - df_agree: DataFrame item-niveau enrichi par `agreement_per_item()`. + df_agree: DataFrame issu de `agreement_per_item`. Returns: - DataFrame résumé : - - n_accord_modeles (1..N) - - n_items - - pct_items - - accord_avec_expert : parmi ces items, % où la modale = expert + Un DataFrame indexé par `n_accord_modeles` : effectif, part des items et + accord du code modal avec l'expert, pour chaque niveau d'accord observé. """ n_modeles = int(df_agree["n_modeles"].iloc[0]) n_total = len(df_agree) @@ -257,7 +214,17 @@ def disagreement_type_summary(df_agree: pd.DataFrame) -> pd.DataFrame: def _wilson_ci(k: int, n: int, z: float = 1.96) -> tuple[float, float]: - """Intervalle de Wilson (pourcentages). Utilisé pour l'accord retenu.""" + """Intervalle de Wilson (pourcentages). Utilisé pour l'accord retenu. + + Args: + k: nombre de succès. + n: nombre total d'observations. + z: quantile normal (1.96 pour un niveau de 95 %). + + Returns: + Les bornes basse et haute de l'intervalle, en pourcentages, ou (NaN, NaN) + si n vaut 0. + """ if n == 0: return float("nan"), float("nan") p = k / n @@ -272,12 +239,25 @@ def referral_curve_with_ci( conf: pd.DataFrame, reference_run: str, ) -> pd.DataFrame: - """Comme `referral_curve_multi`, avec IC Wilson sur l'accord retenu.""" + """Comme `referral_curve_multi`, avec IC Wilson sur l'accord retenu. + + Args: + df_agree: DataFrame issu de `agreement_per_item` (contient le run de référence). + conf: scores de confiance par copie issus de `confidence_score`. + reference_run: run dont l'accord avec l'expert sert de référence. + + Returns: + Un DataFrame, une ligne par seuil τ (0 à 100 par pas de 5) : part de + copies renvoyées, accord des items retenus avec son IC Wilson, nombre de + copies et d'items retenus. + + Raises: + ValueError: si le run de référence est absent de df_agree. + """ pred_col = f"y_pred__{reference_run}" if pred_col not in df_agree.columns: raise ValueError(f"Run {reference_run!r} absent.") - # Table par copie : score + booléen accord sur chaque item copies_conf = conf["score_confiance"].to_dict() df = df_agree.copy() df["_correct"] = (df[pred_col] == df["y_true"]).astype(int) diff --git a/src/evaluation_dictee/evaluation/report.py b/src/evaluation_dictee/evaluation/report.py index d3aa93f..fab35b4 100644 --- a/src/evaluation_dictee/evaluation/report.py +++ b/src/evaluation_dictee/evaluation/report.py @@ -1,18 +1,8 @@ """Analyse des résultats par item et par copie, avec intervalles de confiance. -Toutes les fonctions renvoient des DataFrames pandas, prêts à tracer ou à -exporter. Conventions : -- « erreur » = code != "1" (dans la grille simplifiée : 9 ou 0) -- détection d'erreur : classe positive = l'expert a codé une erreur - -Vocabulaire des désaccords (du point de vue du modèle) : -- SUR-CORRECTION : l'expert voit une erreur, le modèle code correct. - Le modèle a « corrigé » silencieusement la faute en lisant. Biais VLM connu. -- SUR-DÉTECTION : l'expert code correct, le modèle voit une erreur. - Le modèle invente une faute (lecture trop sévère ou hallucination). -Les deux se compensent dans l'accord global mais ont des conséquences -opérationnelles opposées : la sur-correction sous-estime les difficultés des -élèves, la sur-détection les surestime. +Convention : « erreur » = code != "1". Deux désaccords aux conséquences opposées : +SUR-CORRECTION (expert erreur, modèle correct — biais VLM connu) et SUR-DÉTECTION +(expert correct, modèle erreur). """ from __future__ import annotations @@ -20,6 +10,7 @@ import json from pathlib import Path +import fsspec import pandas as pd from sklearn.metrics import cohen_kappa_score, confusion_matrix @@ -29,14 +20,18 @@ def load_predictions(predictions_path: str | Path) -> pd.DataFrame: """Charge les prédictions sauvegardées par le benchmark (JSON Lines). + Accepte un chemin local OU un URI S3 (s3://...) : même accès fsspec que + `data/loaders.py`, ce qui permet de lire les prédictions exportées sur S3 + sans réexécuter le pipeline. + Args: - predictions_path: chemin du fichier .jsonl produit par run_benchmark. + predictions_path: chemin local ou S3 du JSONL (une prédiction par ligne). Returns: - DataFrame avec colonnes copy_id, item_id, y_true, y_pred, confidence. + Un DataFrame, une ligne par item, avec les colonnes du JSONL. """ records = [] - with open(predictions_path, encoding="utf-8") as f: + with fsspec.open(str(predictions_path), "rt", encoding="utf-8") as f: for line in f: line = line.strip() if line: @@ -48,18 +43,13 @@ def per_item_metrics(df: pd.DataFrame, level: float = 0.95) -> pd.DataFrame: """Métriques par item, avec intervalle de Wilson sur l'accord et la prévalence. Args: - df: DataFrame des prédictions. - level: niveau de confiance des intervalles. + df: prédictions à l'item (colonnes item_id, y_true, y_pred). + level: niveau de confiance des intervalles de Wilson. Returns: - DataFrame indexé par item_id : - - n, accord, accord_lo, accord_hi (intervalle de Wilson) - - kappa (NaN si pas de variance) - - pct_erreur_expert / pct_erreur_modele : prévalence d'erreur (%) selon chacun - - pct_erreur_expert_lo/hi, pct_erreur_modele_lo/hi : IC Wilson à 95 % - - rappel_erreur : parmi les erreurs de l'expert, % retrouvées par le modèle - - precision_erreur : parmi les erreurs du modèle, % confirmées par l'expert - - n_sur_correction / n_sur_detection : effectifs des deux désaccords + Un DataFrame indexé par item_id : accord et son IC, kappa, prévalence + d'erreur experte et modèle avec IC, rappel/précision sur l'erreur, et + comptes de sur-correction et de sur-détection. """ rows = [] for item_id, grp in df.groupby("item_id"): @@ -79,10 +69,9 @@ def per_item_metrics(df: pd.DataFrame, level: float = 0.95) -> pd.DataFrame: n_exp_err = int(exp_err.sum()) n_mod_err = int(mod_err.sum()) vrais_pos = int((exp_err & mod_err).sum()) - sur_corr = int((exp_err & ~mod_err).sum()) # expert: erreur, modèle: correct - sur_det = int((~exp_err & mod_err).sum()) # expert: correct, modèle: erreur + sur_corr = int((exp_err & ~mod_err).sum()) + sur_det = int((~exp_err & mod_err).sum()) - # Intervalles de Wilson sur la prévalence d'erreur (proportion binomiale) ci_exp = wilson_interval(n_exp_err, n, level) ci_mod = wilson_interval(n_mod_err, n, level) @@ -112,24 +101,13 @@ def per_item_metrics(df: pd.DataFrame, level: float = 0.95) -> pd.DataFrame: def per_copy_metrics(df: pd.DataFrame) -> pd.DataFrame: """Métriques par copie : repère les élèves/copies difficiles pour le modèle. - Le test pilote a montré que les copies d'élèves faibles (plus de fautes, - écriture moins normée) sont plus dures à coder. Cette vue le quantifie. - Args: - df: DataFrame des prédictions. + df: prédictions à l'item (colonnes copy_id, y_true, y_pred, confidence). Returns: - DataFrame indexé par copy_id : - - n_items, accord - - n_erreurs_expert, n_erreurs_modele : nombre total d'items non corrects - (codes 9 ET 0) selon l'expert et le modèle. Sert de proxy du niveau - global de la copie. - - n_fautes_expert, n_fautes_modele : nombre d'items code 9 (fautes - d'orthographe stricto sensu). - - n_manquants_expert, n_manquants_modele : nombre d'items code 0 - (mots oubliés par l'élève). - - pct_erreur_expert / pct_erreur_modele : mêmes grandeurs en pourcentage. - - confiance_moyenne : confiance moyenne du modèle sur la copie. + Un DataFrame indexé par copy_id : effectif, accord, comptes d'erreurs, + de fautes (code "9") et de manquants (code "0") côté expert et modèle, + prévalences d'erreur et confiance moyenne. """ rows = [] for copy_id, grp in df.groupby("copy_id"): @@ -163,14 +141,14 @@ def per_copy_metrics(df: pd.DataFrame) -> pd.DataFrame: def disagreement_decomposition(df: pd.DataFrame) -> pd.DataFrame: - """Décompose chaque type de désaccord, globalement. + """Décompose chaque type de désaccord (transition expert→modèle), globalement. Args: - df: DataFrame des prédictions. + df: prédictions à l'item (colonnes y_true, y_pred). Returns: - DataFrame une ligne par type de transition expert→modèle parmi les - désaccords, avec effectif et pourcentage du total des désaccords. + Un DataFrame trié par fréquence : libellé de la transition, effectif et + part parmi les désaccords. Vide s'il n'y a aucun désaccord. """ dis = df[df["y_true"] != df["y_pred"]] if len(dis) == 0: @@ -190,11 +168,11 @@ def confusion_df(df: pd.DataFrame, normalize: bool = False) -> pd.DataFrame: """Matrice de confusion globale expert × modèle. Args: - df: DataFrame des prédictions. - normalize: si True, normalise chaque ligne (somme = 1). + df: prédictions à l'item (colonnes y_true, y_pred). + normalize: si True, normalise chaque ligne (expert) pour sommer à 1. Returns: - DataFrame lignes = expert, colonnes = modèle. + Un DataFrame carré, lignes préfixées « expert: » et colonnes « modèle: ». """ labels = sorted(set(df["y_true"]) | set(df["y_pred"])) matrix = confusion_matrix(df["y_true"], df["y_pred"], labels=labels) @@ -209,20 +187,14 @@ def confusion_df(df: pd.DataFrame, normalize: bool = False) -> pd.DataFrame: def copies_by_disagreement(df: pd.DataFrame) -> pd.DataFrame: - """Table des copies triées par taux de désaccord brut décroissant. - - Outil de diagnostic : les copies en tête sont les plus problématiques et les - premières à inspecter visuellement. + """Copies triées par taux de désaccord brut décroissant (les pires en tête). Args: - df: DataFrame des prédictions (copy_id, item_id, y_true, y_pred). + df: prédictions à l'item (colonnes copy_id, y_true, y_pred). Returns: - DataFrame indexé par copy_id, trié par pct_desaccord décroissant : - - n_items : nombre d'items de la copie - - n_desaccords : nombre d'items en désaccord - - pct_desaccord : taux de désaccord brut (0–100) - - accord : taux d'accord (= 100 - pct_desaccord, en proportion) + Un DataFrame indexé par copy_id : effectif, nombre et pourcentage de + désaccords, accord, trié par pourcentage de désaccord décroissant. """ rows = [] for copy_id, grp in df.groupby("copy_id"): diff --git a/src/evaluation_dictee/evaluation/results_summary.py b/src/evaluation_dictee/evaluation/results_summary.py new file mode 100644 index 0000000..ac65a03 --- /dev/null +++ b/src/evaluation_dictee/evaluation/results_summary.py @@ -0,0 +1,136 @@ +"""Synthèses prêtes à afficher pour l'onglet Résultats du site Quarto. + +Chaque fonction condense un DataFrame de prédictions (chargé par +`report.load_predictions`, local ou S3) en une poignée de chiffres agrégés, +correspondant aux lignes des tableaux comparatifs de `website/resultats.qmd`. + +Convention de codage (grille simplifiée) : un code vaut "1" si l'item est +correct, autre chose sinon (« erreur »). Voir CLAUDE.md §3. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd +from sklearn.metrics import cohen_kappa_score + +from evaluation_dictee.evaluation.calibration import expected_calibration_error + +CORRECT_CODE = "1" + + +@dataclass +class ScoringSummary: + """Chiffres agrégés d'un run de codage (une colonne du tableau end-to-end vs two-stage).""" + + run: str + n_items: int + n_copies: int + raw_agreement: float + cohen_kappa: float + recall_errors: float # rappel sur la classe « erreur » + precision_errors: float # précision sur la classe « erreur » + # part d'items « corrigés » silencieusement (expert=erreur, modèle=correct) + overcorrection_rate: float + ece: float # Expected Calibration Error + + +@dataclass +class HTRSummary: + """Chiffres agrégés d'un run de transcription HTR (une ligne du tableau HTR).""" + + run: str + n_samples: int + cer: float + wer: float + cer_normalise: float + wer_normalise: float + + +def scoring_summary(df: pd.DataFrame, run: str = "") -> ScoringSummary: + """Condense les prédictions de codage d'un run en métriques de tableau. + + Args: + df: prédictions à l'item (colonnes y_true, y_pred, confidence, copy_id). + run: nom du run, repris tel quel dans la synthèse. + + Returns: + La synthèse agrégée du run. + + Raises: + ValueError: si le DataFrame est vide ou manque des colonnes attendues. + """ + required = {"y_true", "y_pred"} + if df.empty or not required.issubset(df.columns): + raise ValueError("DataFrame de codage vide ou colonnes y_true/y_pred manquantes.") + + y_true = df["y_true"].astype(str) + y_pred = df["y_pred"].astype(str) + n = len(df) + + exp_err = y_true != CORRECT_CODE + mod_err = y_pred != CORRECT_CODE + vrais_pos = int((exp_err & mod_err).sum()) + n_exp_err = int(exp_err.sum()) + n_mod_err = int(mod_err.sum()) + + try: + kappa = float(cohen_kappa_score(y_true, y_pred)) + except ValueError: + kappa = float("nan") + + confidences = df["confidence"].tolist() if "confidence" in df.columns else [None] * n + + return ScoringSummary( + run=run, + n_items=n, + n_copies=int(df["copy_id"].nunique()) if "copy_id" in df.columns else 0, + raw_agreement=float((y_true == y_pred).mean()), + cohen_kappa=kappa, + recall_errors=vrais_pos / n_exp_err if n_exp_err else float("nan"), + precision_errors=vrais_pos / n_mod_err if n_mod_err else float("nan"), + overcorrection_rate=int((exp_err & ~mod_err).sum()) / n if n else float("nan"), + ece=expected_calibration_error(y_true.tolist(), y_pred.tolist(), confidences), + ) + + +def _micro_mean(df: pd.DataFrame, value_col: str, weight_col: str) -> float: + """Moyenne micro-pondérée d'une colonne par une colonne de poids (longueur de référence).""" + weights = df[weight_col] + total = weights.sum() + if total <= 0: + return float("nan") + return float((df[value_col] * weights).sum() / total) + + +def htr_summary(df: pd.DataFrame, run: str = "") -> HTRSummary: + """Condense les prédictions HTR d'un run en CER/WER micro-pondérés. + + Args: + df: prédictions HTR (colonnes cer, wer, cer_normalise, wer_normalise, + n_char_ref, n_mots_ref, transcrit). + run: nom du run, repris tel quel dans la synthèse. + + Returns: + La synthèse agrégée du run. + + Raises: + ValueError: si le DataFrame est vide ou manque des colonnes attendues. + """ + required = {"cer", "wer", "cer_normalise", "wer_normalise", "n_char_ref", "n_mots_ref"} + if df.empty or not required.issubset(df.columns): + raise ValueError("DataFrame HTR vide ou colonnes CER/WER manquantes.") + + # Micro-pondération sur les seuls échantillons transcrits (mêmes règles que + # transcription/htr_benchmark.py). + ok = df[df["transcrit"]] if "transcrit" in df.columns else df + + return HTRSummary( + run=run, + n_samples=len(df), + cer=_micro_mean(ok, "cer", "n_char_ref"), + wer=_micro_mean(ok, "wer", "n_mots_ref"), + cer_normalise=_micro_mean(ok, "cer_normalise", "n_char_ref"), + wer_normalise=_micro_mean(ok, "wer_normalise", "n_mots_ref"), + ) diff --git a/src/evaluation_dictee/evaluation/statistics.py b/src/evaluation_dictee/evaluation/statistics.py index 35fe421..a1c7999 100644 --- a/src/evaluation_dictee/evaluation/statistics.py +++ b/src/evaluation_dictee/evaluation/statistics.py @@ -1,15 +1,8 @@ -"""Outils statistiques pour une évaluation rigoureuse. +"""Intervalle de Wilson (proportions) et bootstrap par grappes. -Deux outils principaux : - -1. **Intervalle de Wilson** pour les proportions (accord par item). Plus fiable - que l'intervalle normal quand n est petit ou la proportion proche de 0/1. - -2. **Bootstrap par grappes (cluster bootstrap)** pour les métriques globales. - Point méthodologique important : les 83 items d'une même copie ne sont PAS - indépendants (même élève, même écriture, même niveau). Un bootstrap naïf - par item sous-estimerait l'incertitude. On rééchantillonne donc les COPIES - entières, pas les items. +Le bootstrap rééchantillonne les COPIES entières, pas les items : les 83 items +d'une copie ne sont pas indépendants, un bootstrap par item sous-estimerait +l'incertitude. """ from __future__ import annotations @@ -24,14 +17,7 @@ @dataclass class ConfidenceInterval: - """Un intervalle de confiance avec sa valeur ponctuelle. - - Attributes: - estimate: valeur observée. - lower: borne inférieure. - upper: borne supérieure. - level: niveau de confiance (ex. 0.95). - """ + """Un intervalle de confiance avec sa valeur ponctuelle.""" estimate: float lower: float @@ -43,15 +29,15 @@ def wilson_interval(successes: int, n: int, level: float = 0.95) -> ConfidenceIn """Intervalle de Wilson pour une proportion. Args: - successes: nombre de succès (ex. items en accord). - n: nombre total d'essais. - level: niveau de confiance. + successes: nombre de succès observés. + n: nombre total d'observations. + level: niveau de confiance (0.90, 0.95 ou 0.99 ; sinon 0.95 par défaut). Returns: - L'intervalle (estimate = proportion observée). + L'intervalle de confiance avec sa proportion ponctuelle, borné à [0, 1]. Raises: - ValueError: si n == 0. + ValueError: si n vaut 0. """ if n == 0: raise ValueError("n doit être > 0.") @@ -76,16 +62,16 @@ def cluster_bootstrap( """Bootstrap par grappes : rééchantillonne les copies, pas les items. Args: - df: DataFrame des prédictions (une ligne par item × copie). - metric_fn: fonction qui calcule la métrique sur un DataFrame - (ex. lambda d: cohen_kappa_score(d["y_true"], d["y_pred"])). - cluster_col: colonne identifiant la grappe (la copie). - n_boot: nombre de rééchantillonnages. - level: niveau de confiance. - seed: graine aléatoire pour la reproductibilité. + df: données à l'item, avec une colonne identifiant la grappe. + metric_fn: fonction calculant la métrique scalaire sur un DataFrame. + cluster_col: colonne servant de grappe (copie) pour le rééchantillonnage. + n_boot: nombre de tirages bootstrap. + level: niveau de confiance de l'intervalle. + seed: graine du générateur aléatoire (reproductibilité). Returns: - Intervalle percentile autour de la métrique observée. + L'intervalle de confiance : estimation sur df complet et bornes issues + des quantiles de la distribution bootstrap. """ rng = np.random.default_rng(seed) clusters = df[cluster_col].unique() diff --git a/src/evaluation_dictee/evaluation/visual_diff.py b/src/evaluation_dictee/evaluation/visual_diff.py index ce07f90..d629200 100644 --- a/src/evaluation_dictee/evaluation/visual_diff.py +++ b/src/evaluation_dictee/evaluation/visual_diff.py @@ -1,19 +1,7 @@ -"""Génère une mini-interface HTML de comparaison visuelle pour le diagnostic. - -Pour une copie donnée, l'interface affiche côte à côte : - 1. l'image numérisée de l'élève (encodée dans le HTML, autonome) ; - 2. le codage de l'annotateur expert (mot par mot, coloré) ; - 3. la transcription par le modèle (si disponible) ; - 4. le codage par le modèle (mot par mot, coloré) ; -avec mise en évidence des items où modèle et expert divergent. - -Le fichier HTML produit est autonome (image en base64) : il s'ouvre directement -dans un navigateur, sans serveur. Idéal pour inspecter les copies-types -identifiées par le module diagnostics. - -ATTENTION DONNÉES SENSIBLES : le HTML contient l'image d'une copie d'élève. -Le générer uniquement dans l'environnement sécurisé (SSP Cloud), ne jamais le -committer ni le sortir de l'environnement. +"""HTML autonome (image base64) comparant, par copie, codage expert et codage modèle. + +DONNÉES SENSIBLES : le HTML contient l'image d'une copie d'élève. À générer +uniquement sur le SSP Cloud, à ne jamais committer ni sortir de l'environnement. """ from __future__ import annotations @@ -26,10 +14,9 @@ import pandas as pd from PIL import Image +from evaluation_dictee.data.grid import GridItem from evaluation_dictee.data.loaders import load_image -from evaluation_dictee.data.reference import GridItem -# Couleurs par code (fond) — lisibles et cohérentes entre les deux codages _COULEUR_CODE = { "1": "#d7f0d7", # correct → vert clair "9": "#f7d4d4", # erreur → rouge clair @@ -39,7 +26,15 @@ def _img_base64(path: str, max_width: int = 1100) -> str: - """Charge l'image, la redimensionne si besoin, renvoie une data URL PNG.""" + """Charge l'image, la redimensionne si besoin, renvoie une data URL PNG. + + Args: + path: chemin de l'image de la copie. + max_width: largeur maximale ; l'image est réduite au-delà (ratio conservé). + + Returns: + Une data URL `data:image/png;base64,...` prête à inliner dans le HTML. + """ img: Image.Image = load_image(path) if img.width > max_width: ratio = max_width / img.width @@ -58,8 +53,15 @@ def _chip( ) -> str: """Rend une « puce » HTML pour un item : mot attendu + code, colorée. - Affiche également la transcription (« lu : … ») quand elle diffère du mot - attendu, et le raisonnement chain-of-thought (« → … ») quand il est fourni. + Args: + mot: mot attendu (texte de référence de l'item). + code: code attribué, qui détermine la couleur de fond. + transcription: lecture du modèle, affichée si elle diffère du mot attendu. + comparaison: commentaire du modèle, affiché s'il n'est pas « identique ». + divergent: si True, bordure rouge signalant un désaccord expert/modèle. + + Returns: + Le fragment HTML de la puce. """ bg = _COULEUR_CODE.get(code, "#ffffff") bord = "2px solid #b00020" if divergent else "1px solid #ccc" @@ -93,20 +95,19 @@ def build_copy_comparison( Args: copy_id: identifiant de la copie. - image_path: chemin de l'image (local ou s3://). - grid_items: items de la grille dans l'ordre (mot attendu). - expert_codes: {item_id: code_expert}. - model_preds: {item_id: {"code": ..., "transcription": ..., "confidence": ...}}. - raw_transcription: transcription brute de l'étape 1 (approche two_stage). - Si fournie, elle est affichée telle quelle ; sinon la transcription est - reconstituée en recollant les transcriptions par item (approche end_to_end). + image_path: chemin de l'image numérisée de la copie. + grid_items: items de la grille (mot attendu et item_id), dans l'ordre. + expert_codes: code expert par item_id. + model_preds: par item_id, dict {code, transcription, comparaison, ...}. + raw_transcription: transcription brute de l'étape 1 (HTR), prioritaire + si fournie sur la reconstitution par item. Returns: - Un fragment HTML pour cette copie. + Le fragment HTML `
` de la copie (image, transcription, codages + expert et modèle, badge d'accord). """ img_data = _img_base64(image_path) - # Statistiques rapides de la copie n = len(grid_items) n_div = sum( 1 @@ -115,7 +116,6 @@ def build_copy_comparison( ) accord = (n - n_div) / n if n else 0.0 - # Reconstruire la « phrase » expert et modèle, colorée chips_expert, chips_modele = [], [] transcription_libre = [] for it in grid_items: @@ -138,8 +138,8 @@ def build_copy_comparison( if mtrans: transcription_libre.append(mtrans) - # Priorité à la transcription brute de l'étape 1 (approche two_stage), qui est - # la VRAIE lecture du modèle. À défaut, on recolle les transcriptions par item. + # Priorité à la transcription brute de l'étape 1 (VRAIE lecture du modèle) ; + # à défaut on recolle les transcriptions par item. if raw_transcription and raw_transcription.strip(): transcription_txt = html.escape(raw_transcription.strip()) source_trans = "étape 1 (HTR)" @@ -204,11 +204,11 @@ def build_html_report( """Assemble la page HTML complète à partir de fragments de copies. Args: - copies_html: fragments HTML (un par copie, via build_copy_comparison). - title: titre de la page. + copies_html: fragments HTML de copies (issus de `build_copy_comparison`). + title: titre de la page (aussi utilisé dans l'en-tête). Returns: - Le document HTML complet. + Le document HTML complet et autonome (CSS et légende inclus). """ legende = ( "

Légende des codes : " @@ -241,22 +241,18 @@ def generate_comparison_report( """Génère le rapport HTML de comparaison pour une liste de copies. Args: - copy_ids: copies à inclure (typiquement les copies-types du diagnostic). - predictions_df: prédictions (copy_id, item_id, y_pred, confidence, - et éventuellement transcription si conservée). - grid_items: items de la grille dans l'ordre. - images_dir: dossier des images (local ou s3://). - expert_labels: {copy_id: {item_id: code_expert}} (issu de load_labels). - Les codes bruts (3/4/5) sont normalisés automatiquement selon `scheme`. + copy_ids: copies à inclure dans le rapport. + predictions_df: prédictions à l'item (codes, transcription, comparaison…). + grid_items: items de la grille de la dictée, dans l'ordre. + images_dir: dossier des images ; le chemin est `images_dir/`. + expert_labels: codes experts bruts par copie, puis par item_id. output_path: chemin du fichier HTML à écrire. - scheme: schéma de codage ("simplifiee" ou "complete"). Utilisé pour - normaliser les codes experts bruts du CSV afin qu'ils correspondent - au jeu de modalités affiché dans le HTML et utilisé par le modèle. + scheme: schéma de codage pour normaliser les codes experts (ex. « simplifiee »). Returns: - Le chemin du fichier HTML produit. + Le chemin du fichier HTML écrit. """ - from evaluation_dictee.data.grid import normalize as _normalize + from evaluation_dictee.data.reference import normalize as _normalize has_trans = "transcription" in predictions_df.columns has_comp = "comparaison" in predictions_df.columns @@ -273,7 +269,7 @@ def generate_comparison_report( } for _, r in sub.iterrows() } - # Transcription brute de l'étape 1 (identique sur toutes les lignes de la copie) + # Transcription brute étape 1 : identique sur toutes les lignes de la copie raw_trans = None if has_raw and len(sub): vals = sub["raw_transcription"].dropna() @@ -310,16 +306,16 @@ def report_worst_copies( """Génère le HTML diagnostic des N copies au plus fort taux de désaccord. Args: - predictions_df: prédictions (copy_id, item_id, y_pred, confidence, transcription). - grid_items: items de la grille dans l'ordre. - images_dir: dossier des images (local ou s3://). - expert_labels: {copy_id: {item_id: code_expert}} (issu de load_labels). + predictions_df: prédictions à l'item. + grid_items: items de la grille de la dictée, dans l'ordre. + images_dir: dossier des images. + expert_labels: codes experts bruts par copie, puis par item_id. output_path: chemin du fichier HTML à écrire. n: nombre de copies (les pires) à inclure. scheme: schéma de codage pour normaliser les codes experts. Returns: - Le chemin du fichier HTML produit. + Le chemin du fichier HTML écrit. """ from evaluation_dictee.evaluation.report import copies_by_disagreement @@ -347,23 +343,20 @@ def report_single_copy( ) -> Path: """Génère le HTML diagnostic d'UNE copie identifiée par son copy_id. - Permet à l'utilisateur d'inspecter une copie précise (image + transcription + - codage expert + codage modèle + désaccords surlignés). - Args: - copy_id: identifiant de la copie à afficher (ex. "dictee_2015_0042.png"). - predictions_df: prédictions. - grid_items: items de la grille dans l'ordre. - images_dir: dossier des images (local ou s3://). - expert_labels: {copy_id: {item_id: code_expert}}. + copy_id: identifiant de la copie à diagnostiquer. + predictions_df: prédictions à l'item. + grid_items: items de la grille de la dictée, dans l'ordre. + images_dir: dossier des images. + expert_labels: codes experts bruts par copie, puis par item_id. output_path: chemin du fichier HTML à écrire. scheme: schéma de codage pour normaliser les codes experts. Returns: - Le chemin du fichier HTML produit. + Le chemin du fichier HTML écrit. Raises: - ValueError: si la copie n'est pas présente dans les prédictions. + ValueError: si le copy_id est absent des prédictions. """ if copy_id not in set(predictions_df["copy_id"]): dispo = sorted(set(predictions_df["copy_id"]))[:5] diff --git a/src/evaluation_dictee/models/base.py b/src/evaluation_dictee/models/base.py index 675227e..e7e5a5d 100644 --- a/src/evaluation_dictee/models/base.py +++ b/src/evaluation_dictee/models/base.py @@ -1,8 +1,4 @@ -"""Interface commune à tous les modèles d'évaluation. - -Chaque méthode (A/B/C/D) implémente la même interface `Scorer`, ce qui permet -de les comparer avec le même code de benchmark et les mêmes métriques. -""" +"""Interface commune `Scorer` à tous les modèles d'évaluation (méthodes A/B/C/D comparables).""" from __future__ import annotations @@ -14,62 +10,39 @@ @dataclass class ItemPrediction: - """Prédiction du modèle pour un item. - - Attributes: - item_id: identifiant de l'item. - code: code prédit (selon le schéma de la config). - confidence: score de confiance dans [0, 1] (None si indisponible). - transcription: ce que le modèle a lu (utile pour l'analyse, optionnel). - comparaison: description de la différence lue-attendue quand le mode - chain-of-thought est activé (None sinon). Sert au diagnostic. - """ + """Prédiction du modèle pour un item.""" item_id: str code: str confidence: float | None = None transcription: str | None = None - comparaison: str | None = None + comparaison: str | None = None # différence lue-attendue, renseignée en mode chain-of-thought @dataclass class CopyPrediction: - """Ensemble des prédictions du modèle pour une copie. - - Attributes: - copy_id: identifiant de la copie. - items: prédictions par item. - transcribed: False si le modèle n'a renvoyé aucune transcription - exploitable (réponse vide/non parsable après tous les essais). - Une copie non transcrite est exclue du calcul des métriques. - n_attempts: nombre de tentatives effectuées pour obtenir une réponse. - """ + """Ensemble des prédictions du modèle pour une copie.""" copy_id: str items: list[ItemPrediction] - transcribed: bool = True + transcribed: bool = True # False = aucune transcription exploitable, copie exclue des métriques n_attempts: int = 1 - # Transcription brute produite à l'étape 1 (approche two_stage). None en - # approche end_to_end, où la transcription n'existe que par item. + # Transcription brute de l'étape 1 (two_stage) ; None en end_to_end. raw_transcription: str | None = None class Scorer(ABC): - """Contrat que doit respecter tout modèle d'évaluation de copie. - - Implémenter `score_copy` pour une nouvelle méthode suffit à l'intégrer au - pipeline de benchmark. - """ + """Contrat que doit respecter tout modèle d'évaluation de copie (implémenter `score_copy`).""" @abstractmethod def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: """Évalue une copie et renvoie un code (+ confiance) par item. Args: - copy: la copie à évaluer (image + identifiants d'items). - reference_text: texte de référence de la dictée (None si production libre). + copy: Copie à évaluer (image et identifiants d'items). + reference_text: Texte de référence de la dictée, ou None si inconnu. Returns: - Les prédictions pour tous les items de la copie. + Prédiction de la copie : un `ItemPrediction` par item. """ raise NotImplementedError diff --git a/src/evaluation_dictee/models/factory.py b/src/evaluation_dictee/models/factory.py index 962505e..a11a566 100644 --- a/src/evaluation_dictee/models/factory.py +++ b/src/evaluation_dictee/models/factory.py @@ -1,13 +1,9 @@ -"""Fabrique de scorer : choisit l'approche selon la configuration. - -Centralise la construction du bon `Scorer` (approche 1 ou 2) pour que le script -CLI et les notebooks n'aient pas à dupliquer cette logique. -""" +"""Fabrique de scorer : construit le bon `Scorer` (approche 1 ou 2) selon la configuration.""" from __future__ import annotations from evaluation_dictee.config import ExperimentConfig -from evaluation_dictee.data.reference import GridItem +from evaluation_dictee.data.grid import GridItem from evaluation_dictee.models.base import Scorer from evaluation_dictee.models.two_stage import TwoStageScorer from evaluation_dictee.models.vlm import VLMScorer @@ -22,13 +18,13 @@ def build_scorer( """Construit le scorer correspondant à l'approche déclarée dans la config. Args: - config: configuration de l'expérience (contient `approach`). - grid_items: items de la grille (mot attendu). - base_url: URL de l'API. - api_key: clé d'API. + config: Configuration de l'expérience (dont `approach`, `model`, `prompt`, `grid`). + grid_items: Items de la grille de codage. + base_url: URL de base de l'API compatible OpenAI. + api_key: Clé d'API. Returns: - Un VLMScorer (approche end_to_end) ou un TwoStageScorer (approche two_stage). + Un `TwoStageScorer` si `approach == "two_stage"`, sinon un `VLMScorer`. """ if config.approach == "two_stage": return TwoStageScorer( diff --git a/src/evaluation_dictee/models/two_stage.py b/src/evaluation_dictee/models/two_stage.py index 9f7ab24..68bbe26 100644 --- a/src/evaluation_dictee/models/two_stage.py +++ b/src/evaluation_dictee/models/two_stage.py @@ -1,37 +1,25 @@ -"""Scorer en deux étapes (APPROCHE 1) : transcription HTR puis codage textuel. +"""Scorer en deux étapes (APPROCHE 1) : étape 1 HTR (lecture image), étape 2 codage textuel. -Contraste avec `VLMScorer` (approche 2, end-to-end). Ici on sépare explicitement : - - Étape 1 (HTR) : un modèle multimodal lit l'image et restitue le texte brut - écrit par l'élève, SANS connaître le texte de référence ni le - découpage en items. On mesure ainsi la lecture pour elle-même. - - Étape 2 (codage) : un modèle (éventuellement texte seul, plus léger) reçoit la - transcription de l'étape 1 + le texte de référence, et code - chaque item. Il ne voit pas l'image. - -Avantages : la transcription intermédiaire est réutilisable et inspectable ; -les sources d'erreur (lecture vs jugement) sont découplées. -Limite : deux étapes = deux sources d'erreur cumulables. - -Le scorer respecte la même interface `Scorer` que `VLMScorer`, donc il est évalué -par exactement le même code de benchmark et de métriques (comparaison équitable). +Découple lecture et jugement ; même interface `Scorer` que `VLMScorer` pour une comparaison juste. """ from __future__ import annotations import json import re +from typing import cast from openai import OpenAI +from openai.types.chat import ChatCompletionMessageParam from evaluation_dictee.config import ModelConfig, PromptConfig +from evaluation_dictee.data.grid import GridItem from evaluation_dictee.data.loaders import Copy, load_image -from evaluation_dictee.data.reference import GridItem from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer from evaluation_dictee.models.vlm import _image_to_data_url from evaluation_dictee.pipeline.alignment import best_realignment, needs_realignment from evaluation_dictee.pipeline.prompts import ( + attach_image, build_text_coding_prompt, build_transcription_prompt, ) @@ -41,19 +29,13 @@ def _extract_items_from_content(content: str) -> list[dict]: - """Extrait la liste d'items d'une réponse modèle, de façon robuste. - - Cascade de tentatives, de la plus stricte à la plus permissive : - 1. JSON complet après nettoyage des ```json``` ; - 2. Recherche d'un objet racine contenant "items" par regex ; - 3. Extraction directe de tous les sous-objets à structure d'item - (au moins item_id + code) — utile si le JSON global est tronqué. + """Extrait la liste d'items d'une réponse modèle, du parsing strict au plus permissif. Args: - content: réponse brute du modèle. + content: Contenu textuel brut renvoyé par le modèle. Returns: - La liste d'items trouvée (vide si rien d'exploitable). + Liste des items (dictionnaires bruts), vide si rien n'est extractible. """ if not content: return [] @@ -94,8 +76,7 @@ def _extract_items_from_content(content: str) -> list[dict]: except json.JSONDecodeError: pass - # Tentative 3 : récupérer TOUS les sous-objets qui ressemblent à un item - # (utile si la liste "items" est tronquée mais que les objets sont valides) + # Tentative 3 : récupérer les sous-objets d'item isolés (utile si la liste est tronquée). items = [] for match in re.finditer( r'\{\s*"item_id"\s*:\s*"[^"]+"[^{}]*"code"\s*:\s*"[^"]+"[^{}]*\}', @@ -121,17 +102,17 @@ def __init__( scheme: str = "simplifiee", model_config_stage2: ModelConfig | None = None, ) -> None: - """Initialise les clients des deux étapes. + """Initialise le client, l'index des items et les configs des deux étapes. Args: - model_config: modèle de l'étape 1 (HTR) — doit être multimodal. - prompt_config: stratégie de prompting (ratures...). - base_url: URL de l'API (compatible OpenAI). - api_key: clé d'API. - grid_items: items de la grille (mot attendu, dans l'ordre). - scheme: schéma de codage ("simplifiee" ou "complete"). - model_config_stage2: modèle de l'étape 2 (codage texte). Si None, on - réutilise `model_config`. Peut être un modèle texte seul, plus léger. + model_config: Configuration du modèle de l'étape 1 (transcription HTR). + prompt_config: Configuration du prompt (lecture de l'état final, etc.). + base_url: URL de base de l'API compatible OpenAI. + api_key: Clé d'API. + grid_items: Items de la grille de codage. + scheme: Grille de codage utilisée (par défaut « simplifiee »). + model_config_stage2: Configuration du modèle de l'étape 2 (codage textuel) ; + reprend `model_config` si None. """ self.model_config = model_config self.model_config_stage2 = model_config_stage2 or model_config @@ -143,24 +124,23 @@ def __init__( # ── Étape 1 : transcription HTR ────────────────────────────────────────── def _transcribe(self, copy: Copy) -> tuple[str, int]: - """Lit l'image et renvoie (transcription, nb_tentatives). + """Lit l'image et renvoie (transcription, nb_tentatives) ; vide si échec total. - Réessaie tant que la réponse est vide/non parsable, comme VLMScorer. + Args: + copy: Copie dont l'image est à transcrire. Returns: - (transcription, n_attempts). Transcription vide si échec total. + Couple (transcription, nombre de tentatives) ; transcription vide si aucun + essai n'a produit de texte exploitable. """ image = load_image(copy.image_path) - prompt = build_transcription_prompt(read_final_state=self.prompt_config.read_final_state) - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - {"type": "image_url", "image_url": {"url": _image_to_data_url(image)}}, - ], - } - ] + messages = cast( + "list[ChatCompletionMessageParam]", + attach_image( + build_transcription_prompt(read_final_state=self.prompt_config.read_final_state), + _image_to_data_url(image), + ), + ) for attempt in range(self.model_config.max_retries + 1): temp = self.model_config.temperature + (0.3 if attempt > 0 else 0.0) response = self.client.chat.completions.create( @@ -177,30 +157,46 @@ def _transcribe(self, copy: Copy) -> tuple[str, int]: @staticmethod def _parse_transcription(content: str) -> str: - """Extrait le champ 'transcription' du JSON renvoyé à l'étape 1.""" + """Extrait le champ 'transcription' du JSON renvoyé à l'étape 1. + + Args: + content: Contenu textuel brut renvoyé par le modèle. + + Returns: + Transcription extraite ; repli sur le texte brut si le JSON est illisible. + """ try: cleaned = content.strip().removeprefix("```json").removeprefix("```") cleaned = cleaned.removesuffix("```").strip() data = json.loads(cleaned) return str(data.get("transcription", "")).strip() except (json.JSONDecodeError, KeyError, TypeError): - # Repli : si le modèle a répondu en texte brut, on le prend tel quel. + # Repli : réponse en texte brut prise telle quelle. return content.strip() # ── Étape 2 : codage du texte transcrit ────────────────────────────────── def _code_text(self, copy: Copy, reference_text: str, transcription: str) -> CopyPrediction: """Code chaque item à partir de la transcription (sans image). - Réessaie tant que le JSON de réponse est vide ou non parsable, comme - l'étape 1. Sans ce retry, un échec ponctuel de génération JSON (préambule - parasite, troncature) codait toute la copie en '?' silencieusement. + Le retry évite qu'un échec ponctuel de génération JSON code toute la copie en '?'. + + Args: + copy: Copie évaluée (fournit la liste d'items à coder). + reference_text: Texte de référence de la dictée. + transcription: Transcription produite à l'étape 1. + + Returns: + Prédiction de la copie ; items codés « ? » si aucun essai n'est exploitable. """ items_a_coder = [self._items_by_id[i] for i in copy.item_ids if i in self._items_by_id] - prompt = build_text_coding_prompt( - reference_text=reference_text, - transcription=transcription, - items=items_a_coder, - scheme=self.scheme, + messages = cast( + "list[ChatCompletionMessageParam]", + build_text_coding_prompt( + reference_text=reference_text, + transcription=transcription, + items=items_a_coder, + scheme=self.scheme, + ), ) for attempt in range(self.model_config_stage2.max_retries + 1): temp = self.model_config_stage2.temperature + (0.3 if attempt > 0 else 0.0) @@ -208,10 +204,9 @@ def _code_text(self, copy: Copy, reference_text: str, transcription: str) -> Cop model=self.model_config_stage2.name, temperature=temp, max_tokens=self.model_config_stage2.max_tokens, - messages=[{"role": "user", "content": prompt}], + messages=messages, ) content = response.choices[0].message.content or "" - # On regarde d'abord si le JSON est extractible avant de le parser. if _extract_items_from_content(content): return self._parse_coding(copy, content) logger.warning( @@ -220,25 +215,21 @@ def _code_text(self, copy: Copy, reference_text: str, transcription: str) -> Cop self.model_config_stage2.max_retries + 1, copy.copy_id, ) - # Tous les essais ont échoué : on parse quand même (produira des '?') - # pour laisser _parse_coding logger et remonter proprement. + # Échec total : on parse quand même pour laisser _parse_coding logger et remonter des '?'. return self._parse_coding(copy, content) def _parse_coding(self, copy: Copy, content: str) -> CopyPrediction: - """Parse le JSON de codage de l'étape 2 (mêmes garanties que VLMScorer). + """Parse le JSON de codage de l'étape 2, avec ré-alignement de sécurité (cf. VLMScorer). - Extraction JSON robuste : gère les préambules textuels, blocs ```json, - et tente une recherche par regex si le parsing direct échoue (utile - quand le modèle ajoute du texte avant/après le JSON). + Args: + copy: Copie évaluée (fournit la liste d'items attendus). + content: Contenu textuel brut renvoyé par le modèle à l'étape 2. - Applique le ré-alignement de sécurité si la transcription par item révèle - un décalage. Réutilise exactement la logique d'alignement de l'approche 2, - ce qui garde les deux approches comparables. + Returns: + Prédiction de la copie ; items codés « ? » pour ceux absents de la réponse. """ raw_items = _extract_items_from_content(content) if not raw_items: - # Échec total du parsing : le modèle n'a pas rendu de JSON exploitable. - # On loggue explicitement pour que le silence ne masque plus le problème. logger.warning( "Étape 2 (codage textuel) : aucun item extractible du JSON pour la " "copie %s. Réponse tronquée ou format cassé (longueur brute : %d). " @@ -289,20 +280,20 @@ def _parse_coding(self, copy: Copy, content: str) -> CopyPrediction: # ── Interface Scorer ───────────────────────────────────────────────────── def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: - """Évalue une copie en enchaînant transcription puis codage. + """Évalue une copie : transcription puis codage (non transcrite si l'étape 1 échoue). Args: - copy: copie à évaluer. - reference_text: texte de référence de la dictée. + copy: Copie à évaluer. + reference_text: Texte de référence de la dictée, ou None. Returns: - Prédictions par item. Si l'étape 1 ne produit aucune transcription, - la copie est marquée non transcrite (exclue des métriques). + Prédiction de la copie ; `transcribed=False` si l'étape 1 (HTR) n'a produit + aucune transcription. La transcription brute est conservée dans + `raw_transcription`. """ transcription, n_attempts = self._transcribe(copy) if not transcription.strip(): - # Échec de lecture : aucune transcription exploitable. items_vides = [ ItemPrediction(item_id=i, code="?", confidence=0.0) for i in copy.item_ids ] @@ -316,4 +307,4 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: prediction = self._code_text(copy, reference_text or "", transcription) prediction.n_attempts = n_attempts prediction.raw_transcription = transcription - return prediction \ No newline at end of file + return prediction diff --git a/src/evaluation_dictee/models/vlm.py b/src/evaluation_dictee/models/vlm.py index 6edc6b4..e5c7139 100644 --- a/src/evaluation_dictee/models/vlm.py +++ b/src/evaluation_dictee/models/vlm.py @@ -1,35 +1,77 @@ -"""Client pour un VLM open weight servi par vLLM (API compatible OpenAI). - -Implémente la méthode C (end-to-end) : on envoie l'image, le texte de référence -et la grille, et le modèle renvoie un code par item au format JSON, avec un score -de confiance. Voir CLAUDE.md §4. -""" +"""VLM (vLLM, API OpenAI) pour la méthode C end-to-end : image + référence -> code JSON par item.""" from __future__ import annotations import base64 import io import json +from typing import Any, cast -from openai import OpenAI +from langfuse.openai import OpenAI +from openai.types.chat import ChatCompletionMessageParam from PIL import Image from evaluation_dictee.config import ModelConfig, PromptConfig +from evaluation_dictee.data.grid import GridItem from evaluation_dictee.data.loaders import Copy, load_image -from evaluation_dictee.data.reference import GridItem from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer from evaluation_dictee.pipeline.alignment import best_realignment, needs_realignment -from evaluation_dictee.pipeline.prompts import build_dictation_prompt +from evaluation_dictee.pipeline.prompts import ( + PROMPT_DICTATION, + attach_image, + build_dictation_prompt, + fetch_prompt, +) def _image_to_data_url(image: Image.Image) -> str: - """Encode une image PIL en data URL base64 (format attendu par l'API).""" + """Encode une image PIL en data URL base64 (format attendu par l'API). + + Args: + image: Image PIL à encoder. + + Returns: + Data URL `data:image/png;base64,...` de l'image encodée en PNG. + """ buffer = io.BytesIO() image.save(buffer, format="PNG") encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") return f"data:image/png;base64,{encoded}" +def _items_json_schema(chain_of_thought: bool) -> dict[str, Any]: + """Schéma JSON de la réponse attendue (méthode C) pour le décodage contraint vLLM. + + Contraint la structure de chaque item, pas leur nombre (variable selon la copie). + + Args: + chain_of_thought: Si True, ajoute le champ obligatoire `comparaison` à chaque item. + + Returns: + Schéma JSON de la réponse attendue (objet avec une liste `items`). + """ + properties: dict[str, Any] = { + "item_id": {"type": "string"}, + "transcription": {"type": "string"}, + "code": {"type": "string"}, + "confidence": {"type": "number"}, + } + required = ["item_id", "transcription", "code", "confidence"] + if chain_of_thought: + properties["comparaison"] = {"type": "string"} + required.append("comparaison") + return { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "object", "properties": properties, "required": required}, + } + }, + "required": ["items"], + } + + class VLMScorer(Scorer): """Évalue une copie via un VLM (méthode C end-to-end).""" @@ -42,16 +84,15 @@ def __init__( grid_items: list[GridItem], scheme: str = "simplifiee", ) -> None: - """Initialise le client. + """Initialise le client et l'index des items de la grille. Args: - model_config: paramètres du modèle. - prompt_config: stratégie de prompting. - base_url: URL de l'API vLLM (compatible OpenAI). - api_key: clé d'API (factice en interne SSP Cloud). - grid_items: items de la grille (id + mot attendu), pour ancrer - l'alignement dans le prompt. - scheme: schéma de codage demandé au modèle ("simplifiee" ou "complete"). + model_config: Configuration du modèle (nom, température, retries, etc.). + prompt_config: Configuration du prompt (chain-of-thought, etc.). + base_url: URL de base de l'API compatible OpenAI. + api_key: Clé d'API. + grid_items: Items de la grille de codage. + scheme: Grille de codage utilisée (par défaut « simplifiee »). """ self.model_config = model_config self.prompt_config = prompt_config @@ -61,43 +102,49 @@ def __init__( self._items_by_id = {it.item_id: it for it in grid_items} def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: - """Évalue une copie complète en un appel au VLM. + """Évalue une copie complète en un appel au VLM (avec retries à température relevée). Args: - copy: copie à évaluer. - reference_text: texte de référence de la dictée. + copy: Copie à évaluer. + reference_text: Texte de référence de la dictée, ou None. Returns: - Prédictions par item. + Prédiction de la copie ; `transcribed=False` si aucun essai n'a produit + de transcription exploitable. """ image = load_image(copy.image_path) - # On code les items présents dans la copie, dans l'ordre de la grille. items_a_coder = [self._items_by_id[i] for i in copy.item_ids if i in self._items_by_id] - prompt = build_dictation_prompt( - reference_text=reference_text or "", - items=items_a_coder, - config=self.prompt_config, - scheme=self.scheme, + messages = cast( + "list[ChatCompletionMessageParam]", + attach_image( + build_dictation_prompt( + reference_text=reference_text or "", + items=items_a_coder, + config=self.prompt_config, + scheme=self.scheme, + ), + _image_to_data_url(image), + ), ) - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - {"type": "image_url", "image_url": {"url": _image_to_data_url(image)}}, - ], + # Lie la génération à la version du prompt dans les traces Langfuse (no-op si hors ligne). + prompt_ref = fetch_prompt(PROMPT_DICTATION) + trace_kwargs: dict[str, Any] = {"langfuse_prompt": prompt_ref} if prompt_ref else {} + + response_format_kwargs: dict[str, Any] = {} + if self.model_config.structured_output: + response_format_kwargs["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "codage_dictee", + "schema": _items_json_schema(self.prompt_config.chain_of_thought), + }, } - ] - # Tentatives successives tant que la réponse n'est pas exploitable. - # On augmente légèrement la température aux essais suivants pour sortir - # d'une éventuelle réponse vide déterministe. + # Retry avec température légèrement relevée pour sortir d'une réponse vide déterministe. prediction: CopyPrediction | None = None for attempt in range(self.model_config.max_retries + 1): temp = self.model_config.temperature + (0.3 if attempt > 0 else 0.0) - # Les modèles thinking (Qwen3, DeepSeek-R1) génèrent par défaut un - # bloc ... qui casse le parsing JSON et multiplie la - # latence. On le désactive via extra_body quand disable_thinking=True. + # disable_thinking : coupe le bloc , néfaste au parsing JSON et à la latence. extra_body = ( {"chat_template_kwargs": {"enable_thinking": False}} if self.model_config.disable_thinking @@ -109,25 +156,26 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: max_tokens=self.model_config.max_tokens, messages=messages, extra_body=extra_body or None, + **response_format_kwargs, + **trace_kwargs, ) content = response.choices[0].message.content or "{}" prediction = self._parse_response(copy, content) prediction.n_attempts = attempt + 1 if prediction.transcribed: return prediction - # Toutes les tentatives ont échoué : copie non transcrite. return prediction # type: ignore[return-value] def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: - """Parse la réponse JSON du modèle en prédictions par item. + """Parse la réponse JSON en prédictions par item, avec ré-alignement si décalage détecté. - Le modèle renvoie un objet de la forme : - {"items": [{"item_id": "...", "transcription": "...", "code": "1", - "confidence": 0.97}, ...]} + Args: + copy: Copie évaluée (fournit la liste d'items attendus). + content: Contenu textuel brut renvoyé par le modèle (JSON éventuellement balisé). - Robuste aux réponses imparfaites. Applique en outre un filet de sécurité : - si la transcription du modèle révèle un décalage (mot scindé/collé par - l'élève), les prédictions sont ré-alignées sur les mots attendus. + Returns: + Prédiction de la copie ; `transcribed=False` et items codés « ? » si la + réponse est vide ou inexploitable. """ try: cleaned = content.strip().removeprefix("```json").removeprefix("```") @@ -137,7 +185,7 @@ def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: except (json.JSONDecodeError, KeyError, TypeError): raw_items = [] - # Séquence des prédictions DANS L'ORDRE renvoyé par le modèle. + # Séquences dans l'ordre renvoyé par le modèle (avant ré-alignement éventuel). codes_seq = [str(it.get("code", "?")).strip() for it in raw_items] trans_seq = [it.get("transcription") for it in raw_items] conf_seq = [it.get("confidence") for it in raw_items] @@ -150,7 +198,6 @@ def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: ] return CopyPrediction(copy_id=copy.copy_id, items=items_vides, transcribed=False) - # Mots attendus dans l'ordre de la copie. expected_words = [ self._items_by_id[i].attendu for i in copy.item_ids if i in self._items_by_id ] @@ -169,7 +216,6 @@ def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: ] return CopyPrediction(copy_id=copy.copy_id, items=items) - # Sinon : mapping classique par item_id renvoyé par le modèle. by_id = {it.get("item_id"): it for it in raw_items} items = [] for item_id in copy.item_ids: @@ -186,4 +232,4 @@ def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: comparaison=entry.get("comparaison"), ) ) - return CopyPrediction(copy_id=copy.copy_id, items=items) \ No newline at end of file + return CopyPrediction(copy_id=copy.copy_id, items=items) diff --git a/src/evaluation_dictee/pipeline/alignment.py b/src/evaluation_dictee/pipeline/alignment.py index 4fef204..c268ae5 100644 --- a/src/evaluation_dictee/pipeline/alignment.py +++ b/src/evaluation_dictee/pipeline/alignment.py @@ -1,18 +1,7 @@ -"""Ré-alignement des prédictions du modèle sur les items attendus. +"""Ré-alignement des prédictions sur les items attendus (filet contre décalages mot scindé/collé). -Filet de sécurité contre les décalages : même avec une consigne stricte, le modèle -peut scinder un mot (« re trouver ») ou en coller deux (« nousles »), ce qui décale -toutes les prédictions suivantes. Ce module recolle les prédictions du modèle sur -les items attendus de la grille, en s'appuyant sur les TRANSCRIPTIONS fournies par -le modèle (ce qu'il dit avoir lu) comparées aux mots attendus. - -Principe : alignement de séquences (type Needleman-Wunsch simplifié) entre la suite -des mots attendus et la suite des transcriptions du modèle. On maximise la -correspondance mot attendu ↔ transcription, ce qui réabsorbe les insertions -(mot scindé) et les suppressions (mots collés). - -Ce ré-alignement n'est appliqué que si un décalage est détecté ; sinon les -prédictions positionnelles du modèle sont conservées telles quelles. +Alignement de séquences (type Needleman-Wunsch) entre mots attendus et transcriptions du modèle, +appliqué seulement si un décalage est détecté ; sinon les prédictions positionnelles restent. """ from __future__ import annotations @@ -23,23 +12,23 @@ @dataclass class AlignedPrediction: - """Prédiction d'un item après ré-alignement. - - Attributes: - code: code attribué à l'item attendu (1/9/0, ou "?" si non aligné). - transcription: transcription rattachée à cet item (peut être None). - confidence: confiance associée (peut être None). - realigned: True si l'item a été déplacé par le ré-alignement. - """ + """Prédiction d'un item après ré-alignement.""" code: str transcription: str | None confidence: float | None - realigned: bool = False + realigned: bool = False # True si l'item a été déplacé par le ré-alignement def _norm(s: str | None) -> str: - """Normalise une chaîne pour la comparaison (minuscule, sans accents/espaces).""" + """Normalise une chaîne pour la comparaison (minuscule, sans accents/espaces). + + Args: + s: chaîne à normaliser (None traité comme chaîne vide). + + Returns: + La chaîne en minuscules, sans accents ni espaces. + """ if not s: return "" s = unicodedata.normalize("NFKD", s) @@ -48,7 +37,16 @@ def _norm(s: str | None) -> str: def _similar(attendu: str, lu: str | None) -> float: - """Score de similarité simple entre mot attendu et transcription (0..1).""" + """Score de similarité simple entre mot attendu et transcription (0..1). + + Args: + attendu: mot attendu de l'item. + lu: transcription proposée par le modèle (None si absente). + + Returns: + Un score dans [0, 1] : 1.0 si identiques après normalisation, 0.8 si l'un + inclut l'autre, sinon la proportion de préfixe commun. + """ a, b = _norm(attendu), _norm(lu) if not a and not b: return 1.0 @@ -73,15 +71,15 @@ def needs_realignment( transcriptions: list[str | None], seuil: float = 0.5, ) -> bool: - """Détecte un décalage : la transcription positionnelle colle-t-elle aux mots ? + """Détecte un décalage : similarité moyenne transcription/mot attendu sous `seuil`. Args: - expected_words: mots attendus dans l'ordre. - transcriptions: transcriptions du modèle, alignées positionnellement. - seuil: en dessous de cette similarité moyenne, on suspecte un décalage. + expected_words: mots attendus, dans l'ordre des items. + transcriptions: transcriptions positionnelles du modèle (None ignorées). + seuil: seuil de similarité moyenne sous lequel un décalage est signalé. Returns: - True si un ré-alignement est souhaitable. + True si un ré-alignement est nécessaire, False sinon (ou si aucune paire comparable). """ paires = [ _similar(a, t) @@ -100,26 +98,21 @@ def realign( confidences: list[float | None], gap_penalty: float = -0.3, ) -> list[AlignedPrediction]: - """Ré-aligne les prédictions du modèle sur les mots attendus. - - Alignement de séquences entre `expected_words` et `transcriptions` maximisant - la similarité. Chaque mot attendu reçoit le code/transcription du token modèle - qui lui correspond le mieux ; les items attendus sans correspondance reçoivent - le code "0" (absent, considéré non lu) avec une confiance nulle. + """Ré-aligne les prédictions sur les mots attendus (Needleman-Wunsch) ; mot absent codé "0". Args: - expected_words: mots attendus (longueur = nombre d'items de la grille). - codes: codes prédits par le modèle (longueur = sortie du modèle). - transcriptions: transcriptions du modèle (même longueur que codes). - confidences: confiances (même longueur que codes). - gap_penalty: pénalité d'insertion/suppression dans l'alignement. + expected_words: mots attendus, un par item. + codes: codes prédits par le modèle, dans l'ordre de ses tokens. + transcriptions: transcriptions correspondant aux codes. + confidences: scores de confiance correspondant aux codes. + gap_penalty: pénalité appliquée à un saut (mot non lu ou token en trop). Returns: - Une prédiction ré-alignée par mot attendu (longueur = len(expected_words)). + Une prédiction alignée par mot attendu ; "0" si le mot n'a pas été lu, "?" + en repli si aucune correspondance n'a pu être établie. """ n, m = len(expected_words), len(codes) - # Programmation dynamique : score[i][j] = meilleur alignement des i premiers - # mots attendus avec les j premières prédictions. + # score[i][j] = meilleur alignement des i premiers mots attendus et j premières prédictions. score = [[0.0] * (m + 1) for _ in range(n + 1)] for i in range(1, n + 1): score[i][0] = i * gap_penalty @@ -159,14 +152,14 @@ def realign( def _alignment_quality(expected_words: list[str], aligned: list[AlignedPrediction]) -> float: - """Mesure la qualité d'un alignement : similarité moyenne mot attendu ↔ transcription. + """Qualité d'un alignement : similarité moyenne mot attendu / transcription dans [0, 1]. Args: - expected_words: mots attendus. - aligned: prédictions alignées (même longueur). + expected_words: mots attendus, un par item. + aligned: prédictions alignées à évaluer. Returns: - Similarité moyenne dans [0, 1]. Plus c'est haut, meilleur est l'alignement. + La similarité moyenne dans [0, 1] (0.0 si la liste est vide). """ sims = [_similar(w, a.transcription) for w, a in zip(expected_words, aligned, strict=False)] return sum(sims) / len(sims) if sims else 0.0 @@ -178,23 +171,19 @@ def realign_anchored( transcriptions: list[str | None], confidences: list[float | None], ) -> list[AlignedPrediction]: - """Stratégie d'alignement par ancrage sur les correspondances exactes. + """Alignement par ancrage sur les correspondances exactes uniques, réparti entre les ancres. - Complémentaire de `realign` (Needleman-Wunsch global). On repère d'abord les - « ancres » : positions où un mot attendu et une transcription coïncident - exactement et de façon unique. Entre deux ancres, on répartit les prédictions - proportionnellement. Cette approche est robuste quand quelques mots très - distinctifs (ex. « Martine », « téléphoner ») jalonnent la dictée et limitent - la propagation d'un décalage local. + Complémentaire de `realign` ; robuste quand des mots distinctifs jalonnent la dictée. Args: - expected_words: mots attendus. - codes: codes prédits (ordre modèle). - transcriptions: transcriptions (ordre modèle). - confidences: confiances (ordre modèle). + expected_words: mots attendus, un par item. + codes: codes prédits par le modèle, dans l'ordre de ses tokens. + transcriptions: transcriptions correspondant aux codes. + confidences: scores de confiance correspondant aux codes. Returns: - Une prédiction par mot attendu. + Une prédiction alignée par mot attendu ; "0" si le mot n'a pas été lu, "?" + en repli si aucune correspondance n'a pu être établie. """ n = len(expected_words) m = len(codes) @@ -243,20 +232,16 @@ def best_realignment( transcriptions: list[str | None], confidences: list[float | None], ) -> list[AlignedPrediction]: - """Choisit la meilleure des stratégies d'alignement disponibles. - - Applique plusieurs approches (Needleman-Wunsch global et ancrage) et retient - celle dont l'alignement colle le mieux aux mots attendus. Combine ainsi les - forces de chacune pour rattraper un maximum de cas de décalage. + """Applique Needleman-Wunsch et ancrage, garde l'alignement le plus proche des mots attendus. Args: - expected_words: mots attendus. - codes: codes prédits (ordre modèle). - transcriptions: transcriptions (ordre modèle). - confidences: confiances (ordre modèle). + expected_words: mots attendus, un par item. + codes: codes prédits par le modèle, dans l'ordre de ses tokens. + transcriptions: transcriptions correspondant aux codes. + confidences: scores de confiance correspondant aux codes. Returns: - Le meilleur alignement (une prédiction par mot attendu). + L'alignement (parmi `realign` et `realign_anchored`) de meilleure qualité. """ candidats = [ realign(expected_words, codes, transcriptions, confidences), diff --git a/src/evaluation_dictee/pipeline/benchmark.py b/src/evaluation_dictee/pipeline/benchmark.py index efcbacb..6ec2050 100644 --- a/src/evaluation_dictee/pipeline/benchmark.py +++ b/src/evaluation_dictee/pipeline/benchmark.py @@ -1,47 +1,34 @@ -"""Orchestration d'un run de benchmark : données → modèle → métriques. +"""Orchestration d'un run de benchmark : données -> modèle -> métriques, via `ExperimentConfig`. -Ce module est le cœur du projet. Il enchaîne le chargement des données, l'appel -au modèle copie par copie, la normalisation des codes selon le schéma choisi, et -le calcul des métriques. Tout est piloté par une `ExperimentConfig`. - -Les prédictions détaillées (une ligne JSON par item × copie) sont sauvegardées dans -data/processed/_predictions.jsonl pour analyse ultérieure via -evaluation/report.py et le notebook 03_analyse_resultats.ipynb. +Prédictions écrites dans data/processed/_predictions.jsonl (une ligne par item x copie). """ from __future__ import annotations +import contextvars import json import os +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path from tqdm import tqdm from evaluation_dictee.config import ExperimentConfig -from evaluation_dictee.data import grid -from evaluation_dictee.data.loaders import Copy, load_dataset -from evaluation_dictee.data.reference import load_grid +from evaluation_dictee.data import reference +from evaluation_dictee.data.grid import load_grid +from evaluation_dictee.data.loaders import Copy, ink_ratio, load_dataset, load_image from evaluation_dictee.evaluation.metrics import ScoringMetrics, compute_scoring_metrics -from evaluation_dictee.models.base import Scorer +from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer from evaluation_dictee.utils.logging import get_logger +from evaluation_dictee.utils.tracking import copy_trace logger = get_logger(__name__) @dataclass class BenchmarkResult: - """Résultat d'un run : métriques + prédictions et labels alignés. - - Attributes: - metrics: métriques globales (accord, kappa...). - y_true: codes experts normalisés, aplatis (toutes copies × tous items). - y_pred: codes prédits normalisés, alignés sur y_true. - confidences: confiance par item (None si indisponible). - item_ids: identifiant de l'item pour chaque position (pour l'analyse/item). - copy_ids: identifiant de la copie pour chaque position. - predictions_path: chemin du fichier JSONL sauvegardé (None si non sauvegardé). - """ + """Résultat d'un run : métriques + prédictions/labels alignés (y_true/y_pred aplatis).""" metrics: ScoringMetrics y_true: list[str] @@ -51,20 +38,29 @@ class BenchmarkResult: copy_ids: list[str] = field(default_factory=list) predictions_path: Path | None = None non_transcribed: list[str] = field(default_factory=list) + blank_copies: list[str] = field(default_factory=list) -def _load_processed_copy_ids(predictions_path: Path) -> set[str]: - """Lit un fichier de prédictions existant et renvoie les copy_id déjà traités. +@dataclass +class _CopyOutcome: + """Résultat du scoring d'une copie, produit par un worker et consommé au principal.""" + + copy_id: str + status: str # "ok" | "failed" | "non_transcribed" + lines: str = "" # lignes JSONL prêtes à écrire (status "ok") + error: str = "" # message d'erreur (status "failed") + n_attempts: int = 1 # nombre d'essais (status "non_transcribed") + blank: bool = False # copie vierge auto-codée "0" (status "ok") - Permet de reprendre un run interrompu : on saute les copies déjà présentes - dans le fichier `_predictions.jsonl`. Fichier corrompu (dernière ligne - tronquée par un crash) : on ignore silencieusement la dernière ligne. + +def _load_processed_copy_ids(predictions_path: Path) -> set[str]: + """Renvoie les copy_id déjà présents dans un fichier de prédictions (pour reprendre un run). Args: predictions_path: chemin du fichier JSONL de prédictions. Returns: - Ensemble des copy_id déjà présents dans le fichier. + L'ensemble des copy_id déjà traités (vide si le fichier n'existe pas). """ if not predictions_path.exists(): return set() @@ -78,7 +74,7 @@ def _load_processed_copy_ids(predictions_path: Path) -> set[str]: rec = json.loads(line) processed.add(rec["copy_id"]) except (json.JSONDecodeError, KeyError): - # Ligne tronquée par un crash : on la laisse tomber + # Ligne tronquée par un crash : ignorée. continue return processed @@ -87,26 +83,31 @@ def run_benchmark( config: ExperimentConfig, scorer: Scorer, output_dir: str | Path = "data/processed", + concurrency: int | None = None, ) -> BenchmarkResult: """Exécute un benchmark complet pour une config et un modèle donnés. - Écriture incrémentale : chaque copie évaluée est immédiatement `fsync`-ée - dans le JSONL, donc un crash à mi-chemin ne perd que la copie en cours. - Reprise automatique : si un `_predictions.jsonl` existe déjà, les - copies déjà traitées sont sautées. Pour repartir de zéro, supprimer le - fichier ou changer `config.name`. + Écriture incrémentale (fsync par copie) + reprise auto depuis le JSONL existant ; + une erreur API sur une copie est loggée dans failed_copies.txt et le run continue. + Pour repartir de zéro : supprimer le JSONL ou changer `config.name`. - Erreurs API transitoires : chaque échec sur une copie est loggé et la - copie est marquée dans `failed_copies.txt`, mais le run continue sur les - suivantes. Sans ça, un incident réseau à 30 % perdait 8 h de calcul. + Les copies sont évaluées en parallèle (`concurrency` requêtes vLLM concurrentes) : + seul le scoring est parallélisé, l'écriture du JSONL et l'agrégation restent dans + le thread principal (aucun verrou, crash-safety et reprise inchangées). Args: - config: configuration de l'expérience. - scorer: modèle implémentant l'interface Scorer. - output_dir: dossier où sauvegarder le fichier de prédictions détaillées. + config: configuration de l'expérience (données, grille, nom du run). + scorer: modèle chargé de coder chaque copie. + output_dir: dossier où écrire les prédictions et les fichiers d'échec. + concurrency: nombre de copies évaluées en parallèle ; None = valeur de la + config (`config.concurrency`). Returns: - Les métriques et les vecteurs alignés (vrais codes, prédictions, confiances). + Le résultat du run : métriques agrégées, labels/prédictions alignés et + chemin du fichier de prédictions. + + Raises: + RuntimeError: si aucune copie n'a pu être chargée. """ logger.info("Labels : %s", config.data.labels_path) logger.info("Images : %s", config.data.images_path) @@ -125,13 +126,12 @@ def run_benchmark( f" 2. Les images sont introuvables : {config.data.images_path}\n" " (les noms dans le CSV doivent correspondre aux fichiers du dossier)\n" " 3. Les identifiants S3 ne sont pas configurés.\n" - 'Diagnostic : python -c "' + 'Diagnostic : uv run python -c "' "from evaluation_dictee.data.loaders import load_labels; " f"labels=load_labels('{config.data.labels_path}'); " "print(len(labels), 'copies dans le CSV')\"" ) - # Reprise éventuelle depuis un checkpoint output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) out_path = output_dir / f"{config.name}_predictions.jsonl" @@ -152,63 +152,145 @@ def run_benchmark( len(processed), ) - reference = load_grid(config.data.grid_path).reference_text + reference_text = load_grid(config.data.grid_path).reference_text scheme = config.grid.scheme + workers = concurrency if concurrency is not None else config.concurrency + blank_threshold = config.data.blank_ink_threshold non_transcrites: list[str] = [] failed_copies: list[tuple[str, str]] = [] # (copy_id, message d'erreur) - - # Ouverture en mode APPEND : les copies précédemment traitées sont conservées. - with open(out_path, "a", encoding="utf-8") as f_pred: - for copy in tqdm(copies_a_traiter, desc=f"Évaluation ({config.name})"): - # Chaque copie est isolée dans un try/except : une erreur API sur - # UNE copie ne fait plus perdre les précédentes. + blank_copies: list[str] = [] # copies vierges auto-codées "0" + + def _score_one(copy: Copy) -> _CopyOutcome: + """Score une copie et prépare ses lignes JSONL (exécuté dans un thread worker). + + Aucune écriture fichier ni mutation d'état partagé ici : uniquement l'appel + modèle (thread-safe) et la trace Langfuse de la copie. Le résultat est + consommé dans le thread principal. + """ + # Une trace Langfuse par copie (no-op si Langfuse indisponible, trace vaut None). + with copy_trace(copy) as trace: + # Détection copie vierge AVANT tout appel modèle : formulaire non rempli + # (seul le pré-imprimé marque quelques % d'encre). Traitée identiquement + # pour toutes les méthodes → tous les items codés "0" (absent), sans appel + # modèle. Empêche l'end-to-end d'halluciner la référence sur une page vide. try: - prediction = scorer.score_copy(copy, reference) - except Exception as exc: # noqa: BLE001 — on veut TOUT rattraper ici - logger.error( - "Échec sur la copie %s : %s. On passe à la suivante.", - copy.copy_id, - exc, + is_blank = ( + blank_threshold > 0 and ink_ratio(load_image(copy.image_path)) < blank_threshold ) - failed_copies.append((copy.copy_id, str(exc))) - continue - - if not prediction.transcribed: - non_transcrites.append(copy.copy_id) - logger.warning( - "Copie non transcrite après %d tentative(s) : %s (exclue des métriques).", - prediction.n_attempts, - copy.copy_id, + except Exception as exc: # noqa: BLE001 — image illisible : on remonte un échec + if trace is not None: + trace.update(level="ERROR", status_message=str(exc)) + return _CopyOutcome(copy.copy_id, "failed", error=str(exc)) + + if is_blank: + prediction: CopyPrediction = CopyPrediction( + copy_id=copy.copy_id, + items=[ + ItemPrediction(item_id=i, code="0", confidence=1.0, transcription="") + for i in copy.item_ids + ], ) - continue + else: + try: + prediction = scorer.score_copy(copy, reference_text) + except Exception as exc: # noqa: BLE001 — on veut TOUT rattraper ici + if trace is not None: + trace.update(level="ERROR", status_message=str(exc)) + return _CopyOutcome(copy.copy_id, "failed", error=str(exc)) + + if not prediction.transcribed: + if trace is not None: + trace.update( + level="WARNING", + status_message="copie non transcrite", + output={"transcribed": False, "n_attempts": prediction.n_attempts}, + ) + return _CopyOutcome( + copy.copy_id, "non_transcribed", n_attempts=prediction.n_attempts + ) pred_by_id = {it.item_id: it for it in prediction.items} - - # Écriture incrémentale de chaque item de la copie + records = [] + n_accord = 0 for item_id, expert_code in zip(copy.item_ids, copy.expert_codes, strict=True): pred = pred_by_id.get(item_id) - true_code = grid.normalize(expert_code, scheme) - pred_code = grid.normalize(pred.code, scheme) if pred else "?" - conf = pred.confidence if pred else 0.0 - record = { - "copy_id": copy.copy_id, - "item_id": item_id, - "y_true": true_code, - "y_pred": pred_code, - "confidence": conf, - "transcription": pred.transcription if pred else None, - "comparaison": pred.comparaison if pred else None, - "raw_transcription": prediction.raw_transcription, - } - f_pred.write(json.dumps(record, ensure_ascii=False) + "\n") - - # Flush + fsync : les données sont sur le disque. Un crash après ce - # point ne peut plus perdre la copie qu'on vient d'écrire. - f_pred.flush() - os.fsync(f_pred.fileno()) - - # Sauvegarde de la liste des échecs (utile pour relancer sélectivement) + true_code = reference.normalize(expert_code, scheme) + pred_code = reference.normalize(pred.code, scheme) if pred else "?" + records.append( + { + "copy_id": copy.copy_id, + "item_id": item_id, + "y_true": true_code, + "y_pred": pred_code, + "confidence": pred.confidence if pred else 0.0, + "transcription": pred.transcription if pred else None, + "comparaison": pred.comparaison if pred else None, + "raw_transcription": prediction.raw_transcription, + "approach": config.approach, + "blank": is_blank, + } + ) + if pred_code == true_code: + n_accord += 1 + + if trace is not None and records: + accord_copie = n_accord / len(records) + trace.update( + output={ + "n_items": len(records), + "raw_agreement": accord_copie, + "blank": is_blank, + } + ) + trace.score_trace(name="raw_agreement", value=accord_copie) + + lines = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records) + return _CopyOutcome(copy.copy_id, "ok", lines=lines, blank=is_blank) + + def _consume(outcome: _CopyOutcome, f_pred: object) -> None: + """Écrit/comptabilise le résultat d'une copie (thread principal uniquement).""" + if outcome.status == "failed": + logger.error( + "Échec sur la copie %s : %s. On passe à la suivante.", + outcome.copy_id, + outcome.error, + ) + failed_copies.append((outcome.copy_id, outcome.error)) + elif outcome.status == "non_transcribed": + non_transcrites.append(outcome.copy_id) + logger.warning( + "Copie non transcrite après %d tentative(s) : %s (exclue des métriques).", + outcome.n_attempts, + outcome.copy_id, + ) + else: # "ok" + if outcome.blank: + blank_copies.append(outcome.copy_id) + f_pred.write(outcome.lines) # type: ignore[attr-defined] + # Flush + fsync : garantit que la copie écrite survit à un crash ultérieur. + f_pred.flush() # type: ignore[attr-defined] + os.fsync(f_pred.fileno()) # type: ignore[attr-defined] + + # Mode APPEND : conserve les copies déjà traitées lors d'une reprise. + with open(out_path, "a", encoding="utf-8") as f_pred: + desc = f"Évaluation ({config.name}, {workers} en parallèle)" + if workers <= 1: + # Chemin séquentiel (comportement historique), sans thread ni contexte copié. + for copy in tqdm(copies_a_traiter, desc=desc): + _consume(_score_one(copy), f_pred) + else: + # Chaque copie est soumise avec une COPIE du contexte courant, pour que la + # trace Langfuse hérite des attributs de session (propagate_attributes est + # actif dans le thread principal, pas dans les workers). + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = [ + executor.submit(contextvars.copy_context().run, _score_one, copy) + for copy in copies_a_traiter + ] + for future in tqdm(as_completed(futures), total=len(futures), desc=desc): + _consume(future.result(), f_pred) + if failed_copies: with open(failed_path, "w", encoding="utf-8") as f: for cid, msg in failed_copies: @@ -222,9 +304,7 @@ def run_benchmark( logger.info("Prédictions sauvegardées : %s", out_path) - # Recharger l'intégralité du JSONL (celles traitées maintenant + celles - # déjà présentes en reprise) pour construire les métriques et les vecteurs - # alignés attendus par BenchmarkResult. + # Recharger tout le JSONL (copies de ce run + reprises) pour construire les métriques. y_true: list[str] = [] y_pred: list[str] = [] confidences: list[float | None] = [] @@ -244,10 +324,9 @@ def run_benchmark( confidences.append(rec.get("confidence")) item_ids.append(rec["item_id"]) copy_ids.append(rec["copy_id"]) - # Garde-fou : modèle et experts doivent coder dans le MÊME jeu de modalités. - # Après normalisation, tout code hors de l'alphabet attendu signale une - # incohérence (ex. prétraitement expert oublié, prompt non aligné sur le schéma). - attendus = grid.allowed_codes(scheme) + # Garde-fou : après normalisation, un code hors de l'alphabet attendu signale une + # incohérence (prétraitement expert oublié, prompt non aligné sur le schéma). + attendus = reference.allowed_codes(scheme) codes_vus = set(y_true) | set(y_pred) intrus = codes_vus - attendus - {"?"} # "?" = réponse modèle non parsée, traité à part if intrus: @@ -266,6 +345,15 @@ def run_benchmark( non_transcrites, ) + if blank_copies: + logger.info( + "%d copie(s) vierge(s) détectée(s) (encre < %.1f%%) et auto-codée(s) " + '"0" (absent) sans appel modèle : %s', + len(blank_copies), + blank_threshold * 100, + blank_copies, + ) + metrics = compute_scoring_metrics(y_true, y_pred) return BenchmarkResult( metrics=metrics, @@ -276,4 +364,5 @@ def run_benchmark( copy_ids=copy_ids, predictions_path=out_path, non_transcribed=non_transcrites, + blank_copies=blank_copies, ) diff --git a/src/evaluation_dictee/pipeline/prompts.py b/src/evaluation_dictee/pipeline/prompts.py index fb7fb43..6a55b3d 100644 --- a/src/evaluation_dictee/pipeline/prompts.py +++ b/src/evaluation_dictee/pipeline/prompts.py @@ -1,20 +1,24 @@ -"""Construction des prompts d'évaluation. +"""Construction des prompts d'évaluation (décisions méthodologiques CLAUDE.md §3-4). -Les consignes encodent les décisions méthodologiques du projet (CLAUDE.md §3-4) : -grille simplifiée, règle des ratures (état final), consigne anti-sur-correction, -demande de confiance par item, et surtout ALIGNEMENT STRICT mot à mot. - -Point critique d'alignement : la grille découpe les élisions (n', S', l', d', qu') -comme des items SÉPARÉS du mot qui suit. Sans consigne explicite, le modèle recolle -ces unités (« n'étaient » au lieu de « n' » + « étaient »), ce qui décale tous les -items suivants et fait chuter l'accord. Le prompt fournit donc la correspondance -explicite item → mot attendu, dérivée de la grille. +Point critique : les élisions (n', S', l', d', qu') sont des items SÉPARÉS du mot suivant ; +sans consigne explicite le modèle les recolle et décale tous les items suivants. +Chaque prompt est un template chat versionné dans Langfuse, avec repli local si indisponible. """ from __future__ import annotations +from typing import cast + +from langfuse import get_client +from langfuse.model import ChatMessageDict, ChatPromptClient + from evaluation_dictee.config import PromptConfig -from evaluation_dictee.data.reference import GridItem +from evaluation_dictee.data.grid import GridItem + +# Noms sous lesquels les prompts sont versionnés dans Langfuse. +PROMPT_DICTATION = "Dictation" +PROMPT_TRANSCRIPTION = "Transcription" +PROMPT_TEXT_CODING = "Text coding" _GRILLE_SIMPLIFIEE = ( "Pour chaque item, attribue un code :\n" @@ -38,134 +42,353 @@ ) _CONSIGNE_ALIGNEMENT = ( - "RÈGLE D'ALIGNEMENT (la plus importante) : la liste ci-dessous définit des items " + "2 - RÈGLE D'ALIGNEMENT (la plus importante) : la liste de règles ci-dessous définit des items " "FIXES, un par ligne, dans l'ordre du texte. Tu dois rendre EXACTEMENT un code par " - "item, dans le même ordre, sans en fusionner ni en omettre.\n" - "Aligne-toi sur le MOT ATTENDU de chaque item, JAMAIS sur ta propre découpe de " + "item, dans le même ordre, sans en fusionner ni en omettre:\n" + "a - Aligne-toi sur le MOT ATTENDU de chaque item, JAMAIS sur ta propre découpe de " "l'écriture de l'élève. L'élève peut écrire un mot avec un espace au milieu " "(« re trouver » au lieu de « retrouver ») ou coller deux mots (« nousles » au " "lieu de « nous les ») : ne te laisse pas décaler. Dans ces cas, rattache ce que " "tu lis au mot attendu correspondant, et continue d'aligner les items suivants " "sur leurs mots attendus respectifs.\n" - "Les apostrophes d'élision sont des items SÉPARÉS du mot qui suit. Par exemple " + "b - Les apostrophes d'élision sont des items SÉPARÉS du mot qui suit. Par exemple " "« n'étaient » se code en DEUX items distincts : « n' » puis « étaient ». " "De même « S'ils » = « S' » puis « ils » ; « l'olivier » = « l' » puis « olivier ».\n" - "AVANT de coder, vérifie pour chaque item que la transcription que tu donnes " + "c - AVANT de coder, vérifie pour chaque item que la transcription que tu donnes " "correspond bien au mot attendu de CE numéro d'item ; si tu remarques un décalage, " "recale-toi immédiatement sur le mot attendu. Le nombre d'items de ta réponse doit " - "être EXACTEMENT celui demandé." + "être EXACTEMENT celui demandé.\n" ) _CONSIGNE_FIDELITE = ( - "Transcris EXACTEMENT ce que l'élève a écrit pour cet item, fautes comprises. " + "3 - Transcris EXACTEMENT ce que l'élève a écrit pour cet item, fautes comprises. " "Ne corrige jamais silencieusement l'orthographe : une faute non transcrite " - "fausse l'évaluation. Si le mot écrit diffère du mot attendu, c'est une erreur (9)." + "fausse l'évaluation. Si le mot écrit diffère du mot attendu, c'est une erreur (9).\n" ) _CONSIGNE_COMPARAISON = ( - "MÉTHODE DE CODAGE (à appliquer pour chaque item) : compare LETTRE À LETTRE ta " + "4 - MÉTHODE DE CODAGE (à appliquer pour chaque item) : compare LETTRE À LETTRE ta " "transcription au mot attendu. Le code est 1 SEULEMENT si les deux sont rigoureusement " - "identiques (mêmes lettres, mêmes accents, même terminaison). La MOINDRE différence " - "— une lettre, un accent, une terminaison de conjugaison (ex. « mis » au lieu de " - "« mit »), un singulier/pluriel — impose le code 9, même si le mot reste lisible et " + "identiques (mêmes lettres, mêmes accents, même terminaison). La MOINDRE différence, " + "une lettre, un accent, une terminaison de conjugaison (ex. « mis » au lieu de " + "« mit ») ou un singulier/pluriel, impose le code 9, même si le mot reste lisible et " "plausible. Ne te fie pas au sens : « mis » et « mit » se prononcent pareil mais " "l'un est faux. Code d'après la forme écrite exacte, pas d'après ce que l'élève " - "voulait dire." + "voulait dire.\n" ) _CONSIGNE_RATURES_HALLUCINATION = ( - "Quand un passage est raturé/barré : ignore complètement le texte barré et lis " + "5 - Quand un passage est raturé/barré : ignore complètement le texte barré et lis " "uniquement ce que l'élève a retenu en version finale, DANS L'ORDRE OÙ C'EST ÉCRIT " "sur la copie. N'invente pas, ne réordonne pas les mots pour qu'ils collent au texte " - "attendu : si l'élève a écrit les mots dans un certain ordre, transcris cet ordre réel." + "attendu : si l'élève a écrit les mots dans un certain ordre, transcris cet ordre réel.\n" ) _CONSIGNE_RATURES = ( - "Si l'élève a raturé puis réécrit un mot, lis uniquement la version FINALE " - "(corrigée par l'élève), pas la version barrée." + "6 - Si l'élève a raturé puis réécrit un mot, lis uniquement la version FINALE " + "(corrigée par l'élève), pas la version barrée.\n" ) _CONSIGNE_CONFIANCE = ( - "Pour chaque item, fournis un score de confiance entre 0 et 1 reflétant ta " - "certitude (lisibilité, ambiguïté). Un score bas déclenchera une relecture humaine." + "7 - Pour chaque item, fournis un score de confiance entre 0 et 1 reflétant ta " + "certitude (lisibilité, ambiguïté). Un score bas déclenchera une relecture humaine.\n" +) + +_CONSIGNE_COT = ( + "8 - AVANT de choisir le code, écris un champ « comparaison » qui décrit " + "explicitement en quoi la transcription diffère du mot attendu (ou " + "précise « identique » si elles correspondent lettre à lettre). " + "Exemple : attendu « inquiets » lu « inquiet » → « il manque le 's' final ».\n" +) + +# Format de sortie JSON de la méthode C, avec ou sans champ « comparaison » (CoT). +_FORMAT_ITEMS_COT = ( + "Réponds UNIQUEMENT par un objet JSON, sans texte autour ni de notes, de la forme :\n" + '{"items": [{"item_id": "...", "transcription": "ce que l\'élève a écrit", ' + '"comparaison": "identique" OU description brève de la différence, ' + '"code": "1", "confidence": 0.95, "reason": "les raisons du choix"}, ...]}.\n' + "Tout ajout de texte hors de la structure du JSON sera pris comme une erreur par le pipeline." +) +_FORMAT_ITEMS_SIMPLE = ( + "Réponds UNIQUEMENT par un objet JSON, sans texte autour ni de notes, de la forme :\n" + '{"items": [{"item_id": "...", "transcription": "ce que l\'élève a écrit", ' + '"code": "1", "confidence": 0.95, "reason": "les raisons du choix"}, ...]}.\n' + "Tout ajout de texte hors de la structure du JSON sera pris comme une erreur par le pipeline." +) + +# Consigne « ratures » propre à l'étape de transcription (formulation dédiée). +_CONSIGNE_RATURES_TRANSCRIPTION = ( + "Si l'élève a raturé puis réécrit, transcris uniquement la version " + "FINALE (non barrée). Ignore complètement le texte barré.\n" ) +# Consigne « ratures » propre à l'étape d'évaluation directe +_CONSIGNE_RATURES_DICTATION = ( + "Si l'élève a raturé puis réécrit, ignore complètement le texte barré dans ton output." + "Il s'agit d'une correction faite par l'élève, les mots raturés ne doivent pas être pris " + "en compte dans la correction." +) + + +# ───────────────────────────────────────────────────────────────────────────── +# Templates chat (masques versionnés dans Langfuse, {{...}} remplis par les build_*). +# ───────────────────────────────────────────────────────────────────────────── + +_TEMPLATE_DICTATION: list[ChatMessageDict] = [ + { + "role": "system", + "content": ( + "Tu fais partie d'un groupe d'expert composé de professeurs et de formateurs pour une " + "évaluation nationale de dictée.\n" + "Ta tâche : On te montre l'image manuscrite de la dictée d'un élève de primaire. " + "Tu dois noter chaque item de la dictée à l'aide des éléments définis " + "dans la grille de notation.\n\n" + "Règles à respecter impérativement :\n" + "1 - Tu dois noter chaque item de la dictée avec la grille de notation disponible : " + "{{grille}}\n" + + _CONSIGNE_ALIGNEMENT + + "{{consignes_optionnelles}}\n\n" + + _CONSIGNE_CONFIANCE + + "{{consigne_cot}}\n\n" + + _CONSIGNE_RATURES_DICTATION + ), + }, + { + "role": "user", + "content": ( + "# Texte de référence (ce que l'élève devait écrire) :\n" + "« {{reference_text}} »\n\n" + "# Items à coder, dans l'ordre. Chaque ligne = un item fixe " + "« identifiant → mot attendu » :\n" + "{{items_list}}\n\n" + "# Tu dois rendre EXACTEMENT {{n_items}} items, dans cet ordre.\n" + "{{format_sortie}}" + ), + }, +] + +_TEMPLATE_TRANSCRIPTION: list[ChatMessageDict] = [ + { + "role": "system", + "content": ( + "Tu es un expert en lecture d'écriture manuscrite d'enfants.\n" + "On te montre l'image manuscrite de la dictée d'un élève de primaire.\n\n" + "Règles à respecter impérativement :\n" + "1 - Transcris EXACTEMENT le texte écrit par l'élève, mot pour mot, " + "FAUTES D'ORTHOGRAPHE COMPRISES.\n " + "2 - Ne corrige rien, ne complète rien, " + "ne réordonne rien.\n " + " 3 - Reproduis fidèlement les erreurs, y compris les " + "accents manquants, les mots mal orthographiés et la ponctuation.\n" + "4 - Respecte l'ordre exact des items écrits sur la copie " + "(mots, ponctuation, chiffres, ...).\n" + "{{consigne_ratures}}" + ), + }, + { + "role": "user", + "content": ( + "Réponds UNIQUEMENT par un objet JSON, sans texte autour, de la forme :\n" + '{"transcription": "le texte exact écrit par l\'élève"}' + ), + }, +] + +_TEMPLATE_TEXT_CODING: list[ChatMessageDict] = [ + { + "role": "system", + "content": ( + "Tu fais partie d'un groupe d'expert, composé de professeur et de formateurs " + "pour une évaluation nationale de dictée.\n" + "Ta tâche : Tu ne vois PAS l'image, on te donne uniquement la transcription " + "de ce que l'élève a écrit (produite par un système de lecture), et le " + "texte de référence.\n" + "Compare la transcription au mot attendu de chaque item. Si un mot attendu " + "n'apparaît pas dans la transcription, code-le 0 (absent).\n\n" + "Règles à respecter impérativement :\n" + "1 - Tu dois noter chaque item de la dictée avec la grille de notation " + "disponible ci-dessous : " + "{{grille}}\n\n" + _CONSIGNE_ALIGNEMENT + "\n\n" + _CONSIGNE_COMPARAISON + "\n\n" + ), + }, + { + "role": "user", + "content": ( + "Texte de référence (ce que l'élève devait écrire) :\n" + "« {{reference_text}} »\n\n" + "Transcription de la copie de l'élève (fautes comprises) :\n" + "« {{transcription}} »\n\n" + "Items à coder, dans l'ordre. Chaque ligne = un item " + "« identifiant → mot attendu » :\n" + "{{items_list}}\n\n" + "Tu dois rendre EXACTEMENT {{n_items}} items, dans cet ordre.\n" + "Réponds UNIQUEMENT par un objet JSON, sans texte autour, de la forme :\n" + '{"items": [{"item_id": "...", "transcription": "mot lu pour cet item", ' + '"code": "1", "confidence": 0.95}, ...]}' + ), + }, +] + +# Registre exposé pour l'initialisation Langfuse (utils/add_langfuse_prompt.py). +PROMPT_TEMPLATES: dict[str, list[ChatMessageDict]] = { + PROMPT_DICTATION: _TEMPLATE_DICTATION, + PROMPT_TRANSCRIPTION: _TEMPLATE_TRANSCRIPTION, + PROMPT_TEXT_CODING: _TEMPLATE_TEXT_CODING, +} + + +def _format_items(items: list[GridItem]) -> str: + """Formate la liste des items « N. identifiant -> « mot » (nature) », un par ligne. + + Args: + items: items de la grille à formater. + + Returns: + Le texte des items numérotés, une ligne par item. + """ + lignes = [] + for idx, it in enumerate(items, 1): + nature = "ponctuation" if it.type == "ponctuation" else "mot" + lignes.append(f" {idx:>2}. {it.item_id} → « {it.attendu} » ({nature})") + return "\n".join(lignes) + + +def _render_local( + messages: list[ChatMessageDict], variables: dict[str, object] +) -> list[ChatMessageDict]: + """Compile un template localement (repli si Langfuse est indisponible). + + Args: + messages: messages du template, avec placeholders `{{clé}}`. + variables: valeurs à substituer aux placeholders. + + Returns: + Les messages avec les placeholders remplacés par leurs valeurs. + """ + rendered: list[ChatMessageDict] = [] + for message in messages: + content = message["content"] + for key, value in variables.items(): + content = content.replace("{{" + key + "}}", str(value)) + rendered.append({"role": message["role"], "content": content}) + return rendered + + +def _compile_prompt( + name: str, + fallback: list[ChatMessageDict], + variables: dict[str, object], +) -> list[ChatMessageDict]: + """Récupère et compile un prompt chat Langfuse (repli sur `fallback` local si indisponible). + + Args: + name: nom du prompt versionné dans Langfuse. + fallback: template local utilisé si Langfuse est indisponible. + variables: valeurs à injecter dans le template. + + Returns: + Les messages compilés (role/content uniquement, placeholders vides écartés). + """ + try: + prompt = get_client().get_prompt(name, type="chat", fallback=fallback) + messages = prompt.compile(**variables) + except Exception: # pragma: no cover - repli défensif si Langfuse indisponible + messages = _render_local(fallback, variables) + # compile() peut renvoyer des placeholders sans "content" ; on ne garde que role/content. + plain = cast("list[dict[str, object]]", messages) + compiled: list[ChatMessageDict] = [] + for message in plain: + if message.get("content"): + compiled.append({"role": str(message["role"]), "content": str(message["content"])}) + return compiled + + +def attach_image(messages: list[ChatMessageDict], image_data_url: str) -> list[dict[str, object]]: + """Attache une image au dernier message user, en contenu structuré (format multimodal vLLM). + + Args: + messages: messages du prompt ; le dernier doit être le message user. + image_data_url: image encodée en data URL à joindre. + + Returns: + Les messages avec l'image ajoutée au contenu du dernier message user. + """ + *head, user = messages + out: list[dict[str, object]] = [dict(message) for message in head] + out.append( + { + "role": user["role"], + "content": [ + {"type": "text", "text": user["content"]}, + {"type": "image_url", "image_url": {"url": image_data_url}}, + ], + } + ) + return out + + +def fetch_prompt(name: str) -> ChatPromptClient | None: + """Renvoie l'objet prompt Langfuse pour lier une trace à sa version (None si repli local). + + Args: + name: nom du prompt versionné dans Langfuse. + + Returns: + L'objet prompt Langfuse, ou None si Langfuse est indisponible ou renvoie le repli. + """ + try: + prompt = get_client().get_prompt(name, type="chat", fallback=PROMPT_TEMPLATES[name]) + except Exception: # pragma: no cover - repli défensif si Langfuse indisponible + return None + return None if prompt.is_fallback else prompt + def build_dictation_prompt( reference_text: str, items: list[GridItem], config: PromptConfig, scheme: str = "simplifiee", -) -> str: - """Construit le prompt d'évaluation d'une dictée (méthode C, end-to-end). +) -> list[ChatMessageDict]: + """Construit le prompt d'évaluation d'une dictée (méthode C) ; image jointe par l'appelant. Args: - reference_text: texte exact de la dictée attendue. - items: items de la grille (id + mot attendu + type), dans l'ordre du texte. - config: stratégie de prompting (fidélité, ratures, few-shot...). - scheme: "simplifiee" (codes 1/9/0) ou "complete" (codes 1/3/4/5/9/0). - Détermine les consignes de codage données au modèle, pour que celui-ci - code dans le MÊME jeu de modalités que les experts après normalisation. + reference_text: texte de référence de la dictée. + items: items de la grille à coder. + config: options de prompt (fidélité, ratures, chain-of-thought). + scheme: schéma de grille, "complete" ou "simplifiee" (défaut). Returns: - Le prompt complet à envoyer au modèle. + Les messages du prompt compilés, prêts à recevoir l'image. """ grille = _GRILLE_COMPLETE if scheme == "complete" else _GRILLE_SIMPLIFIEE - parts: list[str] = [ - "Tu es correcteur expert pour une évaluation nationale de dictée.", - "On te montre l'image manuscrite de la dictée d'un élève.", - "", - "Texte de référence (ce que l'élève devait écrire) :", - f"« {reference_text} »", - "", - grille, - "", - _CONSIGNE_ALIGNEMENT, - ] + blocs: list[str] = [] if config.enforce_faithful: - parts += ["", _CONSIGNE_FIDELITE, "", _CONSIGNE_COMPARAISON] + blocs += [_CONSIGNE_FIDELITE, _CONSIGNE_COMPARAISON] if config.read_final_state: - parts += ["", _CONSIGNE_RATURES, "", _CONSIGNE_RATURES_HALLUCINATION] - parts += ["", _CONSIGNE_CONFIANCE] - - # Correspondance explicite item -> mot/ponctuation attendu : c'est ce qui - # ancre l'alignement et empêche le modèle de recoller les élisions. - parts += [ - "", - "Items à coder, dans l'ordre. Chaque ligne = un item fixe « identifiant → mot attendu » :", - ] - for idx, it in enumerate(items, 1): - nature = "ponctuation" if it.type == "ponctuation" else "mot" - parts.append(f" {idx:>2}. {it.item_id} → « {it.attendu} » ({nature})") - - parts += [ - "", - f"Tu dois rendre EXACTEMENT {len(items)} items, dans cet ordre.", - ] + blocs += [_CONSIGNE_RATURES, _CONSIGNE_RATURES_HALLUCINATION] + consignes_optionnelles = ("\n\n" + "\n\n".join(blocs)) if blocs else "" if config.chain_of_thought: - # Le champ "comparaison" force le modèle à VERBALISER la différence - # lue-attendue avant de choisir le code, ce qui rend le raisonnement - # inspectable et réduit les erreurs bien lues mais mal codées. - parts += [ - "AVANT de choisir le code, écris un champ « comparaison » qui décrit " - "explicitement en quoi la transcription diffère du mot attendu (ou " - "précise « identique » si elles correspondent lettre à lettre). " - "Exemple : attendu « inquiets » lu « inquiet » → « il manque le 's' final ».", - "Réponds UNIQUEMENT par un objet JSON, sans texte autour, de la forme :", - '{"items": [{"item_id": "...", "transcription": "ce que l\'élève a écrit", ' - '"comparaison": "identique" OU description brève de la différence, ' - '"code": "1", "confidence": 0.95}, ...]}', - ] + consigne_cot = "\n\n" + _CONSIGNE_COT + format_sortie = _FORMAT_ITEMS_COT else: - parts += [ - "Réponds UNIQUEMENT par un objet JSON, sans texte autour, de la forme :", - '{"items": [{"item_id": "...", "transcription": "ce que l\'élève a écrit", ' - '"code": "1", "confidence": 0.95}, ...]}', - ] - - return "\n".join(parts) + consigne_cot = "" + format_sortie = _FORMAT_ITEMS_SIMPLE + + return _compile_prompt( + PROMPT_DICTATION, + fallback=_TEMPLATE_DICTATION, + variables={ + "grille": grille, + "consignes_optionnelles": consignes_optionnelles, + "consigne_cot": consigne_cot, + "reference_text": reference_text, + "items_list": _format_items(items), + "n_items": len(items), + "format_sortie": format_sortie, + }, + ) # ───────────────────────────────────────────────────────────────────────────── @@ -173,40 +396,21 @@ def build_dictation_prompt( # ───────────────────────────────────────────────────────────────────────────── -def build_transcription_prompt(read_final_state: bool = True) -> str: - """Prompt de l'ÉTAPE 1 (HTR) : transcrire fidèlement l'image, sans coder. - - Le modèle ne reçoit PAS le texte de référence : on veut une lecture brute, - non biaisée par ce qui était attendu, pour mesurer la transcription pour - elle-même. Il restitue exactement ce que l'élève a écrit, fautes comprises. +def build_transcription_prompt(read_final_state: bool = True) -> list[ChatMessageDict]: + """Prompt ÉTAPE 1 (HTR) : transcrire l'image sans coder ni voir la référence (non biaisé). Args: - read_final_state: si True, consigne de lire l'état final en cas de rature. + read_final_state: si True, ajoute la consigne de ne lire que l'état final (ratures). Returns: - Le prompt de transcription. + Les messages du prompt de transcription compilés. """ - parts = [ - "Tu es un expert en lecture d'écriture manuscrite d'enfants.", - "On te montre l'image manuscrite de la dictée d'un élève de primaire.", - "", - "Transcris EXACTEMENT le texte écrit par l'élève, mot pour mot, " - "FAUTES D'ORTHOGRAPHE COMPRISES. Ne corrige rien, ne complète rien, " - "ne réordonne rien. Reproduis fidèlement les erreurs, y compris les " - "accents manquants, les mots mal orthographiés et la ponctuation.", - "Respecte l'ordre exact d'écriture sur la copie.", - ] - if read_final_state: - parts.append( - "Si l'élève a raturé puis réécrit, transcris uniquement la version " - "FINALE (non barrée). Ignore complètement le texte barré." - ) - parts += [ - "", - "Réponds UNIQUEMENT par un objet JSON, sans texte autour, de la forme :", - '{"transcription": "le texte exact écrit par l\'élève"}', - ] - return "\n".join(parts) + consigne_ratures = ("\n" + _CONSIGNE_RATURES_TRANSCRIPTION) if read_final_state else "" + return _compile_prompt( + PROMPT_TRANSCRIPTION, + fallback=_TEMPLATE_TRANSCRIPTION, + variables={"consigne_ratures": consigne_ratures}, + ) def build_text_coding_prompt( @@ -214,54 +418,27 @@ def build_text_coding_prompt( transcription: str, items: list[GridItem], scheme: str = "simplifiee", -) -> str: - """Prompt de l'ÉTAPE 2 : coder à partir du TEXTE transcrit (sans image). - - Cette étape ne prend que du texte en entrée : la transcription produite à - l'étape 1 et le texte de référence. Elle peut donc être confiée à un modèle - purement textuel (panel plus large, moins coûteux). Le modèle aligne la - transcription sur les items attendus et attribue un code par item. +) -> list[ChatMessageDict]: + """Prompt ÉTAPE 2 : coder à partir du texte transcrit (sans image, modèle texte seul). Args: reference_text: texte de référence de la dictée. - transcription: transcription produite à l'étape 1. - items: items de la grille (mot attendu, dans l'ordre). - scheme: "simplifiee" ou "complete". + transcription: transcription de la copie produite à l'étape 1. + items: items de la grille à coder. + scheme: schéma de grille, "complete" ou "simplifiee" (défaut). Returns: - Le prompt de codage textuel. + Les messages du prompt de codage textuel compilés. """ grille = _GRILLE_COMPLETE if scheme == "complete" else _GRILLE_SIMPLIFIEE - parts = [ - "Tu es correcteur expert pour une évaluation nationale de dictée.", - "Tu ne vois PAS l'image : on te donne la transcription de ce que l'élève " - "a écrit (produite par un système de lecture), et le texte de référence.", - "", - "Texte de référence (ce que l'élève devait écrire) :", - f"« {reference_text} »", - "", - "Transcription de la copie de l'élève (fautes comprises) :", - f"« {transcription} »", - "", - grille, - "", - _CONSIGNE_ALIGNEMENT, - "", - _CONSIGNE_COMPARAISON, - "", - "Compare la transcription au mot attendu de chaque item. Si un mot attendu " - "n'apparaît pas dans la transcription, code-le 0 (absent).", - "", - "Items à coder, dans l'ordre. Chaque ligne = un item « identifiant → mot attendu » :", - ] - for idx, it in enumerate(items, 1): - nature = "ponctuation" if it.type == "ponctuation" else "mot" - parts.append(f" {idx:>2}. {it.item_id} → « {it.attendu} » ({nature})") - parts += [ - "", - f"Tu dois rendre EXACTEMENT {len(items)} items, dans cet ordre.", - "Réponds UNIQUEMENT par un objet JSON, sans texte autour, de la forme :", - '{"items": [{"item_id": "...", "transcription": "mot lu pour cet item", ' - '"code": "1", "confidence": 0.95}, ...]}', - ] - return "\n".join(parts) + return _compile_prompt( + PROMPT_TEXT_CODING, + fallback=_TEMPLATE_TEXT_CODING, + variables={ + "grille": grille, + "reference_text": reference_text, + "transcription": transcription, + "items_list": _format_items(items), + "n_items": len(items), + }, + ) diff --git a/src/evaluation_dictee/transcription/__init__.py b/src/evaluation_dictee/transcription/__init__.py index b2bb6f5..9cedf7b 100644 --- a/src/evaluation_dictee/transcription/__init__.py +++ b/src/evaluation_dictee/transcription/__init__.py @@ -1 +1 @@ -"""Transcription HTR : chargement Scoledit, métriques CER/WER, benchmark de lecture.""" \ No newline at end of file +"""Transcription HTR : chargement Scoledit, métriques CER/WER, benchmark de lecture.""" diff --git a/src/evaluation_dictee/transcription/htr_benchmark.py b/src/evaluation_dictee/transcription/htr_benchmark.py index 701376b..a021974 100644 --- a/src/evaluation_dictee/transcription/htr_benchmark.py +++ b/src/evaluation_dictee/transcription/htr_benchmark.py @@ -1,11 +1,7 @@ -"""Pipeline d'évaluation de la TRANSCRIPTION (HTR) sur le corpus Scoledit. +"""Pipeline d'évaluation de la TRANSCRIPTION (HTR) sur le corpus Scoledit (CER/WER). -Objectif distinct du codage de dictée : mesurer, isolément, à quel point un modèle -lit fidèlement l'écriture manuscrite d'un enfant (fautes comprises). On compare la -transcription du modèle à la transcription de référence Scoledit via CER/WER. - -Architecture volontairement modulaire pour changer de modèle facilement : le -transcripteur encapsule l'appel VLM et n'expose que `transcribe(image_path) -> str`. +Le transcripteur encapsule l'appel VLM et n'expose que `transcribe(image_path) -> str`, +pour changer de modèle facilement. """ from __future__ import annotations @@ -13,17 +9,20 @@ import base64 import io import json +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path +from typing import cast import pandas as pd from openai import OpenAI +from openai.types.chat import ChatCompletionMessageParam from PIL import Image from tqdm import tqdm from evaluation_dictee.config import ModelConfig from evaluation_dictee.data.loaders import load_image -from evaluation_dictee.pipeline.prompts import build_transcription_prompt +from evaluation_dictee.pipeline.prompts import attach_image, build_transcription_prompt from evaluation_dictee.transcription.htr_metrics import ( TranscriptionMetrics, compute_transcription_metrics, @@ -32,18 +31,21 @@ def _image_to_data_url(image: Image.Image) -> str: - """Encode une image PIL en data URL base64.""" + """Encode une image PIL en data URL base64. + + Args: + image: Image PIL à encoder. + + Returns: + Data URL PNG encodée en base64. + """ buffer = io.BytesIO() image.save(buffer, format="PNG") return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("utf-8") class VLMTranscriber: - """Transcripteur HTR basé sur un VLM (API compatible OpenAI). - - Changer de modèle = changer `model_config.name`. Toute l'évaluation aval - (CER/WER) est indépendante du modèle choisi. - """ + """Transcripteur HTR basé sur un VLM (API compatible OpenAI).""" def __init__( self, @@ -52,37 +54,32 @@ def __init__( api_key: str, read_final_state: bool = True, ) -> None: - """Initialise le client de transcription. + """Initialise le client de transcription (read_final_state : état final si rature). Args: - model_config: modèle multimodal à utiliser. - base_url: URL de l'API. - api_key: clé d'API. - read_final_state: consigne de lire l'état final en cas de rature. + model_config: Configuration du modèle VLM (nom, température, retries...). + base_url: URL de base de l'API compatible OpenAI. + api_key: Clé d'API. + read_final_state: Si True, lire l'état final (version corrigée) en cas de rature. """ self.model_config = model_config self.client = OpenAI(base_url=base_url, api_key=api_key) - self.prompt = build_transcription_prompt(read_final_state=read_final_state) + self.prompt_messages = build_transcription_prompt(read_final_state=read_final_state) def transcribe(self, image_path: str) -> str: - """Transcrit une image manuscrite en texte brut (fautes préservées). + """Transcrit une image manuscrite en texte brut, fautes préservées (vide si échec). Args: - image_path: chemin de l'image (local ou s3://). + image_path: Chemin de l'image à transcrire. Returns: - La transcription produite par le modèle (chaîne vide si échec). + Transcription du texte, ou chaîne vide si tous les essais échouent. """ image = load_image(image_path) - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": self.prompt}, - {"type": "image_url", "image_url": {"url": _image_to_data_url(image)}}, - ], - } - ] + messages = cast( + "list[ChatCompletionMessageParam]", + attach_image(self.prompt_messages, _image_to_data_url(image)), + ) for attempt in range(self.model_config.max_retries + 1): temp = self.model_config.temperature + (0.3 if attempt > 0 else 0.0) response = self.client.chat.completions.create( @@ -99,7 +96,14 @@ def transcribe(self, image_path: str) -> str: @staticmethod def _parse(content: str) -> str: - """Extrait le champ 'transcription' du JSON, avec repli sur texte brut.""" + """Extrait le champ 'transcription' du JSON, avec repli sur texte brut. + + Args: + content: Réponse brute du modèle (JSON éventuellement en bloc markdown). + + Returns: + Transcription extraite, ou le contenu brut si le JSON est invalide. + """ try: cleaned = content.strip().removeprefix("```json").removeprefix("```") cleaned = cleaned.removesuffix("```").strip() @@ -110,14 +114,7 @@ def _parse(content: str) -> str: @dataclass class HTRBenchmarkResult: - """Résultat d'un benchmark de transcription. - - Attributes: - per_sample: DataFrame par échantillon (scan, cer, wer, ...). - mean_cer / mean_wer : moyennes (micro-pondérées par longueur). - n_echecs: nombre d'images sans transcription exploitable. - predictions_path: chemin du fichier de prédictions sauvegardé. - """ + """Résultat d'un benchmark de transcription.""" per_sample: pd.DataFrame mean_cer: float @@ -132,43 +129,62 @@ def run_htr_benchmark( transcriber: VLMTranscriber, run_name: str, output_dir: str | Path = "data/processed", + concurrency: int = 8, ) -> HTRBenchmarkResult: """Transcrit tous les échantillons et calcule les métriques HTR. + Les échantillons sont transcrits en parallèle (`concurrency` requêtes vLLM + concurrentes), mais les enregistrements sont ré-ordonnés dans l'ordre de + `samples` : la sortie est identique au mode séquentiel (transcription + déterministe à température 0). `concurrency=1` = ancien comportement. + Args: - samples: échantillons Scoledit (image + référence). - transcriber: transcripteur (encapsule le modèle choisi). - run_name: nom du run (pour le fichier de sortie). - output_dir: dossier de sauvegarde des prédictions. + samples: Échantillons Scoledit à transcrire. + transcriber: Transcripteur VLM à utiliser (thread-safe). + run_name: Nom du run (préfixe du fichier de prédictions JSONL). + output_dir: Dossier de sortie des prédictions. + concurrency: nombre de transcriptions menées en parallèle. Returns: - Les résultats agrégés et par échantillon. + Résultat du benchmark : prédictions par échantillon, CER/WER moyens + (micro-pondérés par la longueur de la référence) et scans en échec. """ - records = [] - failed = [] - for s in tqdm(samples, desc=f"Transcription ({run_name})"): - hypothese = transcriber.transcribe(s.image_path) - if not hypothese.strip(): - failed.append(s.scan) - m: TranscriptionMetrics = compute_transcription_metrics(s.reference, hypothese) - records.append( - { - "scan": s.scan, - "level": s.level, - "reference": s.reference, - "hypothese": hypothese, - "cer": m.cer, - "wer": m.wer, - "cer_normalise": m.cer_normalise, - "wer_normalise": m.wer_normalise, - "n_char_ref": m.n_char_ref, - "n_mots_ref": m.n_mots_ref, - "transcrit": bool(hypothese.strip()), - } - ) + + def _transcribe_one(sample: ScoledtSample) -> dict: + """Transcrit un échantillon et construit son enregistrement (thread worker).""" + hypothese = transcriber.transcribe(sample.image_path) + m: TranscriptionMetrics = compute_transcription_metrics(sample.reference, hypothese) + return { + "scan": sample.scan, + "level": sample.level, + "reference": sample.reference, + "hypothese": hypothese, + "cer": m.cer, + "wer": m.wer, + "cer_normalise": m.cer_normalise, + "wer_normalise": m.wer_normalise, + "n_char_ref": m.n_char_ref, + "n_mots_ref": m.n_mots_ref, + "transcrit": bool(hypothese.strip()), + } + + desc = f"Transcription ({run_name}, {concurrency} en parallèle)" + if concurrency <= 1: + records = [_transcribe_one(s) for s in tqdm(samples, desc=desc)] + else: + # Résultats stockés par indice d'origine → ordre de `samples` préservé, + # quel que soit l'ordre d'achèvement des workers. + records = [None] * len(samples) # type: ignore[list-item] + with ThreadPoolExecutor(max_workers=concurrency) as executor: + index_of = {executor.submit(_transcribe_one, s): i for i, s in enumerate(samples)} + for future in tqdm(as_completed(index_of), total=len(samples), desc=desc): + records[index_of[future]] = future.result() + + # Échecs (transcription vide), dans l'ordre des échantillons. + failed = [r["scan"] for r in records if not r["transcrit"]] df = pd.DataFrame(records) - # CER/WER micro-pondérés par la longueur de la référence (échantillons transcrits) + # CER/WER micro-pondérés par la longueur de la référence ok = df[df["transcrit"]] mean_cer = ( float((ok["cer"] * ok["n_char_ref"]).sum() / ok["n_char_ref"].sum()) diff --git a/src/evaluation_dictee/transcription/htr_metrics.py b/src/evaluation_dictee/transcription/htr_metrics.py index edd1251..e9d9056 100644 --- a/src/evaluation_dictee/transcription/htr_metrics.py +++ b/src/evaluation_dictee/transcription/htr_metrics.py @@ -1,17 +1,7 @@ -"""Métriques d'évaluation de la transcription (HTR). +"""Métriques HTR (CER, WER) : fidélité de LECTURE, pas correction orthographique. -Compare une transcription produite par le modèle à une transcription de référence -humaine. Les deux préservent les fautes de l'élève : on mesure la fidélité de -LECTURE, pas la correction orthographique. - -Métriques principales : -- **CER** (Character Error Rate) : distance d'édition au niveau caractère, - normalisée par la longueur de la référence. 0 = transcription parfaite. -- **WER** (Word Error Rate) : idem au niveau mot. - -On fournit aussi une variante « normalisée » (minuscules, sans accents ni -ponctuation) pour distinguer les erreurs de lecture substantielles des simples -différences de casse/accents. +Une variante « normalisée » (minuscules, sans accents ni ponctuation) isole les +erreurs de lecture substantielles des simples différences de casse/accents. """ from __future__ import annotations @@ -21,16 +11,14 @@ def _levenshtein(a: list[str] | str, b: list[str] | str) -> int: - """Distance d'édition (insertions + suppressions + substitutions) entre a et b. - - Fonctionne sur des chaînes (niveau caractère) ou des listes (niveau mot). + """Distance d'édition entre a et b (chaînes = niveau caractère, listes = niveau mot). Args: - a: séquence de référence. - b: séquence hypothèse. + a: Première séquence (chaîne ou liste de mots). + b: Seconde séquence (chaîne ou liste de mots). Returns: - Le nombre minimal d'opérations pour transformer a en b. + Nombre minimal d'insertions, suppressions et substitutions. """ n, m = len(a), len(b) if n == 0: @@ -48,7 +36,14 @@ def _levenshtein(a: list[str] | str, b: list[str] | str) -> int: def _normalize_text(s: str) -> str: - """Minuscule, sans accents, sans ponctuation (pour la variante normalisée).""" + """Minuscule, sans accents, sans ponctuation (pour la variante normalisée). + + Args: + s: Texte à normaliser. + + Returns: + Texte en minuscules, sans accents ni ponctuation, espaces réduits. + """ s = unicodedata.normalize("NFKD", s) s = "".join(c for c in s if not unicodedata.combining(c)) s = "".join(c if c.isalnum() or c.isspace() else " " for c in s.lower()) @@ -57,16 +52,7 @@ def _normalize_text(s: str) -> str: @dataclass class TranscriptionMetrics: - """Métriques de transcription pour un échantillon ou un corpus. - - Attributes: - cer: Character Error Rate (0 = parfait). - wer: Word Error Rate. - cer_normalise: CER après normalisation (casse/accents/ponctuation ignorés). - wer_normalise: WER après normalisation. - n_char_ref: nombre de caractères de la référence. - n_mots_ref: nombre de mots de la référence. - """ + """Métriques de transcription pour un échantillon ou un corpus.""" cer: float wer: float @@ -80,11 +66,11 @@ def compute_transcription_metrics(reference: str, hypothesis: str) -> Transcript """Calcule CER et WER (bruts et normalisés) entre référence et hypothèse. Args: - reference: transcription de référence (humaine). - hypothesis: transcription produite par le modèle. + reference: Transcription de référence. + hypothesis: Transcription produite par le modèle. Returns: - Les métriques de transcription. + Métriques CER/WER bruts et normalisés, et longueurs de la référence. """ ref_c, hyp_c = reference, hypothesis ref_w, hyp_w = reference.split(), hypothesis.split() diff --git a/src/evaluation_dictee/transcription/scoledit.py b/src/evaluation_dictee/transcription/scoledit.py index 96bafb0..83460aa 100644 --- a/src/evaluation_dictee/transcription/scoledit.py +++ b/src/evaluation_dictee/transcription/scoledit.py @@ -1,15 +1,7 @@ -"""Chargement du corpus Scoledit pour l'évaluation de la transcription (HTR). - -Structure des données (sur S3) : - scans//.jpg image de la copie (ex. 100a.jpg) - annotation//.json transcription de référence, ex. : - {"student_id": 100, "level": "CE1", "scan": "100a", - "tei": "

il était tune fois un chat pérsent...

"} - -La transcription de référence est en TEI léger : balise

englobante et -pour les retours à la ligne. On la nettoie en texte brut (les deviennent des -espaces) tout en PRÉSERVANT les fautes d'orthographe de l'élève, qui sont l'objet -même de l'évaluation. +"""Chargement du corpus Scoledit (HTR) : appariement scans + annotations TEI. + +La référence TEI est nettoyée en texte brut en PRÉSERVANT les fautes de l'élève, +qui sont l'objet même de l'évaluation. """ from __future__ import annotations @@ -23,16 +15,7 @@ @dataclass class ScoledtSample: - """Un échantillon Scoledit : image + transcription de référence. - - Attributes: - scan: identifiant du scan (ex. "100a"). - level: niveau scolaire (ex. "CE1"). - student_id: identifiant de l'élève. - image_path: chemin de l'image (local ou s3://). - reference: transcription de référence en texte brut (fautes préservées). - tei_raw: transcription TEI brute (pour référence/débogage). - """ + """Un échantillon Scoledit : image + transcription de référence.""" scan: str level: str @@ -43,28 +26,30 @@ class ScoledtSample: def tei_to_text(tei: str) -> str: - """Convertit une transcription TEI légère Scoledit en texte brut. - - Règles : - - (saut de ligne) → espace ; - - balises englobantes

...

retirées ; - - toute autre balise retirée ; - - espaces multiples réduits, texte découpé/recollé proprement. - Les fautes d'orthographe de l'élève sont conservées telles quelles. + """Convertit une transcription TEI légère Scoledit en texte brut (fautes préservées). Args: - tei: chaîne TEI (ex. "

il était tune fois un chat

"). + tei: Transcription au format TEI léger. Returns: - Le texte brut correspondant. + Texte brut, balises retirées et espaces normalisés. """ txt = tei.replace("", " ") - txt = re.sub(r"<[^>]+>", " ", txt) # retire toute balise restante - txt = re.sub(r"\s+", " ", txt) # espaces multiples → un seul + txt = re.sub(r"<[^>]+>", " ", txt) + txt = re.sub(r"\s+", " ", txt) return txt.strip() def _join(base: str, name: str) -> str: + """Concatène un chemin de base et un nom avec un unique séparateur. + + Args: + base: Chemin de base. + name: Nom à ajouter. + + Returns: + Chemin joint. + """ return base.rstrip("/") + "/" + name @@ -76,12 +61,12 @@ def load_scoledit_dataset( """Charge les échantillons Scoledit en appariant scans et annotations. Args: - scans_dir: dossier des images (local ou s3://.../scans/CE1/). - annotations_dir: dossier des JSON (local ou s3://.../annotation/CE1/). - limit: nombre maximal d'échantillons (None = tous). + scans_dir: Dossier des images numérisées. + annotations_dir: Dossier des annotations JSON (champs scan, level, tei...). + limit: Nombre maximal d'échantillons à charger (tous si None). Returns: - Liste de ScoledtSample, ordonnée par nom de scan. + Liste des échantillons appariés. """ fs, _, paths = fsspec.get_fs_token_paths(annotations_dir.rstrip("/") + "/*") json_files = sorted(p for p in fs.glob(annotations_dir.rstrip("/") + "/*.json")) @@ -108,7 +93,15 @@ def load_scoledit_dataset( def _scheme_prefix(reference_dir: str, path: str) -> str: - """Restaure le préfixe s3:// perdu par fs.glob quand la source est S3.""" + """Restaure le préfixe s3:// perdu par fs.glob quand la source est S3. + + Args: + reference_dir: Dossier de référence indiquant le schéma d'origine. + path: Chemin retourné par glob, éventuellement sans préfixe. + + Returns: + Chemin préfixé de s3:// si nécessaire, sinon inchangé. + """ if reference_dir.startswith("s3://") and not path.startswith("s3://"): return "s3://" + path return path diff --git a/src/evaluation_dictee/transcription/visual_diff.py b/src/evaluation_dictee/transcription/visual_diff.py index ec1bc33..86d0d46 100644 --- a/src/evaluation_dictee/transcription/visual_diff.py +++ b/src/evaluation_dictee/transcription/visual_diff.py @@ -1,15 +1,7 @@ -"""Mini-interface HTML pour inspecter visuellement les transcriptions HTR. +"""HTML autonome (image base64) comparant, par copie Scoledit, référence et transcription modèle. -Pour chaque copie Scoledit sélectionnée (les N pires, ou N aléatoires), affiche -côte à côte : l'image de la copie, la transcription de référence humaine et la -transcription produite par le modèle, avec les mots divergents surlignés. - -Fichier HTML autonome (image en base64), à ouvrir dans le navigateur. Idéal pour -le diagnostic rapide des erreurs de lecture. - -ATTENTION DONNÉES SENSIBLES : le HTML contient des images d'élèves mineurs. À ne -jamais sortir de l'environnement sécurisé, à ne jamais committer (dossier data/ -ignoré par Git). +DONNÉES SENSIBLES : le HTML contient des images d'élèves mineurs. À ne jamais +sortir de l'environnement sécurisé ni committer. """ from __future__ import annotations @@ -27,7 +19,15 @@ def _img_base64(path: str, max_width: int = 1100) -> str: - """Charge l'image, la redimensionne si besoin, renvoie une data URL PNG.""" + """Charge l'image, la redimensionne si besoin, renvoie une data URL PNG. + + Args: + path: Chemin de l'image à charger. + max_width: Largeur maximale en pixels ; l'image est réduite au-delà. + + Returns: + Data URL PNG encodée en base64. + """ img: Image.Image = load_image(path) if img.width > max_width: ratio = max_width / img.width @@ -38,7 +38,16 @@ def _img_base64(path: str, max_width: int = 1100) -> str: def _surligne_diff(ref_mots: list[str], hyp_mots: list[str]) -> tuple[str, str]: - """Renvoie les deux séquences HTML avec surlignage des divergences.""" + """Renvoie les deux séquences HTML avec surlignage des divergences. + + Args: + ref_mots: Mots de la référence. + hyp_mots: Mots de l'hypothèse (transcription du modèle). + + Returns: + Couple (HTML référence, HTML hypothèse), divergences surlignées en vert + côté référence et en rouge côté hypothèse. + """ sm = difflib.SequenceMatcher(a=ref_mots, b=hyp_mots) diff_ref, diff_hyp = set(), set() for tag, i1, i2, j1, j2 in sm.get_opcodes(): @@ -47,6 +56,16 @@ def _surligne_diff(ref_mots: list[str], hyp_mots: list[str]) -> tuple[str, str]: diff_hyp.update(range(j1, j2)) def _render(mots: list[str], surlignes: set[int], couleur: str) -> str: + """Rend une liste de mots en spans HTML, surlignant les indices donnés. + + Args: + mots: Mots à rendre. + surlignes: Indices des mots à surligner. + couleur: Couleur de fond des mots surlignés. + + Returns: + Fragment HTML concaténé. + """ out = [] for i, mot in enumerate(mots): bg = couleur if i in surlignes else "transparent" @@ -60,7 +79,16 @@ def _render(mots: list[str], surlignes: set[int], couleur: str) -> str: def _build_sample_html(scan: str, image_path: str, row: pd.Series) -> str: - """Construit le HTML d'un échantillon Scoledit.""" + """Construit le HTML d'un échantillon Scoledit. + + Args: + scan: Identifiant du scan (copie). + image_path: Chemin de l'image numérisée. + row: Ligne de prédictions (référence, hypothese, cer, wer, n_mots_ref). + + Returns: + Fragment HTML
de l'échantillon. + """ img_data = _img_base64(image_path) ref_mots = row["reference"].split() hyp_mots = row["hypothese"].split() if row["hypothese"] else [] @@ -110,6 +138,15 @@ def _build_sample_html(scan: str, image_path: str, row: pd.Series) -> str: def _wrap_page(fragments: list[str], title: str) -> str: + """Assemble les fragments d'échantillons en une page HTML complète. + + Args: + fragments: Fragments HTML des échantillons. + title: Titre de la page. + + Returns: + Document HTML complet. + """ return f""" {html.escape(title)} @@ -132,13 +169,13 @@ def report_worst_transcriptions( """Génère le HTML des N transcriptions au plus fort CER. Args: - predictions_df: prédictions HTR (scan, reference, hypothese, cer, wer, ...). - scans_dir: dossier des images (local ou s3://). - output_path: chemin du fichier HTML à écrire. - n: nombre d'échantillons (les pires) à inclure. + predictions_df: Prédictions par échantillon (colonnes scan, cer, ...). + scans_dir: Dossier contenant les images numérisées. + output_path: Chemin du fichier HTML à écrire. + n: Nombre de transcriptions à inclure. Returns: - Le chemin du fichier HTML produit. + Chemin du fichier HTML écrit. """ pires = predictions_df.sort_values("cer", ascending=False).head(n) fragments = [ @@ -161,20 +198,17 @@ def report_random_transcriptions( n: int = 10, seed: int = 42, ) -> Path: - """Génère le HTML de N transcriptions tirées au hasard. - - Utile pour éviter de ne regarder que les cas pathologiques : les tirages - aléatoires donnent une vue plus représentative de la performance moyenne. + """Génère le HTML de N transcriptions tirées au hasard (plus représentatif que les pires). Args: - predictions_df: prédictions HTR. - scans_dir: dossier des images. - output_path: chemin du fichier HTML. - n: nombre d'échantillons aléatoires. - seed: graine aléatoire pour la reproductibilité. + predictions_df: Prédictions par échantillon (colonnes scan, cer, ...). + scans_dir: Dossier contenant les images numérisées. + output_path: Chemin du fichier HTML à écrire. + n: Nombre de transcriptions souhaité (borné par la taille du corpus). + seed: Graine aléatoire de l'échantillonnage. Returns: - Le chemin du fichier HTML produit. + Chemin du fichier HTML écrit. """ n_effectif = min(n, len(predictions_df)) echantillon = predictions_df.sample(n=n_effectif, random_state=seed) diff --git a/src/evaluation_dictee/utils/add_langfuse_models.py b/src/evaluation_dictee/utils/add_langfuse_models.py new file mode 100644 index 0000000..7b161de --- /dev/null +++ b/src/evaluation_dictee/utils/add_langfuse_models.py @@ -0,0 +1,100 @@ +"""Enregistre dans Langfuse un prix par token THÉORIQUE (GPU amorti) pour les modèles llm.lab. + +Modèles auto-hébergés sans tarif commercial, afin que Langfuse valorise chaque trace. +Toutes les constantes de coût ci-dessous sont des ordres de grandeur à AJUSTER +après mesure du débit réel. Montants raisonnés en euros (l'UI Langfuse affiche « $ »). + +Usage : + uv run --env-file .env add-langfuse-models +""" + +from __future__ import annotations + +import os +import re + +import httpx + +from evaluation_dictee.utils.logging import get_logger + +logger = get_logger(__name__) + +# ── Hypothèses de coût GPU amorti (À AJUSTER) ──────────────────────────────── +COUT_GPU_EUR_H = 2.5 # coût horaire d'une H100 (référence cloud public) +RATIO_ENTREE_SORTIE = 0.25 # un token d'entrée coûte ~1/4 d'un token de sortie + +# Débit de SORTIE agrégé sous charge (tokens/s), par modèle. À mesurer sur llm.lab. +DEBIT_SORTIE_TOK_S: dict[str, float] = { + "gemma4-26b-moe": 2500, # MoE ~26B total + "qwen3.6-35b-moe": 4000, # MoE 35B total / ~3B actifs → plus rapide +} + + +def _prix_par_token(debit_tok_s: float) -> tuple[float, float]: + """Renvoie (prix_entrée, prix_sortie) en €/token pour un débit donné. + + Args: + debit_tok_s: Débit de sortie agrégé du modèle, en tokens/seconde. + + Returns: + Le couple (prix d'entrée, prix de sortie) en €/token, l'entrée valant + `RATIO_ENTREE_SORTIE` fois la sortie. + """ + # Entrée ≈ 1/4 de la sortie : le prefill est plus rapide que le decode. + prix_sortie = COUT_GPU_EUR_H / (debit_tok_s * 3600) + prix_entree = prix_sortie * RATIO_ENTREE_SORTIE + return prix_entree, prix_sortie + + +def _auth_and_base() -> tuple[tuple[str, str], str]: + """Récupère (auth basic, base_url) depuis l'environnement. + + Returns: + Le couple ((clé publique, clé secrète), base_url sans slash final), lu + depuis `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` et `LANGFUSE_BASE_URL`. + + Raises: + RuntimeError: Si l'une des trois variables d'environnement est absente. + """ + public = os.environ.get("LANGFUSE_PUBLIC_KEY") + secret = os.environ.get("LANGFUSE_SECRET_KEY") + base = os.environ.get("LANGFUSE_BASE_URL") + if not (public and secret and base): + raise RuntimeError( + "LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY et LANGFUSE_BASE_URL doivent " + "être définis. Lance : uv run --env-file .env add-langfuse-models" + ) + return (public, secret), base.rstrip("/") + + +def push_models() -> None: + """Enregistre une définition de prix Langfuse pour chaque modèle. + + Relancer le script crée une nouvelle définition (la plus récente s'applique) : + ajuster les constantes puis relancer suffit à mettre à jour les prix. + """ + auth, base = _auth_and_base() + url = f"{base}/api/public/models" + + for model_name, debit in DEBIT_SORTIE_TOK_S.items(): + prix_entree, prix_sortie = _prix_par_token(debit) + payload = { + "modelName": model_name, + # Ancrage strict sur le nom exact (échappé), insensible à la casse. + "matchPattern": f"(?i)^{re.escape(model_name)}$", + "unit": "TOKENS", + "inputPrice": prix_entree, + "outputPrice": prix_sortie, + } + resp = httpx.post(url, auth=auth, json=payload, timeout=30) + resp.raise_for_status() + logger.info( + "Modèle « %s » enregistré : entrée %.3g €/1M, sortie %.3g €/1M", + model_name, + prix_entree * 1e6, + prix_sortie * 1e6, + ) + + +if __name__ == "__main__": + push_models() diff --git a/src/evaluation_dictee/utils/add_langfuse_prompt.py b/src/evaluation_dictee/utils/add_langfuse_prompt.py new file mode 100644 index 0000000..cc4a9a6 --- /dev/null +++ b/src/evaluation_dictee/utils/add_langfuse_prompt.py @@ -0,0 +1,44 @@ +"""Pousse les templates chat de `pipeline/prompts.py` vers Langfuse. + +Nouvelle version promue en production à chaque appel. +La logique conditionnelle (schéma, flags) reste dans le code : un seul prompt +versionné par fonction, pas une version par combinaison de flags. + +Usage : + uv run add-langfuse-prompt +""" + +from __future__ import annotations + +from langfuse import get_client + +from evaluation_dictee.pipeline.prompts import PROMPT_TEMPLATES +from evaluation_dictee.utils.logging import get_logger + +logger = get_logger(__name__) + + +def push_prompts() -> None: + """Crée/met à jour chaque template dans Langfuse et le promeut en production.""" + langfuse = get_client() + + for name, messages in PROMPT_TEMPLATES.items(): + prompt = langfuse.create_prompt( + name=name, + type="chat", + prompt=list(messages), + labels=["production"], + ) + logger.info( + "Prompt « %s » poussé (version %s), labels : %s", + prompt.name, + prompt.version, + prompt.labels, + ) + + # Flush explicite : Langfuse envoie en asynchrone, sinon les créations sont perdues. + langfuse.flush() + + +if __name__ == "__main__": + push_prompts() diff --git a/src/evaluation_dictee/utils/logging.py b/src/evaluation_dictee/utils/logging.py index f3911d6..21291c9 100644 --- a/src/evaluation_dictee/utils/logging.py +++ b/src/evaluation_dictee/utils/logging.py @@ -10,11 +10,14 @@ def get_logger(name: str) -> logging.Logger: """Renvoie un logger configuré avec un affichage enrichi (rich). + Le handler rich (niveau INFO, tracebacks enrichis) n'est ajouté qu'une seule + fois : un logger déjà pourvu de handlers est renvoyé tel quel. + Args: - name: nom du logger (typiquement __name__). + name: Nom du logger (typiquement `__name__` du module appelant). Returns: - Le logger prêt à l'emploi. + Le logger correspondant, muni d'un `RichHandler` au premier appel. """ logger = logging.getLogger(name) if not logger.handlers: diff --git a/src/evaluation_dictee/utils/s3_export.py b/src/evaluation_dictee/utils/s3_export.py new file mode 100644 index 0000000..73ca030 --- /dev/null +++ b/src/evaluation_dictee/utils/s3_export.py @@ -0,0 +1,87 @@ +"""Export des prédictions vers S3 (répertoire `predictions/` du bucket projet). + +Le pipeline écrit les prédictions en local — `data/processed/_predictions.jsonl` — +en mode append + fsync par copie, pour la reprise sur crash. Ce mode est propre au +disque local : S3 ne supporte ni l'append ni le fsync. On sépare donc l'écriture +(locale, incrémentale) de l'export (S3, une fois le run terminé). + +But : pouvoir relancer les notebooks et le site Quarto sans réexécuter le pipeline. +Rien n'est jamais commité dans Git — les transcriptions sont des données d'élèves +mineurs, hébergées sur le SSP Cloud. + +L'accès S3 suit le même pattern que `data/loaders.py` : `fsspec` lit les identifiants +et l'endpoint MinIO depuis l'environnement (variables injectées par Onyxia). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import fsspec + +from evaluation_dictee.utils.logging import get_logger + +logger = get_logger(__name__) + +# Suffixes des fichiers produits par les deux pipelines. Le scoring (end_to_end ET +# two_stage) partage un seul format : c'est le `name` du run qui distingue les runs. +SCORING_SUFFIX = "_predictions.jsonl" +HTR_SUFFIX = "_htr_predictions.jsonl" + + +def _join_s3(prefix: str, name: str) -> str: + """Concatène un préfixe (S3 ou local) et un nom de fichier (un seul slash).""" + return prefix.rstrip("/") + "/" + name + + +def upload_predictions(local_path: str | Path, dest_prefix: str | Path) -> str: + """Copie un fichier de prédictions local vers un préfixe S3. + + Args: + local_path: Chemin local du fichier JSONL de prédictions. + dest_prefix: Préfixe de destination (ex. s3://bucket/predictions). + + Returns: + L'URI de destination du fichier écrit. + + Raises: + FileNotFoundError: Si le fichier local est absent (run non lancé). + """ + local_path = Path(local_path) + if not local_path.is_file(): + raise FileNotFoundError( + f"Fichier de prédictions introuvable : {local_path}. " + "Lancer d'abord le benchmark (scripts/run_benchmark.py) pour le produire." + ) + + dest = _join_s3(str(dest_prefix), local_path.name) + # Copie binaire par flux (pas de réencodage, robuste aux gros fichiers). + with open(local_path, "rb") as src, fsspec.open(dest, "wb") as dst: + shutil.copyfileobj(src, dst) + + logger.info("Prédictions exportées : %s → %s", local_path, dest) + return dest + + +def export_run( + run_name: str, + dest_prefix: str | Path, + source_dir: str | Path = "data/processed", + htr: bool = False, +) -> str: + """Exporte vers S3 le fichier de prédictions d'un run donné. + + Args: + run_name: Nom du run (préfixe des fichiers de sortie). + dest_prefix: Préfixe S3 de destination. + source_dir: Dossier local des prédictions. [défaut : data/processed] + htr: Si True, exporte `_htr_predictions.jsonl` (transcription seule) + au lieu du fichier de scoring. + + Returns: + L'URI S3 du fichier écrit. + """ + suffix = HTR_SUFFIX if htr else SCORING_SUFFIX + local_path = Path(source_dir) / f"{run_name}{suffix}" + return upload_predictions(local_path, dest_prefix) diff --git a/src/evaluation_dictee/utils/tracking.py b/src/evaluation_dictee/utils/tracking.py index 39d4d2c..1840715 100644 --- a/src/evaluation_dictee/utils/tracking.py +++ b/src/evaluation_dictee/utils/tracking.py @@ -1,64 +1,165 @@ -"""Suivi d'expériences avec MLflow. +"""Suivi d'expériences Langfuse : session (= un lancement) → trace (= une copie) → generation. -Enregistre la config, les métriques et les artefacts d'un run. Conçu pour être -optionnel : si MLflow n'est pas configuré, les fonctions deviennent silencieuses -afin de ne pas bloquer un développement local. +Le `session_id` est UNIQUE PAR LANCEMENT (nom du run + horodatage) : relancer la +même config ouvre une nouvelle session ; le tag `run:` regroupe les lancements. +Pas de trace englobante — chaque copie est une trace autonome, inspectable seule. +Tout est no-op si Langfuse est indisponible (ne bloque pas le dev local). """ from __future__ import annotations +import os +import time from collections.abc import Iterator from contextlib import contextmanager -from typing import Any +from typing import TYPE_CHECKING, Any + +from langfuse import get_client, propagate_attributes from evaluation_dictee.config import ExperimentConfig from evaluation_dictee.utils.logging import get_logger +if TYPE_CHECKING: + from langfuse._client.span import LangfuseSpan + + from evaluation_dictee.data.loaders import Copy + logger = get_logger(__name__) -try: - import mlflow - _MLFLOW_AVAILABLE = True -except ImportError: # pragma: no cover - _MLFLOW_AVAILABLE = False +def _run_metadata(config: ExperimentConfig) -> dict[str, str]: + """Paramètres de l'expérience en chaînes (contrainte metadata Langfuse). + + Args: + config: Configuration de l'expérience à décrire. + + Returns: + Dictionnaire {modèle, méthode, schéma, n_few_shot, corpus} en chaînes. + """ + return { + "model": config.model.name, + "method": config.prompt.method, + "scheme": config.grid.scheme, + "n_few_shot": str(config.prompt.n_few_shot), + "corpus": config.data.corpus, + } + + +def _run_tags(config: ExperimentConfig) -> list[str]: + """Tags de filtrage/regroupement dans l'UI Langfuse. + + Args: + config: Configuration de l'expérience. + + Returns: + Liste de tags (méthode, corpus, `scheme:…`, `model:…`, `run:`). + """ + return [ + config.prompt.method, + config.data.corpus, + f"scheme:{config.grid.scheme}", + f"model:{config.model.name}", + # session_id unique par lancement : ce tag regroupe les lancements d'une config + f"run:{config.name}", + ] + + +def _user_id() -> str | None: + """Identifiant utilisateur Langfuse = namespace Kubernetes (KUBERNETES_NAMESPACE), ou None. + + Returns: + La valeur de `KUBERNETES_NAMESPACE`, ou None si absente ou vide. + """ + return os.environ.get("KUBERNETES_NAMESPACE") or None + + +def _launch_session_id(config: ExperimentConfig) -> str: + """Session Langfuse UNIQUE PAR LANCEMENT : « -AAAAMMJJ-HHMMSS » (triable dans le temps). + + Args: + config: Configuration de l'expérience (fournit `config.name`). + + Returns: + L'identifiant de session, nom du run suffixé de l'horodatage courant. + """ + return f"{config.name}-{time.strftime('%Y%m%d-%H%M%S')}" @contextmanager -def experiment_run(config: ExperimentConfig) -> Iterator[None]: - """Ouvre un run MLflow nommé d'après la config (no-op si MLflow absent). +def experiment_run(config: ExperimentConfig) -> Iterator[str]: + """Ouvre le contexte Langfuse d'un run (session neuve, attributs propagés aux traces). + + Propage user_id, session_id, tags et metadata à toutes les traces créées dans + le bloc. Si Langfuse est indisponible, le contexte reste ouvert sans tracer. Args: - config: configuration de l'expérience (sert de nom de run et de params). + config: Configuration de l'expérience à tracer. Yields: - Rien ; le contexte gère le cycle de vie du run. + L'identifiant de session généré pour ce lancement. """ - if not _MLFLOW_AVAILABLE: - logger.warning("MLflow indisponible : le run ne sera pas tracé.") - yield - return - - with mlflow.start_run(run_name=config.name): - mlflow.log_params( - { - "model": config.model.name, - "method": config.prompt.method, - "scheme": config.grid.scheme, - "n_few_shot": config.prompt.n_few_shot, - "corpus": config.data.corpus, - } - ) - yield - - -def log_metrics(metrics: dict[str, Any]) -> None: - """Enregistre des métriques numériques dans le run courant (no-op si absent). + session_id = _launch_session_id(config) + user_id = _user_id() + logger.info("Langfuse : nouvelle session « %s » (user_id=%s).", session_id, user_id) + metadata = {**_run_metadata(config), "run": config.name} + try: + with propagate_attributes( + user_id=user_id, + session_id=session_id, + tags=_run_tags(config), + metadata=metadata, + ): + yield session_id + except Exception: # pragma: no cover - repli défensif si Langfuse indisponible + logger.warning("Langfuse indisponible : le run ne sera pas tracé.", exc_info=True) + yield session_id + + +@contextmanager +def copy_trace(copy: Copy) -> Iterator[LangfuseSpan | None]: + """Ouvre une trace Langfuse pour une copie (les générations LLM s'y imbriquent). + + Args: + copy: Copie à tracer (fournit `copy_id` et `item_ids`). + + Yields: + Le span Langfuse de la copie, ou None si Langfuse est indisponible. + """ + langfuse = get_client() + try: + with langfuse.start_as_current_observation( + as_type="span", + name=f"copy:{copy.copy_id}", + input={"copy_id": copy.copy_id, "n_items": len(copy.item_ids)}, + ) as span: + yield span + except Exception: # pragma: no cover - repli défensif si Langfuse indisponible + logger.warning("Trace de la copie %s non créée (Langfuse indisponible).", copy.copy_id) + yield None + + +def log_metrics(config: ExperimentConfig, metrics: dict[str, Any]) -> None: + """Enregistre les métriques agrégées du run (metadata + Scores Langfuse). No-op si absent. + + Les valeurs numériques deviennent des Scores Langfuse (type NUMERIC) sur la + trace de synthèse ; toutes les métriques sont aussi jointes en metadata. Args: - metrics: dictionnaire {nom: valeur} de métriques scalaires. + config: Configuration de l'expérience (paramètres joints en metadata). + metrics: Métriques agrégées du run ; seules les valeurs int/float sont + converties en Scores. """ - if not _MLFLOW_AVAILABLE: - return - numeric = {k: v for k, v in metrics.items() if isinstance(v, (int, float))} - mlflow.log_metrics(numeric) + langfuse = get_client() + numeric = {k: float(v) for k, v in metrics.items() if isinstance(v, (int, float))} + metadata = {**_run_metadata(config), **{k: str(v) for k, v in metrics.items()}} + try: + with langfuse.start_as_current_observation( + as_type="span", + name=f"run-summary:{config.name}", + output=numeric, + metadata=metadata, + ): + for name, value in numeric.items(): + langfuse.score_current_trace(name=name, value=value, data_type="NUMERIC") + except Exception: # pragma: no cover - repli défensif si Langfuse indisponible + logger.warning("Métriques non enregistrées dans Langfuse (indisponible).", exc_info=True) diff --git a/tests/test_alignment.py b/tests/test_alignment.py index 6dfb256..949e29c 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -31,7 +31,6 @@ def test_realign_recolle_sur_mots_attendus() -> None: conf = [0.9, 0.9, 0.9, 0.9] aligned = realign(attendus, codes, trans, conf) assert len(aligned) == len(attendus) - # Le 1er item reste « nous » assert aligned[0].transcription == "nous" @@ -42,7 +41,6 @@ def test_realign_longueur_toujours_egale_aux_attendus() -> None: conf = [1.0, 1.0, 1.0] aligned = realign(attendus, codes, trans, conf) assert len(aligned) == 5 - # Les items sans correspondance sont marqués absents assert aligned[-1].code in {"0", "?"} @@ -100,7 +98,6 @@ def test_realign_anchored_utilise_les_ancres() -> None: conf = [0.9] * 5 aligned = realign_anchored(attendus, codes, trans, conf) assert len(aligned) == 4 - # L'ancre Martine doit être correctement placée i_martine = attendus.index("Martine") assert aligned[i_martine].transcription == "Martine" diff --git a/tests/test_benchmark_concurrency.py b/tests/test_benchmark_concurrency.py new file mode 100644 index 0000000..27192e5 --- /dev/null +++ b/tests/test_benchmark_concurrency.py @@ -0,0 +1,190 @@ +"""Tests de l'évaluation concurrente du benchmark (parallélisme + parité séquentielle). + +On mocke les I/O lourdes (chargement dataset/grille) et la trace Langfuse, pour +exercer uniquement l'orchestration : scoring parallèle, écriture mono-thread, +gestion des échecs/non-transcrits et reprise. +""" + +import contextlib +import json +import threading +import time +from pathlib import Path + +import pytest + +from evaluation_dictee.config import ExperimentConfig +from evaluation_dictee.data.loaders import Copy +from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer +from evaluation_dictee.pipeline import benchmark as bench + + +class _FakeGrid: + reference_text = "texte de référence" + items: list = [] + + +class FakeScorer(Scorer): + """Scorer déterministe (prédit = code expert) qui mesure le parallélisme observé.""" + + def __init__( + self, + delay: float = 0.0, + fail: set[str] | None = None, + non_transcribed: set[str] | None = None, + ) -> None: + self.delay = delay + self.fail = fail or set() + self.non_transcribed = non_transcribed or set() + self.scored: list[str] = [] + self.max_active = 0 + self._active = 0 + self._lock = threading.Lock() + + def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: + with self._lock: + self._active += 1 + self.max_active = max(self.max_active, self._active) + self.scored.append(copy.copy_id) + try: + if self.delay: + time.sleep(self.delay) + if copy.copy_id in self.fail: + raise RuntimeError("échec simulé") + if copy.copy_id in self.non_transcribed: + return CopyPrediction(copy_id=copy.copy_id, items=[], transcribed=False) + items = [ + ItemPrediction(item_id=i, code=c, confidence=0.9, transcription="x") + for i, c in zip(copy.item_ids, copy.expert_codes, strict=True) + ] + return CopyPrediction(copy_id=copy.copy_id, items=items) + finally: + with self._lock: + self._active -= 1 + + +def _copies(n: int) -> list[Copy]: + return [ + Copy( + copy_id=f"c{k:03d}.png", + image_path=f"s3://x/c{k:03d}.png", + expert_codes=["1", "9", "0"], + item_ids=[f"i{k}_1", f"i{k}_2", f"i{k}_3"], + ) + for k in range(n) + ] + + +@pytest.fixture +def patched(monkeypatch: pytest.MonkeyPatch): + """Neutralise dataset/grille/trace pour isoler l'orchestration.""" + + @contextlib.contextmanager + def _no_trace(copy): + yield None + + monkeypatch.setattr(bench, "load_grid", lambda _p: _FakeGrid()) + monkeypatch.setattr(bench, "copy_trace", _no_trace) + # Neutralise le chargement image + la détection copie vierge (non vierge par défaut). + monkeypatch.setattr(bench, "load_image", lambda _p: object()) + monkeypatch.setattr(bench, "ink_ratio", lambda _img: 0.5) + + +def _config(n: int) -> ExperimentConfig: + return ExperimentConfig.model_validate( + { + "name": "test_run", + "model": {"name": "fake"}, + "data": {"images_path": "x", "labels_path": "y"}, + } + ) + + +def _read_lines(path: Path) -> list[str]: + return sorted(line for line in path.read_text(encoding="utf-8").splitlines() if line) + + +def test_concurrent_matches_sequential(patched, monkeypatch, tmp_path: Path) -> None: + """Même sortie JSONL (à l'ordre près) en séquentiel et en concurrent.""" + copies = _copies(12) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + + seq_dir, par_dir = tmp_path / "seq", tmp_path / "par" + bench.run_benchmark(_config(12), FakeScorer(), output_dir=seq_dir, concurrency=1) + bench.run_benchmark(_config(12), FakeScorer(), output_dir=par_dir, concurrency=8) + + assert _read_lines(seq_dir / "test_run_predictions.jsonl") == _read_lines( + par_dir / "test_run_predictions.jsonl" + ) + + +def test_concurrency_actually_parallel(patched, monkeypatch, tmp_path: Path) -> None: + """Avec un délai par copie, plusieurs scorings tournent simultanément.""" + copies = _copies(16) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + scorer = FakeScorer(delay=0.05) + + bench.run_benchmark(_config(16), scorer, output_dir=tmp_path, concurrency=8) + + assert scorer.max_active >= 2 # preuve de parallélisme réel + + +def test_failures_and_non_transcribed(patched, monkeypatch, tmp_path: Path) -> None: + """Les échecs vont dans failed_copies.txt ; les non-transcrites sont exclues.""" + copies = _copies(6) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + scorer = FakeScorer(fail={"c001.png"}, non_transcribed={"c002.png"}) + + result = bench.run_benchmark(_config(6), scorer, output_dir=tmp_path, concurrency=4) + + assert "c002.png" in result.non_transcribed + assert (tmp_path / "test_run_failed_copies.txt").exists() + written = { + json.loads(line)["copy_id"] for line in _read_lines(tmp_path / "test_run_predictions.jsonl") + } + assert "c001.png" not in written # échec → non écrit + assert "c002.png" not in written # non transcrite → non écrit + assert "c000.png" in written + + +def test_copie_vierge_auto_codee_zero(patched, monkeypatch, tmp_path: Path) -> None: + """Une copie vierge est codée '0' sans appel modèle, identiquement pour toute méthode.""" + copies = _copies(4) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + # c001 est sous le seuil d'encre → vierge ; les autres au-dessus. + monkeypatch.setattr(bench, "load_image", lambda path: path) # identité : garde le chemin + monkeypatch.setattr(bench, "ink_ratio", lambda path: 0.01 if "c001" in path else 0.5) + scorer = FakeScorer() + + result = bench.run_benchmark(_config(4), scorer, output_dir=tmp_path, concurrency=1) + + assert result.blank_copies == ["c001.png"] + assert "c001.png" not in scorer.scored # aucune inférence sur une copie vierge + recs = [ + json.loads(line) for line in _read_lines(tmp_path / "test_run_predictions.jsonl") + ] + vierge = [r for r in recs if r["copy_id"] == "c001.png"] + assert len(vierge) == 3 + assert all(r["y_pred"] == "0" for r in vierge) + assert all(r["blank"] is True for r in vierge) + assert all(r["confidence"] == 1.0 for r in vierge) + # Une copie non vierge reste scorée normalement et marquée blank=False. + non_vierge = [r for r in recs if r["copy_id"] == "c000.png"] + assert all(r["blank"] is False for r in non_vierge) + + +def test_resume_skips_processed(patched, monkeypatch, tmp_path: Path) -> None: + """Une copie déjà présente dans le JSONL n'est pas re-scorée.""" + copies = _copies(5) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + out = tmp_path / "test_run_predictions.jsonl" + out.write_text( + json.dumps({"copy_id": "c000.png", "item_id": "i0_1", "y_true": "1", "y_pred": "1"}) + "\n", + encoding="utf-8", + ) + scorer = FakeScorer() + + bench.run_benchmark(_config(5), scorer, output_dir=tmp_path, concurrency=4) + + assert "c000.png" not in scorer.scored # sautée à la reprise + assert len(scorer.scored) == 4 diff --git a/tests/test_benchmark_resume.py b/tests/test_benchmark_resume.py index 2ecf1b4..3f2c5fd 100644 --- a/tests/test_benchmark_resume.py +++ b/tests/test_benchmark_resume.py @@ -28,7 +28,6 @@ def test_load_processed_ignores_truncated_last_line(tmp_path: Path) -> None: f.write(json.dumps({"copy_id": "c1.png", "item_id": "i1"}) + "\n") # Ligne tronquée (le crash a coupé au milieu du json) f.write('{"copy_id": "c2.png", "item_id":') - # Doit lire c1 et ignorer la ligne tronquée sans lever assert _load_processed_copy_ids(path) == {"c1.png"} diff --git a/tests/test_chain_of_thought.py b/tests/test_chain_of_thought.py index be1cddc..1f62d4a 100644 --- a/tests/test_chain_of_thought.py +++ b/tests/test_chain_of_thought.py @@ -1,7 +1,7 @@ """Tests de l'option chain_of_thought (chain-of-thought).""" from evaluation_dictee.config import PromptConfig -from evaluation_dictee.data.reference import GridItem +from evaluation_dictee.data.grid import GridItem from evaluation_dictee.pipeline.prompts import build_dictation_prompt ITEMS = [ @@ -10,22 +10,24 @@ ] +def _texte(messages: list[dict]) -> str: + """Concatène le contenu de tous les messages pour les assertions.""" + return "\n\n".join(str(m["content"]) for m in messages) + + def test_cot_desactive_pas_de_champ_comparaison() -> None: - prompt = build_dictation_prompt("Le soir", ITEMS, PromptConfig(chain_of_thought=False)) + prompt = _texte(build_dictation_prompt("Le soir", ITEMS, PromptConfig(chain_of_thought=False))) assert "comparaison" not in prompt - assert '"code": "1"' in prompt # schéma classique présent + assert '"code": "1"' in prompt def test_cot_active_ajoute_champ_comparaison() -> None: - prompt = build_dictation_prompt("Le soir", ITEMS, PromptConfig(chain_of_thought=True)) + prompt = _texte(build_dictation_prompt("Le soir", ITEMS, PromptConfig(chain_of_thought=True))) assert "comparaison" in prompt - # Le schéma JSON attendu inclut le nouveau champ assert '"comparaison"' in prompt - # L'exemple pédagogique est présent - assert "inquiets" in prompt.lower() # inclus dans l'exemple donné + assert "inquiets" in prompt.lower() # mot issu de l'exemple pédagogique du prompt def test_cot_ordre_du_raisonnement_est_impose() -> None: - prompt = build_dictation_prompt("Le soir", ITEMS, PromptConfig(chain_of_thought=True)) - # La consigne indique bien AVANT le code (ordre du raisonnement) + prompt = _texte(build_dictation_prompt("Le soir", ITEMS, PromptConfig(chain_of_thought=True))) assert "AVANT de choisir le code" in prompt diff --git a/tests/test_code_coherence.py b/tests/test_code_coherence.py index 2df5a59..95cd8c8 100644 --- a/tests/test_code_coherence.py +++ b/tests/test_code_coherence.py @@ -1,33 +1,31 @@ -"""Vérifie que modèle et experts codent dans le MÊME jeu de modalités. +"""Vérifie qu'après normalisation, modèle et experts codent dans le MÊME alphabet. -Après normalisation par `grid.normalize`, les codes du modèle et ceux des experts -doivent appartenir au même alphabet (défini par le schéma choisi). Ce test rapide -attrape l'erreur où le prétraitement des codes experts aurait été oublié — par -exemple des codes 3/4/5 experts comparés à des prédictions modèle 1/9/0. +Attrape l'erreur où le prétraitement des codes experts aurait été oublié (ex. codes +3/4/5 experts comparés à des prédictions modèle 1/9/0). """ import pytest -from evaluation_dictee.data import grid +from evaluation_dictee.data import reference def test_alphabets_attendus() -> None: - assert grid.allowed_codes("simplifiee") == {"1", "9", "0"} - assert grid.allowed_codes("complete") == {"1", "3", "4", "5", "9", "0"} + assert reference.allowed_codes("simplifiee") == {"1", "9", "0"} + assert reference.allowed_codes("complete") == {"1", "3", "4", "5", "9", "0"} def test_schema_inconnu_leve_erreur() -> None: with pytest.raises(ValueError): - grid.allowed_codes("inexistant") + reference.allowed_codes("inexistant") @pytest.mark.parametrize("scheme", ["simplifiee", "complete"]) def test_codes_experts_normalises_dans_alphabet(scheme: str) -> None: # Tous les codes bruts possibles d'un expert (grille complète + ponctuation) codes_experts_bruts = ["1", "3", "4", "5", "9", "0"] - attendus = grid.allowed_codes(scheme) + attendus = reference.allowed_codes(scheme) for code in codes_experts_bruts: - norm = grid.normalize(code, scheme) + norm = reference.normalize(code, scheme) assert norm in attendus, f"Code expert {code!r} → {norm!r} hors de {attendus} ({scheme})" @@ -35,17 +33,16 @@ def test_codes_experts_normalises_dans_alphabet(scheme: str) -> None: def test_codes_modele_normalises_dans_alphabet(scheme: str) -> None: # Codes que le modèle peut produire selon le schéma demandé dans le prompt codes_modele = ["1", "9", "0"] if scheme == "simplifiee" else ["1", "3", "4", "5", "9", "0"] - attendus = grid.allowed_codes(scheme) + attendus = reference.allowed_codes(scheme) for code in codes_modele: - assert grid.normalize(code, scheme) in attendus + assert reference.normalize(code, scheme) in attendus def test_modele_et_experts_meme_alphabet_apres_normalisation() -> None: """Cœur du test : sur les deux schémas, les deux sources convergent.""" for scheme in ("simplifiee", "complete"): - experts = {grid.normalize(c, scheme) for c in ["1", "3", "4", "5", "9", "0"]} + experts = {reference.normalize(c, scheme) for c in ["1", "3", "4", "5", "9", "0"]} modele_brut = ["1", "9", "0"] if scheme == "simplifiee" else ["1", "3", "4", "5", "9", "0"] - modele = {grid.normalize(c, scheme) for c in modele_brut} - # Les codes du modèle doivent être inclus dans ceux des experts (même jeu) + modele = {reference.normalize(c, scheme) for c in modele_brut} assert modele <= experts - assert experts <= grid.allowed_codes(scheme) + assert experts <= reference.allowed_codes(scheme) diff --git a/tests/test_grid.py b/tests/test_codes.py similarity index 66% rename from tests/test_grid.py rename to tests/test_codes.py index 42cee52..9ce1dfb 100644 --- a/tests/test_grid.py +++ b/tests/test_codes.py @@ -1,8 +1,8 @@ -"""Tests de la logique de grille (simplification des codes).""" +"""Tests de la logique des codes de la dictée (simplification 6 codes → 1/9/0).""" import pytest -from evaluation_dictee.data import grid +from evaluation_dictee.data import reference @pytest.mark.parametrize( @@ -18,12 +18,12 @@ ], ) def test_to_simplified(code_complet: str, attendu: str) -> None: - assert grid.to_simplified(code_complet) == attendu + assert reference.to_simplified(code_complet) == attendu def test_normalize_complete_garde_le_code() -> None: - assert grid.normalize("4", "complete") == "4" + assert reference.normalize("4", "complete") == "4" def test_normalize_simplifiee_regroupe() -> None: - assert grid.normalize("4", "simplifiee") == "9" + assert reference.normalize("4", "simplifiee") == "9" diff --git a/tests/test_config.py b/tests/test_config.py index e16d9b6..86a7bd2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,8 +7,8 @@ def test_charge_config_exemple() -> None: # On part de la racine du dépôt (pytest est lancé depuis là) - config = load_config(Path("configs/dictee_qwen7b_zeroshot.yaml")) - assert config.name == "dictee_qwen7b_zeroshot" + config = load_config(Path("configs/scoring/dictee_REFERENCE.yaml")) + assert config.name == "dictee_REFERENCE" assert config.model.kind == "vlm" assert config.grid.scheme == "simplifiee" assert config.prompt.method == "C" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 78773af..5d2c4f9 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -36,14 +36,13 @@ def test_accuracy_by_position_detecte_degradation() -> None: # Désaccords concentrés en fin → accord doit chuter avec la position df = _df("c1", faux_positions={6, 7, 8, 9}) tab = accuracy_by_position(df, ITEMS, n_bins=5) - # La dernière tranche doit avoir un accord plus faible que la première assert tab.iloc[-1]["accord"] < tab.iloc[0]["accord"] def test_correlation_negative_si_fin_degradee() -> None: df = _df("c1", faux_positions={6, 7, 8, 9}) corr = position_accuracy_correlation(df, ITEMS) - assert corr < 0 # plus on avance, moins c'est juste + assert corr < 0 def test_longest_run_detecte_sequence_consecutive() -> None: @@ -90,7 +89,6 @@ def test_hotspots_renvoie_les_palmares() -> None: "plus_forte_chute", "sur_confiance", } - # La pire copie doit être en tête du palmarès d'accord assert hot["pire_accord"].index[0] == "mauvaise" @@ -104,9 +102,7 @@ def test_omission_decale_et_detruit_la_fin() -> None: res_fin = simulate_word_omission_shift(expert, omission_position=18) assert res_debut["accord_sans_decalage"] == 1.0 - # Décalage précoce : l'accord s'effondre largement assert res_debut["accord_avec_decalage"] < 0.6 - # Décalage tardif : peu d'items affectés, accord reste élevé assert res_fin["accord_avec_decalage"] > res_debut["accord_avec_decalage"] diff --git a/tests/test_html_report.py b/tests/test_html_report.py index 879eaf3..1aaaa77 100644 --- a/tests/test_html_report.py +++ b/tests/test_html_report.py @@ -43,7 +43,6 @@ def test_filter_keeps_header_cells(tmp_path: Path) -> None: filtered = _filter_by_sections(nb, ["section:b"]) # 1 cellule d'en-tête + 2 cellules pour section B = 3 assert len(filtered.cells) == 3 - # La 1re cellule (imports) est toujours là assert "import pandas" in "".join(filtered.cells[0].source) @@ -82,6 +81,5 @@ def test_bootstrap_prevalence_ci_shape() -> None: assert set(ci.columns) == {"estimate", "lo", "hi"} # L'estimation de la prévalence sur i0 est 100 % (tous à "9") assert ci.loc["i0", "estimate"] == 100.0 - # IC toujours [lo, hi] avec lo <= estimate <= hi for it in ci.index: assert ci.loc[it, "lo"] <= ci.loc[it, "estimate"] <= ci.loc[it, "hi"] diff --git a/tests/test_htr_concurrency.py b/tests/test_htr_concurrency.py new file mode 100644 index 0000000..ebae7d0 --- /dev/null +++ b/tests/test_htr_concurrency.py @@ -0,0 +1,95 @@ +"""Tests de la transcription HTR concurrente (non-régression vs séquentiel). + +Un faux transcripteur remplace l'appel VLM : on vérifie que le parallélisme ne +change ni le contenu, ni l'ORDRE des enregistrements, ni la liste des échecs. +""" + +import json +import threading +import time +from pathlib import Path + +from evaluation_dictee.transcription.htr_benchmark import run_htr_benchmark +from evaluation_dictee.transcription.scoledit import ScoledtSample + + +class FakeTranscriber: + """Transcripteur déterministe qui mesure le parallélisme observé.""" + + def __init__(self, delay: float = 0.0, empty_for: set[str] | None = None) -> None: + self.delay = delay + self.empty_for = empty_for or set() + self.max_active = 0 + self._active = 0 + self._lock = threading.Lock() + + def transcribe(self, image_path: str) -> str: + with self._lock: + self._active += 1 + self.max_active = max(self.max_active, self._active) + try: + if self.delay: + time.sleep(self.delay) + # Transcription déterministe dérivée du chemin ; vide si demandé (échec). + return "" if image_path in self.empty_for else f"texte {image_path}" + finally: + with self._lock: + self._active -= 1 + + +def _samples(n: int) -> list[ScoledtSample]: + return [ + ScoledtSample( + scan=f"s{k:03d}", + level="CE1", + student_id=k, + image_path=f"img/{k:03d}.png", + reference=f"reference du scan {k}", + tei_raw="", + ) + for k in range(n) + ] + + +def _lines(path: Path) -> list[str]: + return [line for line in path.read_text(encoding="utf-8").splitlines() if line] + + +def test_parallel_identical_to_sequential(tmp_path: Path) -> None: + """Même JSONL, MÊME ORDRE, mêmes moyennes en séquentiel et en concurrent.""" + samples = _samples(12) + + seq = run_htr_benchmark( + samples, FakeTranscriber(), run_name="seq", output_dir=tmp_path, concurrency=1 + ) + par = run_htr_benchmark( + samples, FakeTranscriber(), run_name="par", output_dir=tmp_path, concurrency=8 + ) + + # Ordre + contenu identiques (on ignore la seule colonne "scan"→run-agnostique). + seq_lines = _lines(tmp_path / "seq_htr_predictions.jsonl") + par_lines = _lines(tmp_path / "par_htr_predictions.jsonl") + assert [json.loads(x)["scan"] for x in seq_lines] == [json.loads(x)["scan"] for x in par_lines] + assert [json.loads(x)["scan"] for x in par_lines] == [s.scan for s in samples] # ordre préservé + assert seq.mean_cer == par.mean_cer + assert seq.mean_wer == par.mean_wer + + +def test_concurrency_actually_parallel(tmp_path: Path) -> None: + """Avec un délai, plusieurs transcriptions tournent en même temps.""" + transcriber = FakeTranscriber(delay=0.05) + run_htr_benchmark(_samples(16), transcriber, run_name="p", output_dir=tmp_path, concurrency=8) + assert transcriber.max_active >= 2 + + +def test_failures_listed_in_order(tmp_path: Path) -> None: + """Les transcriptions vides sont comptées comme échecs, dans l'ordre des samples.""" + samples = _samples(6) + transcriber = FakeTranscriber(empty_for={"img/001.png", "img/004.png"}) + + result = run_htr_benchmark( + samples, transcriber, run_name="f", output_dir=tmp_path, concurrency=4 + ) + + assert result.n_echecs == 2 + assert result.failed_scans == ["s001", "s004"] # ordre samples, pas ordre d'achèvement diff --git a/tests/test_multi_model.py b/tests/test_multi_model.py index 93db4d3..58151ca 100644 --- a/tests/test_multi_model.py +++ b/tests/test_multi_model.py @@ -121,7 +121,6 @@ def test_referral_curve_monotonicity(multi_runs_data) -> None: conf = confidence_score(agree) curve = referral_curve_multi(agree, conf, reference_run="run1") - # Le % renvoyé doit être monotone croissant en τ pct = curve["pct_copies_renvoyees"].values assert all(pct[i] <= pct[i + 1] for i in range(len(pct) - 1)), f"Non monotone : {pct}" @@ -132,9 +131,7 @@ def test_disagreement_type_summary(multi_runs_data) -> None: agree = agreement_per_item(df) summary = disagreement_type_summary(agree) - # Il doit y avoir des lignes pour n_accord_modeles = 2 ou 3 assert set(summary.index).issubset({1, 2, 3}) - # Somme des % = 100 assert abs(summary["pct_items"].sum() - 100.0) < 1e-6 diff --git a/tests/test_results_summary.py b/tests/test_results_summary.py new file mode 100644 index 0000000..2983643 --- /dev/null +++ b/tests/test_results_summary.py @@ -0,0 +1,122 @@ +"""Tests des synthèses de résultats (evaluation/results_summary).""" + +import math + +import pandas as pd +import pytest + +from evaluation_dictee.evaluation.results_summary import htr_summary, scoring_summary + + +def _scoring_df() -> pd.DataFrame: + # 4 items, 2 copies. Codes "1" = correct, autre = erreur. + # c1/i1 : expert=1 pred=1 → accord, pas d'erreur + # c1/i2 : expert=9 pred=9 → accord, vrai positif erreur + # c2/i1 : expert=9 pred=1 → désaccord, SUR-CORRECTION + # c2/i2 : expert=1 pred=9 → désaccord, sur-détection + return pd.DataFrame( + [ + {"copy_id": "c1", "item_id": "i1", "y_true": "1", "y_pred": "1", "confidence": 0.9}, + {"copy_id": "c1", "item_id": "i2", "y_true": "9", "y_pred": "9", "confidence": 0.8}, + {"copy_id": "c2", "item_id": "i1", "y_true": "9", "y_pred": "1", "confidence": 0.7}, + {"copy_id": "c2", "item_id": "i2", "y_true": "1", "y_pred": "9", "confidence": 0.6}, + ] + ) + + +def test_scoring_summary_core_metrics() -> None: + """Accord, rappel, précision et sur-correction calculés depuis les codes bruts.""" + s = scoring_summary(_scoring_df(), run="r") + assert s.run == "r" + assert s.n_items == 4 + assert s.n_copies == 2 + assert s.raw_agreement == 0.5 # 2 accords / 4 + # erreurs expert = {i2, c2/i1} = 2 ; vrais positifs = {c1/i2} = 1 + assert s.recall_errors == pytest.approx(0.5) + # erreurs modèle = {c1/i2, c2/i2} = 2 ; vrais positifs = 1 + assert s.precision_errors == pytest.approx(0.5) + # sur-correction = expert erreur & modèle correct = {c2/i1} = 1 sur 4 items + assert s.overcorrection_rate == pytest.approx(0.25) + + +def test_scoring_summary_types_coerced_and_ece_bounded() -> None: + """Les codes numériques sont coercés en str ; l'ECE reste dans [0, 1].""" + df = _scoring_df() + df["y_true"] = df["y_true"].astype(int) # simule un JSONL avec codes entiers + s = scoring_summary(df, run="r") + assert s.n_items == 4 + assert 0.0 <= s.ece <= 1.0 + + +def test_scoring_summary_empty_raises() -> None: + with pytest.raises(ValueError): + scoring_summary(pd.DataFrame(), run="r") + + +def _htr_df() -> pd.DataFrame: + # Un échantillon non transcrit (transcrit=False) doit être exclu des moyennes. + return pd.DataFrame( + [ + { + "cer": 0.10, + "wer": 0.20, + "cer_normalise": 0.05, + "wer_normalise": 0.10, + "n_char_ref": 100, + "n_mots_ref": 20, + "transcrit": True, + }, + { + "cer": 0.30, + "wer": 0.40, + "cer_normalise": 0.15, + "wer_normalise": 0.20, + "n_char_ref": 100, + "n_mots_ref": 20, + "transcrit": True, + }, + { + "cer": 1.00, + "wer": 1.00, + "cer_normalise": 1.00, + "wer_normalise": 1.00, + "n_char_ref": 100, + "n_mots_ref": 20, + "transcrit": False, + }, + ] + ) + + +def test_htr_summary_micro_weighted_excludes_untranscribed() -> None: + """CER/WER micro-pondérés sur les seuls échantillons transcrits.""" + s = htr_summary(_htr_df(), run="htr") + assert s.n_samples == 3 + # (0.10*100 + 0.30*100) / 200 = 0.20, l'échantillon non transcrit est ignoré. + assert s.cer == pytest.approx(0.20) + assert s.wer == pytest.approx(0.30) + assert s.cer_normalise == pytest.approx(0.10) + + +def test_htr_summary_missing_columns_raises() -> None: + with pytest.raises(ValueError): + htr_summary(pd.DataFrame([{"cer": 0.1}]), run="htr") + + +def test_htr_summary_zero_weight_is_nan() -> None: + """Poids total nul → NaN plutôt qu'une division par zéro.""" + df = pd.DataFrame( + [ + { + "cer": 0.1, + "wer": 0.2, + "cer_normalise": 0.0, + "wer_normalise": 0.0, + "n_char_ref": 0, + "n_mots_ref": 0, + "transcrit": True, + } + ] + ) + s = htr_summary(df, run="htr") + assert math.isnan(s.cer) diff --git a/tests/test_retry_transcription.py b/tests/test_retry_transcription.py index 669bbaa..6171b7b 100644 --- a/tests/test_retry_transcription.py +++ b/tests/test_retry_transcription.py @@ -1,8 +1,8 @@ """Tests de la détection des copies non transcrites.""" from evaluation_dictee.config import ModelConfig, PromptConfig +from evaluation_dictee.data.grid import GridItem from evaluation_dictee.data.loaders import Copy -from evaluation_dictee.data.reference import GridItem from evaluation_dictee.models.vlm import VLMScorer diff --git a/tests/test_s3_export.py b/tests/test_s3_export.py new file mode 100644 index 0000000..bb64159 --- /dev/null +++ b/tests/test_s3_export.py @@ -0,0 +1,68 @@ +"""Tests de l'export des prédictions (utils/s3_export). + +On utilise un répertoire local comme « destination » : fsspec traite un chemin +sans schéma comme du LocalFileSystem, ce qui teste la même logique de copie que +vers s3:// sans dépendre du réseau. +""" + +import json +from pathlib import Path + +import pytest + +from evaluation_dictee.utils.s3_export import ( + HTR_SUFFIX, + SCORING_SUFFIX, + export_run, + upload_predictions, +) + + +def _write_jsonl(path: Path, rows: list[dict]) -> None: + with open(path, "w", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def test_upload_predictions_copies_content(tmp_path: Path) -> None: + """Le fichier est copié à l'identique sous le préfixe de destination.""" + src = tmp_path / "run_predictions.jsonl" + _write_jsonl(src, [{"copy_id": "c1", "item_id": "i1", "y_pred": "1"}]) + dest_dir = tmp_path / "predictions" + + dest = upload_predictions(src, dest_dir) + + assert dest.endswith("predictions/run_predictions.jsonl") + assert Path(dest).read_text(encoding="utf-8") == src.read_text(encoding="utf-8") + + +def test_upload_predictions_missing_file_raises(tmp_path: Path) -> None: + """Un fichier absent lève une erreur claire plutôt que d'écrire du vide.""" + with pytest.raises(FileNotFoundError): + upload_predictions(tmp_path / "absent.jsonl", tmp_path / "predictions") + + +def test_export_run_scoring_and_htr_naming(tmp_path: Path) -> None: + """export_run choisit le bon suffixe (scoring vs HTR) selon `htr`.""" + source_dir = tmp_path / "processed" + source_dir.mkdir() + _write_jsonl(source_dir / f"dictee_x{SCORING_SUFFIX}", [{"a": 1}]) + _write_jsonl(source_dir / f"dictee_x{HTR_SUFFIX}", [{"b": 2}]) + dest_dir = tmp_path / "predictions" + + scoring_dest = export_run("dictee_x", dest_dir, source_dir=source_dir) + htr_dest = export_run("dictee_x", dest_dir, source_dir=source_dir, htr=True) + + assert scoring_dest.endswith(f"dictee_x{SCORING_SUFFIX}") + assert htr_dest.endswith(f"dictee_x{HTR_SUFFIX}") + + +def test_export_run_trailing_slash_prefix(tmp_path: Path) -> None: + """Un préfixe avec slash final ne produit pas de double slash.""" + source_dir = tmp_path / "processed" + source_dir.mkdir() + _write_jsonl(source_dir / f"run{SCORING_SUFFIX}", [{"a": 1}]) + + dest = export_run("run", str(tmp_path / "predictions") + "/", source_dir=source_dir) + + assert "//run" not in dest.replace("://", "") diff --git a/tests/test_two_stage.py b/tests/test_two_stage.py index 8f9d518..4452e70 100644 --- a/tests/test_two_stage.py +++ b/tests/test_two_stage.py @@ -4,8 +4,8 @@ from types import SimpleNamespace from evaluation_dictee.config import ModelConfig, PromptConfig +from evaluation_dictee.data.grid import GridItem from evaluation_dictee.data.loaders import Copy -from evaluation_dictee.data.reference import GridItem from evaluation_dictee.models.two_stage import TwoStageScorer ITEMS = [ @@ -89,11 +89,13 @@ def test_deux_appels_distincts(monkeypatch) -> None: scorer = _make_scorer([transcription, codage], monkeypatch) scorer.score_copy(_copy(), "Le soir tombait") assert len(scorer.client.appels) == 2 - # 1er appel = multimodal (contenu = liste avec image) - contenu1 = scorer.client.appels[0]["messages"][0]["content"] + # Chaque prompt est désormais structuré system + user ; l'image (ou le texte) + # est portée par le message user, c.-à-d. le dernier message. + # 1er appel = multimodal : le contenu du message user est une liste (avec image). + contenu1 = scorer.client.appels[0]["messages"][-1]["content"] assert isinstance(contenu1, list) - # 2e appel = texte seul (contenu = chaîne) - contenu2 = scorer.client.appels[1]["messages"][0]["content"] + # 2e appel = texte seul : le contenu du message user est une chaîne. + contenu2 = scorer.client.appels[1]["messages"][-1]["content"] assert isinstance(contenu2, str) diff --git a/tests/test_two_stage_parser.py b/tests/test_two_stage_parser.py index 631b0a7..888b509 100644 --- a/tests/test_two_stage_parser.py +++ b/tests/test_two_stage_parser.py @@ -38,7 +38,6 @@ def test_json_tronque_recupere_items_valides() -> None: '{"item_id": "i3", "cod' # tronqué ) items = _extract_items_from_content(contenu) - # On récupère au moins les 2 items complets assert len(items) >= 2 assert items[0]["item_id"] == "i1" diff --git a/uv.lock b/uv.lock index aa3c249..9d2fb71 100644 --- a/uv.lock +++ b/uv.lock @@ -181,20 +181,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[package]] -name = "alembic" -version = "1.18.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -274,6 +260,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/1a/c8923d819b49872a612033b90d29299c0be73a7cbed1ddb3dc78dfe5e9f1/apache_tvm_ffi-0.1.9-cp314-cp314t-win_amd64.whl", hash = "sha256:a42d7ca27dce83efbdce7ec970fe3e773a69c31d928730ee5d9badb1229d106c", size = 2039007, upload-time = "2026-02-27T19:27:43.618Z" }, ] +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "ast-serialize" version = "0.5.0" @@ -323,6 +374,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/88/97eef84f48fa04fbd6750e62dcceafba6c63c81b7ac1420856c8dcc0a3f9/astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5", size = 27488, upload-time = "2019-12-10T01:50:33.628Z" }, ] +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -360,6 +429,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/41/562a61d5a61fba3ffb273a115e249f1d8471b9515c59fcc38b4b9deda238/av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4", size = 21324700, upload-time = "2026-06-07T05:52:53.972Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "backoff" version = "2.2.1" @@ -369,6 +447,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "blake3" version = "1.0.8" @@ -453,12 +544,20 @@ wheels = [ ] [[package]] -name = "blinker" -version = "1.9.0" +name = "bleach" +version = "6.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, ] [[package]] @@ -730,6 +829,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + [[package]] name = "compressed-tensors" version = "0.15.0.1" @@ -1099,37 +1207,37 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cublas" }, ] cudart = [ - { name = "nvidia-cuda-runtime", version = "13.0.96", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-runtime", version = "13.0.96", source = { registry = "https://pypi.org/simple" } }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", version = "13.0.88", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-nvrtc", version = "13.0.88", source = { registry = "https://pypi.org/simple" } }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -1142,18 +1250,46 @@ wheels = [ ] [[package]] -name = "databricks-sdk" -version = "0.117.0" +name = "debugpy" +version = "1.8.21" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "protobuf" }, - { name = "requests" }, - { name = "urllib3" }, +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/86/fe149c523a9181deb3a90ae4e83708f54a6ee80c40ca392cf66ffdf425df/databricks_sdk-0.117.0.tar.gz", hash = "sha256:30a6fc21a8f9c4a41830c43332ae63b38c3679a83ac1bda4734ce7ced114faf1", size = 989404, upload-time = "2026-06-11T18:19:29.034Z" } + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/4d/3e28a5a1ae9aa42237bd0f4e6b34867b554c046a0f3e7c2ef49fa74800f6/databricks_sdk-0.117.0-py3-none-any.whl", hash = "sha256:bafd588c067ee353c905e56cd2eb37c7eca7a56a4c1b57656621b77208c32f86", size = 936854, upload-time = "2026-06-11T18:19:27.395Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] @@ -1214,20 +1350,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] -[[package]] -name = "docker" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, -] - [[package]] name = "docstring-parser" version = "0.18.0" @@ -1260,13 +1382,12 @@ wheels = [ ] [[package]] -name = "evaluation-ecrit" +name = "evaluation-dictee" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "langfuse" }, - { name = "mlflow" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "openai" }, @@ -1283,6 +1404,18 @@ dependencies = [ ] [package.optional-dependencies] +gpu = [ + { name = "qwen-vl-utils" }, + { name = "torch" }, + { name = "transformers" }, + { name = "vllm" }, +] +notebooks = [ + { name = "jupyterlab" }, + { name = "matplotlib" }, +] + +[package.dev-dependencies] dev = [ { name = "mypy" }, { name = "pandas-stubs" }, @@ -1291,42 +1424,50 @@ dev = [ { name = "ruff" }, { name = "types-pyyaml" }, ] -gpu = [ - { name = "qwen-vl-utils" }, - { name = "torch" }, - { name = "transformers" }, - { name = "vllm" }, -] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, - { name = "langfuse", specifier = ">=2.30" }, - { name = "mlflow", specifier = ">=2.12" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "jupyterlab", marker = "extra == 'notebooks'", specifier = ">=4.6.1" }, + { name = "langfuse", specifier = ">=4,<5" }, + { name = "matplotlib", marker = "extra == 'notebooks'", specifier = ">=3.11.1" }, { name = "numpy", specifier = ">=1.26" }, { name = "openai", specifier = ">=1.30" }, { name = "pandas", specifier = ">=2.2" }, - { name = "pandas-stubs", marker = "extra == 'dev'" }, { name = "pillow", specifier = ">=10.2" }, { name = "pydantic", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.2" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "qwen-vl-utils", marker = "extra == 'gpu'", specifier = ">=0.0.8" }, { name = "rich", specifier = ">=13.7" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, { name = "s3fs", specifier = ">=2024.2" }, { name = "scikit-learn", specifier = ">=1.4" }, { name = "torch", marker = "extra == 'gpu'", specifier = ">=2.3" }, { name = "tqdm", specifier = ">=4.66" }, { name = "transformers", marker = "extra == 'gpu'", specifier = ">=4.45" }, { name = "typer", specifier = ">=0.12" }, - { name = "types-pyyaml", marker = "extra == 'dev'" }, { name = "vllm", marker = "extra == 'gpu'", specifier = ">=0.6" }, ] -provides-extras = ["dev", "gpu"] +provides-extras = ["gpu", "notebooks"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.10" }, + { name = "pandas-stubs" }, + { name = "pytest", specifier = ">=8.2" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.4" }, + { name = "types-pyyaml" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] [[package]] name = "fastapi" @@ -1498,6 +1639,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/6d/56ef943ea524784598c035ccbd42e564e937da0438ae3f55f0e76cb95571/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6a1c56957ac82408be37a3f63594bc83e0919e8760492a4475e542f9f1828778", size = 1034886, upload-time = "2026-04-13T17:11:15.617Z" }, ] +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + [[package]] name = "fastsafetensors" version = "0.3.2" @@ -1564,36 +1714,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/bc/518b092473f37d904ae07766ad37c772b93da13ea788777b22a80c3f1a7c/flashinfer_python-0.6.11.post2-py3-none-any.whl", hash = "sha256:550cbdb760f9f7ec0e42055e06636b9489d05f1a38989cafd77e6eb820de0138", size = 13746417, upload-time = "2026-05-14T04:57:30.25Z" }, ] -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, -] - -[[package]] -name = "flask-cors" -version = "6.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" }, -] - [[package]] name = "fonttools" version = "4.63.0" @@ -1643,6 +1763,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1773,43 +1902,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/bb/d71d6da82763528c2c2ed6b59a9d6142c6595545a4c448e2085d155e88c2/gguf-0.19.0-py3-none-any.whl", hash = "sha256:70bcd10edfe697fb2dad6e40af2234b9d8ece9a41a99761405121ebda1c3c1cd", size = 118475, upload-time = "2026-05-06T13:04:02.588Z" }, ] -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.50" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, -] - -[[package]] -name = "google-auth" -version = "2.54.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/15/f6/494e18317546d7def90c957b71d68b025d24f0e22e486c2606bc57765c48/google_auth-2.54.0.tar.gz", hash = "sha256:130f6fd5e3f497fdad897a23ed9489973437edf561238c4b92a4d02c435f8af9", size = 343161, upload-time = "2026-06-12T18:03:17.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/c5/d53bddd2c0949833fcb4ea06f9d5dd1c40575a1a4214cd1021eff57ba301/google_auth-2.54.0-py3-none-any.whl", hash = "sha256:784e9837f92244141250470d47c893df50cbab485ce491aca5e9deb558ad2b48", size = 249878, upload-time = "2026-06-12T18:02:57.58Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.75.0" @@ -1822,105 +1914,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] -[[package]] -name = "graphene" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "graphql-core" }, - { name = "graphql-relay" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/f6/bf62ff950c317ed03e77f3f6ddd7e34aaa98fe89d79ebd660c55343d8054/graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa", size = 44739, upload-time = "2024-11-09T20:44:25.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/e0/61d8e98007182e6b2aca7cf65904721fb2e4bce0192272ab9cb6f69d8812/graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71", size = 114894, upload-time = "2024-11-09T20:44:23.851Z" }, -] - -[[package]] -name = "graphql-core" -version = "3.2.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/90/f2aff026ab4aebd80eb71905106a0885f4cfde85dcf965543f45bed0d9ee/graphql_core-3.2.11.tar.gz", hash = "sha256:e7e156d10beb127cab5c89ff0da71416fc73d27c484a4757d3b2d35633774802", size = 528407, upload-time = "2026-06-05T13:45:22.915Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl", hash = "sha256:0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0", size = 214879, upload-time = "2026-06-05T13:45:21.245Z" }, -] - -[[package]] -name = "graphql-relay" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "graphql-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/13/98fbf8d67552f102488ffc16c6f559ce71ea15f6294728d33928ab5ff14d/graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c", size = 50027, upload-time = "2022-04-16T11:03:45.447Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/16/a4cf06adbc711bd364a73ce043b0b08d8fa5aae3df11b6ee4248bcdad2e0/graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5", size = 16940, upload-time = "2022-04-16T11:03:43.895Z" }, -] - -[[package]] -name = "greenlet" -version = "3.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, - { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, - { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, - { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, - { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", size = 234989, upload-time = "2026-05-20T13:08:27.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, - { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, - { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, - { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, - { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, - { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, - { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, - { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, - { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, - { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, - { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, - { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, - { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, - { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, - { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, - { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, - { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, - { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, - { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, - { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, - { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, -] - [[package]] name = "grpcio" version = "1.81.1" @@ -1972,18 +1965,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, ] -[[package]] -name = "gunicorn" -version = "26.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -2105,15 +2086,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] -[[package]] -name = "huey" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/cb/58f229149944602917a976d533d3fe7d54770d6ea18df5931e3f4f313fa0/huey-3.0.3.tar.gz", hash = "sha256:1a17fef95fc8432f75413f1b77439cef5f3493c1ddbfba9151756b31a1b2dad3", size = 263604, upload-time = "2026-06-12T01:53:55.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/82/f85d8918949786420716a5e421525ab12aa084bcbe32d86c9743e50bcf3d/huey-3.0.3-py3-none-any.whl", hash = "sha256:d1c687734778b8282c035a943eead8368c1736bb28abc006596fbbc01bdc96dc", size = 94945, upload-time = "2026-06-12T01:53:53.981Z" }, -] - [[package]] name = "huggingface-hub" version = "1.16.1" @@ -2256,18 +2228,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/0f/e849d072f2e0afe49627de3995fc9dae54b4c804c70c0840f928d95c10e1/ijson-3.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fdeee6957f92e0c114f65c55cf8fe7eabb80cfacab64eea6864060913173f66d", size = 55369, upload-time = "2026-02-24T03:58:29.839Z" }, ] -[[package]] -name = "importlib-metadata" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -2287,12 +2247,86 @@ wheels = [ ] [[package]] -name = "itsdangerous" -version = "2.2.0" +name = "ipykernel" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] @@ -2415,6 +2449,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "json5" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71", size = 53278, upload-time = "2026-06-19T20:08:27.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618", size = 36570, upload-time = "2026-06-19T20:08:26.748Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -2430,6 +2482,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -2442,6 +2507,174 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jupyter-builder" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/61/47f7ae054f5cd3983c10e1d65a6eb7fcd4b87ebb1056e190ef7d63ff4f19/jupyter_builder-1.1.1.tar.gz", hash = "sha256:1a13977912b08deda77fce2c803940131c27cf77a27ed64b9ffca25aa0ed7e6c", size = 971667, upload-time = "2026-07-17T13:14:47.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/cc/f6a12de1c890ea5dd2816c5c76d5ac6d3ed52db3c37f78691328207d13b9/jupyter_builder-1.1.1-py3-none-any.whl", hash = "sha256:f9c14bc55c0488a073f62af12d468936fcf9ecb7e9dd802f6f9c33de46ad70db", size = 913264, upload-time = "2026-07-17T13:14:45.857Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt' and sys_platform != 'darwin'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt' and sys_platform != 'darwin'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-builder" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/2a/d6af53bfd45a43a5bfe7e40ba47ee7a8921a807daf4bb708e3a295bbb54d/jupyterlab-4.6.1.tar.gz", hash = "sha256:75315982ed28427edaa62bb85eadb5105e4043a757643c910efd787fe6ed0837", size = 28179125, upload-time = "2026-06-29T12:48:45.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/81/90ac6cc31d248e83a0d1eab343a5e6e68bc783d3f74fbe61640f42a61da4/jupyterlab-4.6.1-py3-none-any.whl", hash = "sha256:85a58546c831f3dce6cf919468c26874c9065e99c42279fb4abb8e1b552a98bb", size = 17164660, upload-time = "2026-06-29T12:48:41.21Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -2723,18 +2956,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] -[[package]] -name = "mako" -version = "1.3.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -2823,7 +3044,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.11.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -2837,53 +3058,65 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261, upload-time = "2026-06-12T02:27:34.161Z" }, - { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669, upload-time = "2026-06-12T02:27:37.413Z" }, - { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076, upload-time = "2026-06-12T02:27:39.926Z" }, - { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999, upload-time = "2026-06-12T02:27:42.433Z" }, - { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103, upload-time = "2026-06-12T02:27:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945, upload-time = "2026-06-12T02:27:46.867Z" }, - { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304, upload-time = "2026-06-12T02:27:48.798Z" }, - { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, - { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, - { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, - { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, - { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, - { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, - { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, - { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, - { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, - { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, - { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, - { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, - { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, - { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137, upload-time = "2026-06-12T02:28:37.93Z" }, - { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514, upload-time = "2026-06-12T02:28:40.028Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005, upload-time = "2026-06-12T02:28:42.39Z" }, - { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459, upload-time = "2026-06-12T02:28:44.483Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160, upload-time = "2026-06-12T02:28:46.564Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186, upload-time = "2026-06-12T02:28:49.344Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349, upload-time = "2026-06-12T02:28:51.382Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921, upload-time = "2026-06-12T02:28:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190, upload-time = "2026-06-12T02:28:55.623Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181, upload-time = "2026-06-12T02:28:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715, upload-time = "2026-06-12T02:29:00.555Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427, upload-time = "2026-06-12T02:29:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809, upload-time = "2026-06-12T02:29:04.994Z" }, - { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226, upload-time = "2026-06-12T02:29:07.033Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d0/791aa183dd88491555cf7d4be0b52b0bcf6c3c2a2c22c815a2e819bf53e2/matplotlib-3.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2", size = 9440302, upload-time = "2026-07-18T03:38:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/82bbdf683a301f4478384c8aaba6903631a2ca18294b2d7655c9a542bffb/matplotlib-3.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c", size = 9268549, upload-time = "2026-07-18T03:38:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/9b4298911303f74e6d83e64a81d996c0616405ec95046fac7f17e4258b9e/matplotlib-3.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83", size = 10024922, upload-time = "2026-07-18T03:38:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/84/6f/0bc3c3d05b021db44c14bc379a7c0df7d57302aa15380c16fd4e63fd6a9b/matplotlib-3.11.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099", size = 10832170, upload-time = "2026-07-18T03:38:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e375f39acdb2af5a9342730618608e39790ec842e6f1b392863028781459/matplotlib-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407", size = 10916701, upload-time = "2026-07-18T03:38:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/bc/be/fa26ed085b41298f64a8f9b7592c671bbf1acc8b0df124c1c5de96b859f8/matplotlib-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f", size = 9315331, upload-time = "2026-07-18T03:38:14.949Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eb5bdf3b6e191b200db298b08bbc1638b7f3c82cdc8680f9d88bf72559ae/matplotlib-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18", size = 9003475, upload-time = "2026-07-18T03:38:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/4798363b7fb5644e309fe1fac30216e9146c9f70859d80d588c18caf5317/matplotlib-3.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191", size = 9454341, upload-time = "2026-07-18T03:38:35.001Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/6acadbe7f98df19d274bc107ac58bb439fa75df82c33dc110d71a4a8501f/matplotlib-3.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1", size = 9283627, upload-time = "2026-07-18T03:38:37.061Z" }, + { url = "https://files.pythonhosted.org/packages/24/ea/65cec46fe241390ccea1b1754207ee28eb71c5ab866bd5f22fe47e538fa4/matplotlib-3.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3", size = 10035860, upload-time = "2026-07-18T03:38:39.663Z" }, + { url = "https://files.pythonhosted.org/packages/c7/10/63fdccccbabe002fb0960876baabc5e3f24d9c1bb4cfb25651457f74b3a0/matplotlib-3.11.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e", size = 10843594, upload-time = "2026-07-18T03:38:42.144Z" }, + { url = "https://files.pythonhosted.org/packages/98/51/a1155945bff7b91381875022ac1522c5dfdac0d006be8e7df389b3134eae/matplotlib-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f", size = 10927962, upload-time = "2026-07-18T03:38:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3a/3d5e1f42dc761bf53401a62a83ff93389b37de9d2c093b2a3aa49ac34f1b/matplotlib-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b", size = 9334074, upload-time = "2026-07-18T03:38:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/3f5ea5a5b64060ef5e1ff60a19170423e41ce21b8497a6fe15a36e0b43e3/matplotlib-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2", size = 9007662, upload-time = "2026-07-18T03:38:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/c7ae5e0531425b69c0826b00ebbc264c85cab853f1cd6e096c9983c2cdc1/matplotlib-3.11.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f", size = 9503790, upload-time = "2026-07-18T03:38:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/15be162e0a2ed546939674e2e97d0e33ec2447d86d4d4e611fa295bb178c/matplotlib-3.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1", size = 9336148, upload-time = "2026-07-18T03:38:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7f/36ffe144fc4aacfe0e3ed2318f72b6755d1e73b041d619b4d393e60f5a66/matplotlib-3.11.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464", size = 10049244, upload-time = "2026-07-18T03:38:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/55812d68c0a840d3a463638f48c00ab1fe338518ec49a640cb6473b444af/matplotlib-3.11.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf", size = 10860798, upload-time = "2026-07-18T03:38:58.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/64/cca444b4eb5e6c768c44fc5e1f0b5211f20ca2b282778051996e996a2bdf/matplotlib-3.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f", size = 10943282, upload-time = "2026-07-18T03:39:00.465Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0f/a49c329d394f2e9ef38506982107e8b04ecf94dd41a9d8423ff82cc737c7/matplotlib-3.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78", size = 9383532, upload-time = "2026-07-18T03:39:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/e4/50/103e86afb806d8f64d04ede14e4cfc09dbfc25f512421ff85fdd6ebd59cf/matplotlib-3.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a", size = 9059665, upload-time = "2026-07-18T03:39:04.607Z" }, + { url = "https://files.pythonhosted.org/packages/35/04/3079499fa8cb661ea66d13d6439d5a3ae6710a7afd5c7f72e08914f275f8/matplotlib-3.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c492d4ba9448595b6fd8708c6725963f8148e25c0d8842948da5b05f0ee8d3", size = 9456022, upload-time = "2026-07-18T03:39:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319", size = 9285475, upload-time = "2026-07-18T03:39:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/31b15a2ca56d4ddd6aaa1c884c2f51cf9a61cfaf5ca6f6fbd6343d38e6df/matplotlib-3.11.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be943cb68bc6660ead58c55b3aa6366cba2ef7feb06460fbcce32360376f19f", size = 10847102, upload-time = "2026-07-18T03:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be", size = 11131087, upload-time = "2026-07-18T03:39:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/97/c5/5e100efdd67abb7de20befaa333612ef9bfc63417fb71398f904f25d083c/matplotlib-3.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d3fd84082b1afbd9398466c81309e20045be20d48fe0fb18c43504d164cbbb2", size = 10929036, upload-time = "2026-07-18T03:39:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6", size = 9489571, upload-time = "2026-07-18T03:39:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/facabdc2f1f6caba7e856db64dfedddca25f7608df07d96a1c8fd114fd3b/matplotlib-3.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:ae30c6109848ac0f9fa36c5d6270938487614c47ba31860bd5361266dabc5685", size = 9164486, upload-time = "2026-07-18T03:39:21.424Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/18da6cd01cf96354534f98c468a25380c68ce582a2c9dd0cae12b04af4f2/matplotlib-3.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dadfe80797174e2984aae3be0b77594a3c72d2c0a40fbd4a0de48d2728caf3ae", size = 9504876, upload-time = "2026-07-18T03:39:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/b0/f0b63555a18b79d038c81fd6126f35fc4dfce0eaff48d96103348c7cf935/matplotlib-3.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:89b193b255f4f6f7948dbcee3691f4f341ab05d9a8874a67b45ddb4182922eda", size = 9336120, upload-time = "2026-07-18T03:39:25.797Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dd/f210ec7c4a6f198d5567237048a93d0811fb5a1f1691f13320e592f95b41/matplotlib-3.11.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191163532cdefcb1571ca38a6d7e6474baccde64495783e6ba47aa07ec4b9bbb", size = 10858033, upload-time = "2026-07-18T03:39:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/d6d5324507c5fbb316db48e258c09c2807f3de03d9af47017e120070926f/matplotlib-3.11.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fdf1c818ab05d0e74002091ddaf414478a3a449ec9d51c8976d45be7e3a01e2", size = 11141827, upload-time = "2026-07-18T03:39:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/0f/68/3c22e9320bdce2c4d2f1320643ef706db7a24cb7420eea28b97a2d67f5a8/matplotlib-3.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b937b9dba5f5f6c1e31c47abe2186c865c0914fd18f2ce0dfc39c9adcef5951d", size = 10943061, upload-time = "2026-07-18T03:39:32.356Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/907ed190ee81a9df581e0ed5456134fc0f7cb55ffcfda2f9e54ca900761c/matplotlib-3.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f2912f647f3fbe1ccf085f91e213936f9101bead81a5e670565b1f1b3712f4fb", size = 9540074, upload-time = "2026-07-18T03:39:34.789Z" }, + { url = "https://files.pythonhosted.org/packages/23/d4/97c19b77e0a6e3b48581185bb65088f431cd20186076cc0f650a1757ea46/matplotlib-3.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987", size = 9213472, upload-time = "2026-07-18T03:39:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/ceb1d637c4db6d06141f3739e93af3321e7caaabe69b57ae48ffe3ee95b1/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741", size = 9438045, upload-time = "2026-07-18T03:39:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/72ad8b58602d3a6ef1dfc4b65ecd01634ab65a2bdf494c9fe0e966dbf081/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb", size = 9266127, upload-time = "2026-07-18T03:39:41.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] [[package]] @@ -2946,127 +3179,54 @@ image = [ ] [[package]] -name = "ml-dtypes" -version = "0.5.4" +name = "mistune" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/a5/2dab368d6950e6808904dec98f54c7e726ee7be4a0c6afe00e6e011bd52d/mistune-3.3.3.tar.gz", hash = "sha256:c4c6c0c840b8637a2e9b8b6d607eb7c8f00888bf14c754409bcd339e848c2477", size = 115363, upload-time = "2026-07-09T06:18:05.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" }, - { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" }, - { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" }, - { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742, upload-time = "2025-11-17T22:31:44.068Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709, upload-time = "2025-11-17T22:31:46.557Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/3c70881695e056f8a32f8b941126cf78775d9a4d7feba8abcb52cb7b04f2/ml_dtypes-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac", size = 676927, upload-time = "2025-11-17T22:31:48.182Z" }, - { url = "https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900", size = 5028464, upload-time = "2025-11-17T22:31:50.135Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, - { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, - { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, - { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, - { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, - { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" }, - { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" }, - { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" }, - { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" }, - { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" }, - { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" }, - { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/89/70/b1e4737b84163db5bb1dfde6f216dbfbf32783330a9989c965e121172830/mistune-3.3.3-py3-none-any.whl", hash = "sha256:99de1585e42dcbd826faa9e11a202727a5e202e4e4722a4c69ac1ff615793dd7", size = 63569, upload-time = "2026-07-09T06:18:03.839Z" }, ] [[package]] -name = "mlflow" -version = "3.13.0" +name = "ml-dtypes" +version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "alembic" }, - { name = "cryptography" }, - { name = "docker" }, - { name = "flask" }, - { name = "flask-cors" }, - { name = "graphene" }, - { name = "gunicorn", marker = "sys_platform != 'win32'" }, - { name = "huey" }, - { name = "matplotlib" }, - { name = "mlflow-skinny" }, - { name = "mlflow-tracing" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "pandas" }, - { name = "pyarrow" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "skops" }, - { name = "sqlalchemy" }, - { name = "waitress", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/69/d71afc475fa7e7b22bb27392247d2a3015c9da202dea44f150a54be4bd67/mlflow-3.13.0.tar.gz", hash = "sha256:a95198d592a8a15fad3db7f56b228acc9422c09f0daa7c6c976a9996ab73c3e2", size = 10086808, upload-time = "2026-06-01T05:55:09.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/1f/d44140128356f2f5db37f9fb4d9da31123d839d49f3fe00a079cd35bfe20/mlflow-3.13.0-py3-none-any.whl", hash = "sha256:7ca9cb2f623f300dabadaf5e985c85af77c5db3d7c36f56769d22c101b132f6c", size = 10788121, upload-time = "2026-06-01T05:55:06.86Z" }, ] - -[[package]] -name = "mlflow-skinny" -version = "3.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cachetools" }, - { name = "click" }, - { name = "cloudpickle" }, - { name = "databricks-sdk" }, - { name = "fastapi" }, - { name = "gitpython" }, - { name = "importlib-metadata" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlparse" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/13/840db21a4f46ebe6ba9837a38bc93d748e23b6b61986799c8040cd4bf728/mlflow_skinny-3.13.0.tar.gz", hash = "sha256:d2273bfa21f776359f7d6ab2267967e3a6732a5fb00996ad433d0e777dfa3b71", size = 2814837, upload-time = "2026-06-01T05:54:54.175Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/fd/f2739de1b6a09da981927aa90db87340cbe4b3cf6cd175fd5e6e4366208e/mlflow_skinny-3.13.0-py3-none-any.whl", hash = "sha256:ced3d9a580564fae093d14732df8531fb180574f6483d4c642b6083879eb86fc", size = 3365675, upload-time = "2026-06-01T05:54:52.166Z" }, -] - -[[package]] -name = "mlflow-tracing" -version = "3.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cachetools" }, - { name = "databricks-sdk" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/b0/5912313e895e6ce02f1f67110164d147593d1b5379a4767a30a5b9e730c5/mlflow_tracing-3.13.0.tar.gz", hash = "sha256:42c435b0fdcab00f1865cab4a52f7a85a2a08d68a959f36bcf90a1c9fe65db0a", size = 1393079, upload-time = "2026-06-01T05:54:44.906Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fd/2c53ebc2f7fbb34ed4a3913a71ff53962e6be35fa02f7cee1f52c77388cd/mlflow_tracing-3.13.0-py3-none-any.whl", hash = "sha256:2f8187ce2b1af7419be71d2d8ab5fec53d207d4b8d703cd15e5db64939098d72", size = 1664279, upload-time = "2026-06-01T05:54:42.911Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742, upload-time = "2025-11-17T22:31:44.068Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709, upload-time = "2025-11-17T22:31:46.557Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/3c70881695e056f8a32f8b941126cf78775d9a4d7feba8abcb52cb7b04f2/ml_dtypes-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac", size = 676927, upload-time = "2025-11-17T22:31:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900", size = 5028464, upload-time = "2025-11-17T22:31:50.135Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, + { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" }, + { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, ] [[package]] @@ -3330,6 +3490,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, ] +[[package]] +name = "nbclient" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -3365,6 +3589,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/93/a7b983643d1253bb223234b5b226e69de6cda02b76cdca7770f684b795f5/ninja-1.13.0-py3-none-win_arm64.whl", hash = "sha256:3c0b40b1f0bba764644385319028650087b4c1b18cdfa6f45cb39a3669b81aa9", size = 290806, upload-time = "2025-08-11T15:10:18.018Z" }, ] +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + [[package]] name = "numba" version = "0.65.0" @@ -3689,7 +3925,7 @@ name = "nvidia-cudnn-cu13" version = "9.19.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version < '3.15' or sys_platform != 'darwin'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, @@ -3720,7 +3956,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version < '3.15' or sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3750,9 +3986,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version < '3.15' or sys_platform != 'darwin'" }, - { name = "nvidia-cusparse", marker = "python_full_version < '3.15' or sys_platform != 'darwin'" }, - { name = "nvidia-nvjitlink", marker = "python_full_version < '3.15' or sys_platform != 'darwin'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3764,7 +4000,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version < '3.15' or sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4124,6 +4360,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/11/13adf2d02c681b599c1eb550b0dbd763d1b818a106a13bd693019bdb5637/outlines_core-0.2.14-cp314-cp314-win_amd64.whl", hash = "sha256:3e67fc23b1a3ac9562488fb50f409c171538b76f64aa5f7e25d9b0bf14770204", size = 2139979, upload-time = "2026-01-09T15:58:57.984Z" }, ] +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -4201,6 +4446,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/e0/99ec5b02203c4e9ce878bc63d8caa06ac1f891e4d63bded9a5ced70fcb4f/pandas_stubs-3.0.3.260530-py3-none-any.whl", hash = "sha256:a6277eb1c8cebf48d9b2413fcd2e9a6b4ff479c934a223c29eacbc3058c4cb55", size = 173780, upload-time = "2026-05-30T17:47:39.13Z" }, ] +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + [[package]] name = "partial-json-parser" version = "0.2.1.1.post7" @@ -4219,6 +4482,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -4307,24 +4582,21 @@ wheels = [ ] [[package]] -name = "pluggy" -version = "1.6.0" +name = "platformdirs" +version = "4.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/cd/4f25b2f95b23f5d2c9c1fe43e49841bff5800562149b2666afc09309aa8f/platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695", size = 31678, upload-time = "2026-07-18T03:53:43.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443", size = 22906, upload-time = "2026-07-18T03:53:42.576Z" }, ] [[package]] -name = "prettytable" -version = "3.17.0" +name = "pluggy" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -4349,6 +4621,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/ad/b14ed2fe73bb1d37bcc947e75afae018f795526415d5b849aeb99c403cd1/prometheus_fastapi_instrumentator-8.0.0-py3-none-any.whl", hash = "sha256:e3c967c402124dfe81cf5aad35208d9f96db5452c6cb752350d9358ca0a96198", size = 19411, upload-time = "2026-05-29T13:04:44.17Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -4504,83 +4788,30 @@ wheels = [ ] [[package]] -name = "py-cpuinfo" -version = "9.0.0" +name = "ptyprocess" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] [[package]] -name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, - { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, - { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, - { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, - { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, - { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, - { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.3" +name = "pure-eval" +version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] [[package]] -name = "pyasn1-modules" -version = "0.4.2" +name = "py-cpuinfo" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] [[package]] @@ -5044,6 +5275,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] +[[package]] +name = "pywinpty" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/5c/31feb3dd82d1b33ae0bd09ca601edb993d9da1b7f0226b3336d4b4c39e1e/pywinpty-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:af7a8720c78776ddd6259b71dd567944f766a6cd67f8d2887fbc4973967bacda", size = 2092466, upload-time = "2026-06-10T23:44:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fe/fe23e2229ffec0c10190cef5964f5c9b2dba179d23b69ae537b7ea90bcab/pywinpty-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:c2406f54f699eab75953fb75ce805f2ae55a33a957cd070890abd454fb4b7680", size = 818395, upload-time = "2026-06-10T23:41:56.93Z" }, + { url = "https://files.pythonhosted.org/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://files.pythonhosted.org/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -5321,6 +5572,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -5761,6 +6045,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + [[package]] name = "sentencepiece" version = "0.2.1" @@ -5928,32 +6221,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "skops" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "packaging" }, - { name = "prettytable" }, - { name = "scikit-learn" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c8/9f/46448c4e41a4c5ee4bdb74b3758af48e5ff0faeffe40f4e301bfc7594894/skops-0.14.0.tar.gz", hash = "sha256:6c8c0e047f691a3a582c3258943eecafcbfd79c8c7eef66260f3703e363254f0", size = 608084, upload-time = "2026-04-20T18:23:55.336Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/0e/3ae19fa941522cd98e119762e7181d371c8dba0b2d72bfaf9522692e329c/skops-0.14.0-py3-none-any.whl", hash = "sha256:60a5db78a9db46ccee2139a0ba13ab5afb1c96f4749b382e75a371291bbe3e36", size = 132198, upload-time = "2026-04-20T18:23:54.018Z" }, -] - -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -5964,60 +6231,12 @@ wheels = [ ] [[package]] -name = "sqlalchemy" -version = "2.0.50" +name = "soupsieve" +version = "2.9" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/5d/3172686af1770e4de2805f919a51441085f589ddadf3dd76ec582f84f497/sqlalchemy-2.0.50-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508", size = 2161366, upload-time = "2026-05-24T20:00:02.061Z" }, - { url = "https://files.pythonhosted.org/packages/0f/90/e98dedea3c3e663a17afcd003a34ba45efdac2cea3b6f2e4585e2b1e2537/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3", size = 3318926, upload-time = "2026-05-24T20:07:42.369Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4f/501308c2babb62c11753ecb4ee88ba9eef019419a4d6cbf7cb13e2bad353/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c", size = 3319199, upload-time = "2026-05-24T20:14:28.551Z" }, - { url = "https://files.pythonhosted.org/packages/ac/39/d88996c5e03ed6248c3a788d20f0b8d8b376b9f8a495e4bab9df7c72d2f8/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4", size = 3270301, upload-time = "2026-05-24T20:07:44.917Z" }, - { url = "https://files.pythonhosted.org/packages/42/1b/1ae0e65161b51cc43e5ca75430ef79d80e23b5042d645586c2c342c3b92e/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86", size = 3293465, upload-time = "2026-05-24T20:14:30.501Z" }, - { url = "https://files.pythonhosted.org/packages/83/29/17c0003f2c0dfa6d1b97672475707e3ec5980db09defd7fa20beb6833bbd/sqlalchemy-2.0.50-cp311-cp311-win32.whl", hash = "sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22", size = 2120694, upload-time = "2026-05-24T20:08:09.237Z" }, - { url = "https://files.pythonhosted.org/packages/c9/18/280d00654cc19d1fccf236fa5070f6dd04b84dde6f1b2e637bde0ff340a7/sqlalchemy-2.0.50-cp311-cp311-win_amd64.whl", hash = "sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5", size = 2145315, upload-time = "2026-05-24T20:08:10.952Z" }, - { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, - { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, - { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, - { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, - { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, - { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, - { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, - { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, - { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, - { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, - { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, - { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, - { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, -] - -[[package]] -name = "sqlparse" -version = "0.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, ] [[package]] @@ -6033,6 +6252,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -6076,6 +6309,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt' and sys_platform != 'darwin'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -6164,6 +6411,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/a7/f4bfb86f87e107703146e703204cec2c0eae2492b633e0052b0ace3febb6/tilelang-0.1.9-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:77ab0ee2f40f66ea015b6b21426d482751e28cbc635ef9d1198cbd6502454a7c", size = 42110365, upload-time = "2026-04-22T09:17:18.292Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + [[package]] name = "tokenizers" version = "0.22.2" @@ -6412,6 +6671,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/6a/09f3844c10643f6c0de5d95abc863420cfaf194c88c7dffd0ac523e2015f/torchvision-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e9d0e022c19a78552fb055d0414d47fecb4a649309b9968573daea160ba6869c", size = 4454275, upload-time = "2026-03-23T18:12:27.487Z" }, ] +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + [[package]] name = "tqdm" version = "4.68.2" @@ -6424,6 +6700,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" }, ] +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + [[package]] name = "transformers" version = "5.12.0" @@ -6537,6 +6822,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -6692,15 +6986,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/cd/c863370930b2043b7b7f67ff900c9e27c2265f3b3edd080d62c148d4bcb4/vllm-0.22.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cc50536c93827a577b2bc45f038b8b866c31ed220631008521a337c9b2558f4d", size = 261042548, upload-time = "2026-06-05T10:13:34.853Z" }, ] -[[package]] -name = "waitress" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, -] - [[package]] name = "watchfiles" version = "1.2.0" @@ -6807,11 +7092,38 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.8.1" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] [[package]] @@ -6873,18 +7185,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] -[[package]] -name = "werkzeug" -version = "3.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, -] - [[package]] name = "win32-setctime" version = "1.2.0" @@ -7107,12 +7407,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/91/33de49538444d4aafbe47415c450c2f9abab1733e1226f276b496672f46c/z3_solver-4.15.4.0-py3-none-win32.whl", hash = "sha256:be3bc916545c96ffbf89e00d07104ff14f78336e55db069177a1bfbcc01b269d", size = 13191672, upload-time = "2025-10-29T18:11:58.424Z" }, { url = "https://files.pythonhosted.org/packages/03/d6/a0b135e4419df475177ae78fc93c422430b0fd8875649486f9a5989772e6/z3_solver-4.15.4.0-py3-none-win_amd64.whl", hash = "sha256:00e35b02632ed085ea8199fb230f6015e6fc40554a6680c097bd5f060e827431", size = 16259597, upload-time = "2025-10-29T18:12:01.14Z" }, ] - -[[package]] -name = "zipp" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, -] diff --git a/website/_quarto.yml b/website/_quarto.yml new file mode 100644 index 0000000..188db40 --- /dev/null +++ b/website/_quarto.yml @@ -0,0 +1,41 @@ +project: + type: website + output-dir: _site + +website: + title: "Évaluation automatique de la dictée" + description: "Coder automatiquement la dictée manuscrite d'élèves avec des modèles d'IA, et en mesurer la fiabilité. DEPP × SSP Lab (INSEE)." + page-footer: + left: "DEPP × SSP Lab (INSEE)" + right: "Construit avec [Quarto](https://quarto.org)" + navbar: + background: primary + search: true + left: + - text: "Accueil" + file: index.qmd + - text: "Architecture" + file: architecture.qmd + - text: "Résultats" + file: resultats.qmd + - text: "Évaluation & métriques" + file: evaluation.qmd + - text: "Fine-tuning" + file: finetuning.qmd + +format: + html: + theme: + - cosmo + toc: true + toc-title: "Sur cette page" + toc-depth: 3 + number-sections: false + code-copy: true + code-overflow: wrap + fig-responsive: true + smooth-scroll: true + +execute: + freeze: auto + echo: true diff --git a/website/architecture.qmd b/website/architecture.qmd new file mode 100644 index 0000000..5f1de40 --- /dev/null +++ b/website/architecture.qmd @@ -0,0 +1,111 @@ +--- +title: "Architecture du projet" +subtitle: "Du scan de la copie à l'évaluation, étape par étape" +--- + +## Vue d'ensemble + +Le projet est une chaîne de traitement (*pipeline*) qui part d'une image de copie +et produit des métriques de fiabilité. Chaque expérience est entièrement décrite +par **un fichier de configuration** (YAML) : c'est ce qui la rend reproductible. + +![Vue d'ensemble du pipeline](/img/overview_pipeline.png) + +Deux principes de conception structurent tout le code : + +1. **Tout passe par la configuration.** Aucun chemin ni paramètre n'est écrit « en + dur » dans le code : un fichier YAML = un run reproductible, validé au + chargement (via *Pydantic*, qui vérifie que chaque champ a le bon type). +2. **Les deux approches partagent la même interface.** Elles produisent le même + type de sortie, donc elles sont mesurées par **exactement le même code**. La + comparaison est ainsi équitable : ce qu'on compare, c'est l'architecture, pas + deux tuyaux différents. + +## La brique centrale : l'interface `Scorer` + +Un *Scorer* est un objet qui sait « coder une copie ». Il expose une seule +méthode : `score_copy(copie, texte_de_référence) → prédictions`. Peu importe ce +qui se passe à l'intérieur (une passe ou deux étapes), la sortie a toujours la +même forme. + +| Implémentation | Fichier | Ce qu'elle fait | +|----------------|---------|-----------------| +| `VLMScorer` | `models/vlm.py` | Approche **end-to-end** : un appel, image → codes. | +| `TwoStageScorer` | `models/two_stage.py` | Approche **deux étapes** : transcription puis codage. | +| `build_scorer(...)` | `models/factory.py` | Choisit la bonne implémentation selon le YAML. | + +Grâce à cette abstraction, la boucle d'évaluation (`pipeline/benchmark.py`) ne +connaît qu'un `Scorer` générique : ajouter une nouvelle approche demain +n'impliquerait de toucher ni les métriques, ni le suivi, ni les notebooks. + +## Le déroulé d'un run + +Le module `pipeline/benchmark.py` orchestre chaque run : + +1. **Charger les données** : les imagettes (depuis S3) et le CSV des codes experts. +2. **Charger la grille** : le mot attendu pour chaque item, et le texte de référence. +3. **Coder chaque copie** via le `Scorer`, une copie à la fois. +4. **Normaliser les codes** au schéma choisi (`simplifiee` ou `complete`), des + **deux** côtés (modèle et expert), pour que la comparaison soit sur la même échelle. +5. **Écrire les prédictions** au fil de l'eau dans un fichier `.jsonl` (une ligne + par item × copie). +6. **Calculer les métriques** finales. + +### Robustesse : écriture incrémentale et reprise + +Un run complet (plusieurs milliers de copies × ~30 s) peut durer de longues heures. +Le benchmark est donc conçu pour **survivre aux incidents** : + +- **Écriture après chaque copie** (`flush + fsync`) : un crash ne perd que la copie + en cours, jamais celles déjà traitées. +- **Reprise automatique** : relancer la même commande saute les copies déjà + présentes dans le fichier de prédictions. +- **Isolation des erreurs** : une erreur d'API sur une copie est journalisée + (`_failed_copies.txt`) et la copie est sautée — le run continue, et cette copie + sera retentée au prochain lancement. + +## Organisation du dépôt + +``` +evaluation_dictee/ +├── configs/ Configurations d'expériences (YAML), une par run +│ ├── scoring/ → codage de la dictée +│ ├── htr/ → évaluation de la transcription (CER/WER) +│ ├── finetune/ → fine-tuning LoRA (GPU) +│ └── grille_dictee_2015.json → grille (mot attendu + fautes connues par item) +├── src/evaluation_dictee/ +│ ├── config.py Configs validées (Pydantic) + secrets (.env / Vault) +│ ├── data/ Chargement images (S3, TIFF), grille, codes experts +│ ├── models/ Les Scorer (vlm, two_stage) + la factory +│ ├── pipeline/ Prompts, boucle de benchmark, ré-alignement +│ ├── evaluation/ Métriques, statistiques, calibration, rapports HTML +│ ├── transcription/ Pipeline HTR indépendant (corpus Scoledit) +│ └── utils/ Journalisation, suivi Langfuse +├── scripts/ Points d'entrée en ligne de commande +├── notebooks/ Analyse interactive des résultats +├── tests/ Tests unitaires +└── docs/ Note méthodologique, décisions, grille de codage +``` + +## Suivi des expériences (traçabilité) + +Chaque run est tracé, pour qu'on puisse toujours retrouver *quoi* a produit *quel* +résultat : + +- **Langfuse** (inférence et évaluation) : une **session** par lancement → une + **trace** par copie (entrée, sortie, score d'accord) → une **génération** par + appel au modèle. Les métriques agrégées y sont enregistrées. +- **MLflow** (fine-tuning sur GPU) : les courbes d'entraînement (perte, taux + d'apprentissage) au fil des étapes. + +Cette séparation reflète les deux natures de calcul : l'**inférence/évaluation** +(passer un modèle existant sur des copies) et l'**entraînement** (modifier un +modèle), qui n'ont pas les mêmes besoins de suivi. + +## Un point d'attention sur les données + +Les images fournies sont des **TIFF bi-level (1 bit)** : chaque pixel est soit +noir soit blanc, sans nuances de gris. Cette binarisation rend certains **accents** +difficiles à lire (ils se confondent avec le bruit). Le chargement +(`data/loaders.py`) normalise systématiquement le format en entrée. C'est un +facteur à garder en tête pour interpréter les erreurs de lecture. diff --git a/website/evaluation.qmd b/website/evaluation.qmd new file mode 100644 index 0000000..b7aa36b --- /dev/null +++ b/website/evaluation.qmd @@ -0,0 +1,188 @@ +--- +title: "Évaluation de la prédiction" +subtitle: "Comment assurer la fiabilité de la prédiction " +--- + +Évaluer, ici, ce n'est pas seulement « le modèle a-t-il raison ». C'est répondre à +trois questions distinctes : + +1. **Le code prédit est-il fiable ?** (le modèle attribue-t-il les bons codes ?) +2. **La lecture est-elle fidèle ?** (le modèle lit-il correctement l'écriture ?) +3. **La indicateur de confiance est-il exploitable ?** (peut-on se fier au score de confiance + pour trier ce qu'on automatise et ce qu'on renvoie à un humain ?) + +Pour chaque question, des métriques différentes seront utilisées. + +--- + +## 1. Fiabilité du code prédit + +On compare, item par item, le code du modèle au code de l'**expert humain**, qui +sert de **vérité de terrain**. + +### Accord brut + +La proportion d'items où le modèle et l'expert donnent le **même** code. + +$$\text{accord brut} = \frac{\text{nombre d'items identiques}}{\text{nombre total d'items}}$$ + +Métrique simple à comprendre, mais **peu fiable** lorsqu'une catégorie domine. Si 90 % des mots +sont corrects, un modèle qui répond « correct » partout obtient 90 % d'accord sans +rien comprendre. + +### Kappa de Cohen + +Le **kappa** ($\kappa$) corrige l'accord brut de ce qu'on obtiendrait **par pur +hasard**. C'est la métrique de référence du projet. + +$$\kappa = \frac{p_o - p_e}{1 - p_e}$$ + +où $p_o$ est l'accord observé et $p_e$ l'accord attendu par hasard. + +| Valeur de κ | Interprétation courante | +|:-----------:|-------------------------| +| < 0,20 | accord faible | +| 0,20 – 0,40 | accord passable | +| 0,40 – 0,60 | accord modéré | +| 0,60 – 0,80 | accord substantiel | +| > 0,80 | accord presque parfait | + +::: {.callout-tip appearance="simple"} +**Repère du projet.** Sur le test pilote, la grille simplifiée atteint κ ≈ 0,88 — +un accord presque parfait. C'est le niveau à retrouver (ou dépasser) à grande échelle. +::: + +### Matrice de confusion + +Un tableau qui croise les codes de l'expert (lignes) et ceux du modèle (colonnes). +La diagonale, ce sont les accords ; **hors diagonale**, ce sont les erreurs, et +surtout **leur nature**. + +Elle répond à des questions comme : « le modèle confond-il +plutôt *correct* et *erreur*, ou *erreur* et *absent* ? ». + +### F1 pondéré + +Une synthèse de la **précision** (quand le modèle dit « erreur », a-t-il raison ?) +et du **rappel** (parmi les vraies erreurs, combien le modèle en attrape-t-il ?). +Le « pondéré » tient compte du fait que les catégories sont déséquilibrées. + +::: {.callout-note appearance="simple"} +**Les deux erreurs n'ont pas le même coût.** Rater une faute (mauvais **rappel**) +et signaler une faute qui n'existe pas (mauvaise **précision**) n'ont pas les mêmes +conséquences pour la DEPP. On regarde donc les deux séparément, pas seulement leur +moyenne. +::: + +--- + +## 2. Fidélité de la lecture (HTR) + +Ici on compare la **transcription** produite par le modèle à une transcription de +**référence humaine**. Les deux préservent les fautes de l'élève : on mesure la +**lecture**, pas l'orthographe. + +### CER — taux d'erreur au niveau caractère + +*Character Error Rate.* Le nombre minimal de corrections (insérer, supprimer, +remplacer un caractère) pour transformer la transcription du modèle en la référence, +divisé par la longueur de la référence. + +$$\text{CER} = \frac{\text{insertions} + \text{suppressions} + \text{substitutions (caractères)}}{\text{nombre de caractères de la référence}}$$ + +**0 = transcription parfaite.** 0,10 signifie « environ un caractère sur dix à corriger ». + +### WER — taux d'erreur au niveau mot + +*Word Error Rate.* La même idée, mais en comptant les **mots** entiers. Plus sévère +que le CER : un seul caractère faux rend tout le mot faux. + +### Variantes normalisées + +On calcule aussi CER et WER après **normalisation** (minuscules, sans accents ni +ponctuation). Comparer la version brute et la version normalisée permet de +**distinguer** les vraies erreurs de lecture des simples différences de casse ou +d'accent — particulièrement utile ici, car la binarisation des scans rend les +accents difficiles à lire. + +### Taux de sur-correction + +::: {.callout-important appearance="simple"} +**La métrique critique pour l'écriture d'enfants.** La sur-correction, c'est quand +le modèle « corrige » silencieusement l'élève : il lit *maison* là où l'enfant a +écrit *maisson*. Le texte produit est correct, mais **faux** pour notre usage : +on perd précisément la faute qu'on voulait détecter. Un bon transcripteur doit +reproduire les fautes, pas les gommer. +::: + +--- + +## 3. Fiabilité de la confiance + +Chaque code prédit s'accompagne d'un **score de confiance**. L'objectif final du +projet est d'**automatiser les items sûrs** et de **renvoyer les items douteux** à +un correcteur humain. Pour cela, encore faut-il que la confiance soit *fiable*. + +### Diagramme de fiabilité + +On regroupe les items par tranche de confiance (0–10 %, 10–20 %, …) et, dans chaque +tranche, on compare la **confiance annoncée** à l'**accord réellement observé**. + +Un modèle **bien calibré** est sur la diagonale : quand il annonce 80 % de +confiance, il a effectivement raison ~80 % du temps. S'il est au-dessus de sa +performance réelle, il est **trop sûr de lui** (dangereux : on automatiserait des +erreurs). + +### ECE — erreur de calibration attendue + +*Expected Calibration Error.* Un unique chiffre qui résume le diagramme : l'écart +moyen (pondéré par le nombre d'items) entre confiance annoncée et accord observé. + +- **0 = parfaitement calibré.** +- **> ~0,10** : le score de confiance n'est pas fiable tel quel et doit être + **recalibré** avant de servir de seuil de décision. + +### Courbe de renvoi humain — le livrable décisionnel + +C'est **la** sortie qui guide la décision opérationnelle. Pour chaque **seuil** de +confiance, on mesure deux quantités : + +- le **taux de renvoi humain** : la part d'items renvoyés à un correcteur (ceux dont + la confiance est sous le seuil) ; +- le **taux d'erreur résiduel** : le taux d'erreur qui subsiste sur les items que + le modèle a **auto-validés** (au-dessus du seuil). + +```{mermaid} +flowchart LR + A["Tous les items codés
+ confiance"] --> B{"confiance ≥ seuil ?"} + B -->|oui| C["Auto-validé
(on mesure l'erreur résiduelle)"] + B -->|non| D["Renvoyé à un humain"] +``` + +Faire varier le seuil dessine un compromis : **plus on renvoie à l'humain, plus +l'erreur résiduelle baisse**. La DEPP peut ainsi choisir un point de +fonctionnement, par exemple : « je tolère 2 % d'erreur résiduelle — combien de +copies dois-je alors relire à la main ? » + +--- + +## 4. Toujours comparer à l'humain + +Une dernière exigence transversale : la performance de l'IA se lit **relativement à +la variabilité entre correcteurs humains**. Deux experts qui codent la même copie ne +sont pas toujours d'accord — c'est une borne naturelle. Un modèle qui atteint le +niveau d'accord *inter-humains* fait déjà « aussi bien qu'un humain ». On situe donc +systématiquement les métriques du modèle par rapport à ce plancher humain (estimé +via des modèles d'accord inter-codeurs, type Dawid & Skene). + +--- + +## Récapitulatif + +| Question | Métriques | Sens | +|----------|-----------|------| +| Le codage est-il fiable ? | Accord brut, **kappa**, matrice de confusion, F1 | plus haut = mieux | +| La lecture est-elle fidèle ? | **CER**, **WER** (+ normalisés), **sur-correction** | plus bas = mieux | +| La confiance est-elle exploitable ? | Diagramme de fiabilité, **ECE**, **courbe de renvoi** | ECE bas ; courbe = arbitrage | + +: Les trois familles de métriques {.striped .hover} diff --git a/website/finetuning.qmd b/website/finetuning.qmd new file mode 100644 index 0000000..12f84d9 --- /dev/null +++ b/website/finetuning.qmd @@ -0,0 +1,118 @@ +--- +title: "Fine-tuning" +subtitle: "Spécialiser un modèle à l'écriture manuscrite des enfants" +--- + +## Pourquoi fine-tuner ? + +Les modèles multimodaux « génériques » lisent bien un texte imprimé, mais l'écriture +**manuscrite d'enfants** (lettres mal formées, orthographe approximative, ratures) +est un cas particulier. Le **fine-tuning** consiste à poursuivre l'entraînement d'un +modèle existant sur nos propres données, pour qu'il **s'y spécialise**. + +Objectif précis ici : améliorer la **fidélité de lecture** (la transcription), pas +le codage. Le modèle doit apprendre à reproduire le texte de l'élève **fautes +comprises** — c'est la lecture qu'on entraîne, jamais la correction orthographique. + +::: {.callout-note appearance="simple"} +Le fine-tuning est une **phase ultérieure** du projet (méthode « D »). Il vient +après avoir établi ce que font les modèles de base, pour mesurer le gain qu'il apporte. +::: + +## Les données : Scoledit + +L'entraînement s'appuie sur le corpus **Scoledit**, qui couvre cinq niveaux (CP, +CE1, CE2, CM1, CM2). Pour chaque copie, on dispose du **scan** et d'une +**transcription de référence humaine** qui **préserve les fautes**. Le modèle +apprend à reproduire cette transcription à partir de l'image. + +### Découpage train / validation / test + +Les données sont séparées en trois ensembles, avec un découpage **stratifié par +niveau** : + +```{mermaid} +flowchart LR + A["Corpus Scoledit
CP → CM2"] --> B["Train — 70 %
le modèle apprend dessus"] + A --> C["Validation — 15 %
suivi pendant l'entraînement"] + A --> D["Test — 15 %
jamais vu, évaluation finale"] +``` + +- **Train (70 %)** : les exemples sur lesquels le modèle apprend. +- **Validation (15 %)** : sert à surveiller l'entraînement (le modèle progresse-t-il + vraiment, ou apprend-il « par cœur » ?). +- **Test (15 %)** : mis de côté, **jamais vu** pendant l'entraînement, pour une + mesure finale honnête. + +::: {.callout-tip appearance="simple"} +**Pourquoi stratifier par niveau ?** Sans précaution, un niveau peu représenté +(disons le CP) pourrait se retrouver **uniquement** dans le train. On ne saurait +alors rien de la performance du modèle sur ce niveau. La stratification garantit que +chaque niveau est présent dans les trois ensembles, dans les mêmes proportions. +::: + +## La méthode : QLoRA + +Fine-tuner un gros modèle « en entier » demande énormément de mémoire GPU. Deux +techniques combinées rendent l'opération abordable : + +- **LoRA** (*Low-Rank Adaptation*) : au lieu de modifier **tous** les poids du + modèle, on n'entraîne que de **petits modules additionnels** (les *adaptateurs*). + Le résultat est un fichier léger (~50–200 Mo) qui se « branche » par-dessus le + modèle de base au moment de l'inférence. +- **QLoRA** : LoRA appliqué à un modèle chargé en **4 bits** (une représentation + compressée des poids). La mémoire nécessaire chute d'environ 60 Go à ~20 Go. + +```{mermaid} +flowchart LR + A["Modèle de base
(figé, chargé en 4 bits)"] --> C["Adaptateurs LoRA
(seule partie entraînée)"] + B["Images + transcriptions
Scoledit"] --> C + C --> D["Adaptateur fine-tuné
(~50–200 Mo)"] +``` + +Un choix important pour l'HTR : on entraîne **à la fois** les couches de *vision* +(pour mieux voir l'écriture enfantine) **et** les couches de *langage* (pour +produire un texte fidèle). Cibler uniquement le langage ne suffirait pas. + +## Reproductibilité : un point crucial + +Le prompt utilisé **pendant l'entraînement** doit être **identique** à celui utilisé +**à l'inférence**. Si le modèle apprend avec une consigne et est interrogé avec une +autre, ses performances s'effondrent. Ce prompt est donc fixé et versionné. + +Chaque run enregistre par ailleurs la liste exacte des copies de chaque ensemble +(`splits.json`), pour pouvoir rejouer précisément la même expérience. + +## Suivi de l'entraînement + +Contrairement au reste du projet (tracé dans Langfuse), le fine-tuning est suivi +via **MLflow**, qui enregistre les **courbes d'entraînement** : la *perte* (l'erreur +que le modèle cherche à minimiser) et le *taux d'apprentissage*, au fil des étapes. +On y lit si l'apprentissage progresse et se stabilise. + +## Prérequis et lancement + +::: {.callout-warning appearance="simple"} +**GPU requis.** Ce script s'exécute sur un service **GPU** du SSP Cloud (H100 80 Go +recommandé). Ne pas le lancer sur un service CPU. +::: + +```bash +# Installation des dépendances GPU (sur le service GPU) +uv pip install unsloth trl peft transformers bitsandbytes \ + accelerate datasets pillow s3fs pyyaml pydantic mlflow + +# Lancement du fine-tuning +uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml +``` + +Tous les hyperparamètres (nombre d'époques, taille de lot, taux d'apprentissage, +rang LoRA…) sont pilotés par le fichier de configuration `finetune_REFERENCE.yaml`, +exhaustivement commenté et prévu pour être copié. + +## Et ensuite ? + +Une fois l'adaptateur produit, on **réévalue** la transcription (CER/WER, et surtout +sur-correction) sur l'ensemble de **test**, puis on compare au modèle de base : le +gain de lecture justifie-t-il le coût du fine-tuning ? Ces résultats viendront +enrichir les tableaux de la page [Résultats](resultats.qmd). diff --git a/website/index.qmd b/website/index.qmd new file mode 100644 index 0000000..e57beee --- /dev/null +++ b/website/index.qmd @@ -0,0 +1,83 @@ +--- +title: "Évaluation automatique de la dictée" +subtitle: "Coder les écrits d'élèves (dictées, recopie et rédaction) avec des modèles d'IA à partir d'une grille de notation" +--- + +::: {.callout-note appearance="simple"} +Projet **DEPP × SSP Lab (INSEE)**. Ce site présente les principaux points importants du projet : +une présentation générale, l'architecture du pipeline de travail, +les résultats, la démarche d'évaluation et le fine-tuning. +Pour l'instant, seule l'évaluation des copies de dictées est traitée. A terme, toutes les copies (dictée, recopie et rédaction) pourront être évaluées. +::: + +## Le problème en une phrase + +Depuis 2015, la **DEPP** (Direction de l'évaluation, de la prospective et de la +performance du Ministère de l'Éducation nationale) corrige à la main la dictée +d'un échantillon d'élèves de CM2. Cette correction est **entièrement manuelle**, +coûteuse (5 à 10 minutes par copie), et donc difficile à passer à l'échelle. + +**La question du projet** : est-ce que l'IA peut être un nouvel outil permettant d'accélérer +l'évaluation des copies et être cfomplémentaire à la correction manuelle faite par les professeurs ? + +## La tâche, concrètement + +Chaque **item** (un mot ou un signe de ponctuation) écrit par l'élève reçoit un **code**. +Dans un premier temps, la cible principale du projet est une **grille simplifiée** à trois modalités : + +| Code | Signification | +|:----:|---------------| +| **1** | Orthographe correcte | +| **9** | Erreur (lexicale, grammaticale, ou les deux) | +| **0** | Mot absent (oubli de l'élève) | + +: La grille de codage simplifiée {.striped .hover} + +::: {.callout-tip appearance="simple"} +**Pourquoi travailler sur une grille simplifiée ?** La grille complète distingue erreur *lexicale*, +*grammaticale* et *mixte* (codes 3/4/5). Sur le test pilote, regrouper ces trois +codes en un seul (« erreur ») permet de supprimer exactement la zone où les correcteurs +humains eux-mêmes divergent le plus. +::: + +L'entrée du modèle est **l'image scannée** de la copie ; la référence est le +**texte de la dictée** (connu à l'avance) et une **grille** qui donne, pour chaque +item, le mot attendu (grille de codage). La sortie est un code par item, accompagné d'un **score de +confiance**. + +## Deux approches comparées + +Le cœur méthodologique du projet est la comparaison de deux +méthodes de prédiction des erreurs dans les écrits d'élèves, évaluées par exactement le même code de métriques : + +::: {.grid} + +::: {.g-col-12 .g-col-md-6} +### Approche end-to-end +Un seul modèle multimodal lit l'image **et** produit directement le code de chaque +item, en une passe. Simple, rapide, approche **par défaut**. +::: + +::: {.g-col-12 .g-col-md-6} +### Approche en deux étapes +Étape 1 : un modèle **transcrit** l'image en texte brut (lecture, ou *HTR*). +Étape 2 : un modèle **code** ce texte. On sépare ainsi *lire* et *juger*. +::: + +::: + +Ces deux approches sont détaillées dans la page **[Résultats](resultats.qmd)**. + +## Comment lire ce site + +| Page | Ce que vous y trouverez | +|------|-------------------------| +| **[Architecture](architecture.qmd)** | Comment le projet est organisé : du scan à la métrique, brique par brique. | +| **[Résultats](resultats.qmd)** | Les deux approches en détail, et les tableaux de comparaison (à compléter). | +| **[Évaluation & métriques](evaluation.qmd)** | Chaque métrique expliquée : ce qu'elle mesure et pourquoi. | +| **[Fine-tuning](finetuning.qmd)** | Spécialiser un modèle à l'écriture d'enfants. | + +::: {.callout-important appearance="simple"} +**Données sensibles.** Les copies sont des écritures d'élèves **mineurs**. Les +microdonnées ne quittent **jamais** le SSP Cloud et ne sont jamais versionnées. +::: diff --git a/website/resultats.qmd b/website/resultats.qmd new file mode 100644 index 0000000..2134921 --- /dev/null +++ b/website/resultats.qmd @@ -0,0 +1,266 @@ +--- +title: "Résultats des deux approches" +subtitle: "Deux façons de coder une copie, une seule grille de métriques" +--- + +```{python} +#| echo: false +#| warning: false +# Câblage des tableaux : on lit les prédictions exportées (S3 ou local) et on +# calcule les métriques à la volée. Les runs affichés sont surchargeables par +# variables d'environnement ; un run absent ou inaccessible retombe sur « — », +# ce qui garde la page rendable partout (y compris hors SSP Cloud). +import math +import os + +from evaluation_dictee.evaluation.report import load_predictions +from evaluation_dictee.evaluation.results_summary import htr_summary, scoring_summary + +PREFIX = os.environ.get( + "S3_PREDICTIONS_PREFIX", "s3://projet-production-ecrits-depp/predictions" +) +RUNS = { + "end_to_end": os.environ.get("RESULTATS_RUN_END_TO_END", "dictee_REFERENCE"), + "two_stage": os.environ.get("RESULTATS_RUN_TWO_STAGE", ""), + "htr_base": os.environ.get("RESULTATS_RUN_HTR_BASE", ""), + "htr_finetune": os.environ.get("RESULTATS_RUN_HTR_FINETUNE", ""), +} +NOTES: list[str] = [] + + +def _path(run: str, htr: bool = False) -> str: + suffix = "_htr_predictions.jsonl" if htr else "_predictions.jsonl" + return PREFIX.rstrip("/") + "/" + run + suffix + + +def _load(run: str, htr: bool): + """Charge et synthétise un run ; None si absent/inaccessible (avec note).""" + if not run: + return None + try: + df = load_predictions(_path(run, htr=htr)) + return htr_summary(df, run=run) if htr else scoring_summary(df, run=run) + except Exception as exc: # noqa: BLE001 — la page doit rester rendable + NOTES.append(f"`{run}` indisponible ({type(exc).__name__}).") + return None + + +def pct(x) -> str: + return "—" if x is None or (isinstance(x, float) and math.isnan(x)) else f"{x:.1%}" + + +def num(x, d: int = 3) -> str: + return "—" if x is None or (isinstance(x, float) and math.isnan(x)) else f"{x:.{d}f}" + + +def _col(summary, attr, formatter): + return "—" if summary is None else formatter(getattr(summary, attr)) +``` + +::: {.callout-note appearance="simple"} +**Tableaux calculés automatiquement** à partir des prédictions exportées sur S3 +(voir la commande d'export en bas de page). Chaque cellule affiche « — » tant que +le run correspondant n'a pas été exporté. Le seul run disponible à ce jour est un +échantillon de démonstration (~20 copies, **non représentatif**) : les chiffres +ci-dessous illustrent le format, pas la performance finale. +::: + +## Les deux approches en détail + +Le projet compare deux architectures pour passer de l'image au code. Toutes deux +respectent la même interface et sont donc évaluées par le même code de métriques : +la comparaison porte bien sur l'**architecture**, à modèle égal. + +### Approche 1 — Deux étapes (transcription puis codage) + +On sépare explicitement la **lecture** et le **jugement** : + +```{mermaid} +flowchart LR + A["Image de la copie"] --> B["Étape 1 — HTR
transcription du texte brut
(fautes comprises)"] + B --> C["Texte transcrit"] + C --> D["Étape 2 — codage
texte + référence → codes"] + R["Texte de référence"] --> D + D --> E["Code par item"] +``` + +- **Étape 1 (HTR)** : un modèle multimodal lit l'image et restitue le texte + **exactement tel que l'élève l'a écrit**, fautes comprises. Il ne connaît ni le + texte de référence ni le découpage en items : on mesure la **lecture pour + elle-même**. +- **Étape 2 (codage)** : un modèle reçoit cette transcription **et** le texte de + référence, puis code chaque item. Il ne voit pas l'image. Ce peut être un modèle + de texte plus léger (champ `model_stage2`). + +| Avantages | Limites | +|-----------|---------| +| La transcription intermédiaire est **inspectable** (on voit ce que le modèle a lu). | Deux étapes = **deux sources d'erreur** qui se cumulent. | +| Les erreurs de **lecture** et de **jugement** sont découplées. | Deux appels au modèle = coût et latence plus élevés. | +| L'étape 2 peut utiliser un modèle texte moins cher. | | + +### Approche 2 — End-to-end (une seule passe) + +Un unique modèle multimodal (*VLM*) reçoit l'image, le texte de référence et la +grille, et produit **directement** le code de chaque item. + +```{mermaid} +flowchart LR + A["Image de la copie"] --> C["VLM
lit ET code
en une passe"] + R["Texte de référence + grille"] --> C + C --> E["Code + confiance par item"] +``` + +| Avantages | Limites | +|-----------|---------| +| **Un seul appel** : plus simple, plus rapide, moins cher. | La lecture est « fondue » dans le codage : moins facile à diagnostiquer. | +| Le modèle voit l'image **et** la référence en même temps. | Risque de **sur-correction** (le modèle « corrige » silencieusement l'élève). | + +::: {.callout-note appearance="simple"} +C'est l'approche **par défaut** du projet (la méthode « C » : image + référence + +grille → codes). +::: + +## Zoom sur la transcription (HTR) + +**HTR** = *Handwritten Text Recognition*, la reconnaissance d'écriture manuscrite. +C'est la brique qui transforme une image en texte. Elle joue un rôle **dans +l'approche 1** (étape 1), et peut aussi être évaluée **seule** pour comparer les +modèles sur la seule capacité à lire. + +Le point délicat, propre à l'écriture d'enfants : on veut une transcription +**fidèle**, c'est-à-dire qui **préserve les fautes**. Un modèle qui « corrige » +spontanément l'orthographe est un mauvais transcripteur ici, même s'il produit un +français correct — c'est le biais de **sur-correction**, surveillé de près (voir +la page [Évaluation](evaluation.qmd)). + +L'évaluation de la transcription se fait sur le corpus **Scoledit**, qui fournit +des transcriptions de référence **humaines** (fautes préservées), du CP au CM2. +On mesure la qualité de lecture par le **CER** et le **WER** (définis dans la page +[Évaluation](evaluation.qmd)). + +## Tableau de comparaison — codage de la dictée + +Comparaison des deux approches, **à modèle égal** (pour isoler l'effet de +l'architecture). Chiffres calculés sur les prédictions exportées. + +```{python} +#| echo: false +#| output: asis +e2e = _load(RUNS["end_to_end"], htr=False) +two = _load(RUNS["two_stage"], htr=False) + +# (libellé, attribut de synthèse, formateur, lecture). Les deux dernières lignes +# ne sont pas dérivables du JSONL de prédictions (copies non transcrites exclues à +# l'écriture ; coût/latence non tracés ici) → « — » assumé. +ROWS = [ + ("Accord brut", "raw_agreement", pct, "plus haut = mieux"), + ("Kappa de Cohen", "cohen_kappa", num, "plus haut = mieux"), + ("Rappel des fautes", "recall_errors", pct, "plus haut = moins de fautes ratées"), + ("Précision sur les fautes", "precision_errors", pct, "plus haut = moins de fausses alertes"), + ("Taux de sur-correction", "overcorrection_rate", pct, "plus **bas** = mieux"), + ("ECE (calibration)", "ece", num, "plus **bas** = confiance plus fiable"), +] + +lines = [ + "| Métrique | Approche end-to-end | Approche two-stage | Lecture |", + "|----------|:-------------------:|:------------------:|---------|", +] +for label, attr, fmt, lecture in ROWS: + lines.append(f"| {label} | {_col(e2e, attr, fmt)} | {_col(two, attr, fmt)} | {lecture} |") +# Lignes non dérivables des prédictions (pour mémoire, cf. note sous le tableau). +lines.append("| Copies non transcrites | — | — | plus **bas** = mieux |") +lines.append("| Coût / latence par copie | — | — | plus **bas** = mieux |") +lines.append("") +lines.append(": Codage de la dictée — end-to-end vs two-stage {.striped .hover}") + +n_info = " ; ".join( + f"{name} : {s.n_copies} copies, {s.n_items} items" + for name, s in (("end-to-end", e2e), ("two-stage", two)) + if s is not None +) +print("\n".join(lines)) +if n_info: + print(f"\nEffectifs — {n_info}.") +``` + +::: {.callout-note appearance="simple"} +Les lignes **Copies non transcrites** et **Coût / latence** ne sont pas dérivables +du JSONL de prédictions (les copies non transcrites sont exclues à l'écriture, le +coût/latence n'y est pas tracé) : elles restent à renseigner depuis les logs de run +ou Langfuse. +::: + +## Tableau de comparaison — transcription seule (HTR) + +Qualité de lecture par modèle, sur Scoledit. Chiffres calculés sur les prédictions +HTR exportées (`_htr_predictions.jsonl`). + +```{python} +#| echo: false +#| output: asis +htr_rows = [ + ("modèle de base", _load(RUNS["htr_base"], htr=True)), + ("modèle fine-tuné", _load(RUNS["htr_finetune"], htr=True)), +] + +lines = [ + "| Modèle | CER | WER | CER normalisé | WER normalisé | Sur-correction |", + "|--------|:---:|:---:|:-------------:|:-------------:|:--------------:|", +] +for label, s in htr_rows: + cer = _col(s, "cer", pct) + wer = _col(s, "wer", pct) + cer_n = _col(s, "cer_normalise", pct) + wer_n = _col(s, "wer_normalise", pct) + # Sur-correction HTR : non présente dans le JSONL de prédictions → « — ». + lines.append(f"| _{label}_ | {cer} | {wer} | {cer_n} | {wer_n} | — |") +lines.append("") +lines.append(": Transcription (HTR) sur Scoledit — plus bas = mieux {.striped .hover}") +print("\n".join(lines)) +``` + +## Comment produire ces chiffres + +**1. Lancer les runs**, puis **2. exporter les prédictions sur S3** — c'est cette +exportation que la présente page relit pour remplir les tableaux (aucun pipeline +n'est réexécuté au rendu du site) : + +```bash +# Codage de la dictée (approche définie dans le YAML : end_to_end ou two_stage) +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml +uv run scripts/export_predictions.py --config configs/scoring/dictee_REFERENCE.yaml + +# Transcription seule (CER/WER) sur Scoledit +uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml +uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr +``` + +**3. Choisir les runs affichés** via des variables d'environnement (sinon +`end_to_end` = `dictee_REFERENCE`, les autres vides → « — ») avant de rendre le site : + +```bash +export RESULTATS_RUN_END_TO_END=dictee_gemma4_zeroshot +export RESULTATS_RUN_TWO_STAGE=dictee_gemma4_two_stage +export RESULTATS_RUN_HTR_BASE=htr_gemma4_base +export RESULTATS_RUN_HTR_FINETUNE=htr_gemma4_finetune +uv run quarto render website/resultats.qmd # ou : quarto preview website +``` + +L'analyse détaillée (distributions, intervalles de confiance, matrices de +confusion, graphiques) se fait ensuite dans les **notebooks** : + +- `notebooks/03_analyse_resultats.ipynb` — métriques globales et par item ; +- `notebooks/04_diagnostic.ipynb` — inspection copie par copie ; +- `notebooks/05_analyse_transcription_htr.ipynb` — analyse HTR. + +## Ce qu'on cherche à conclure + +Au-delà des chiffres bruts, le projet vise trois décisions : + +1. **Quelle approche** (end-to-end ou deux étapes) offre le meilleur compromis + fiabilité / coût ? +2. **Quel modèle** lit le mieux l'écriture d'enfants (et le fine-tuning + améliore-t-il assez la lecture pour justifier son coût) ? +3. **Où placer le seuil** de renvoi vers un correcteur humain, pour garantir un + taux d'erreur résiduel acceptable tout en automatisant le maximum de copies + (voir la **courbe de renvoi** dans la page [Évaluation](evaluation.qmd)).