From cea14268dbb41797de4c90ee1a73ea172e530a0e Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Fri, 26 Jun 2026 11:52:20 +0000 Subject: [PATCH 01/14] up doc --- .gitignore | 5 +++++ README.md | 13 +++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 5428727..3ffb305 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 diff --git a/README.md b/README.md index 540e5fb..4d1d08c 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,12 @@ La tâche cible est la **grille simplifiée** : `1` correct / `9` erreur / `0` a ```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 @@ -55,11 +56,11 @@ 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 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 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)" @@ -68,7 +69,7 @@ messages=[{'role':'user','content':'Dis bonjour'}], max_tokens=10).choices[0].me ### 3. Lancer un benchmark ```bash -python scripts/run_benchmark.py --config configs/dictee_gemma4_zeroshot.yaml +uv run scripts/run_benchmark.py --config configs/dictee_gemma4_zeroshot.yaml ``` Cela produit `data/processed/dictee_gemma4_zeroshot_predictions.jsonl` From def5c4c962182fd5286e6385fe419fcb3c13f7ba Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Fri, 26 Jun 2026 11:52:53 +0000 Subject: [PATCH 02/14] maj config eval avec qwen --- configs/dictee_qwen7b_zeroshot.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/configs/dictee_qwen7b_zeroshot.yaml b/configs/dictee_qwen7b_zeroshot.yaml index 8e96718..0b48340 100644 --- a/configs/dictee_qwen7b_zeroshot.yaml +++ b/configs/dictee_qwen7b_zeroshot.yaml @@ -6,19 +6,19 @@ name: dictee_qwen7b_zeroshot seed: 42 model: - name: Qwen/Qwen2.5-VL-7B-Instruct + name: qwen3-vl kind: vlm temperature: 0.0 - max_tokens: 2048 + max_tokens: 4096 # 83 items en JSON ~3400 tokens : 2048 tronque la reponse request_logprobs: true data: corpus: dictee # Chemins S3 (s3://...) ou locaux. Voir .env pour les identifiants S3. - images_path: data/raw/dictee_2015 - labels_path: data/raw/resultat_dictee_2015.csv + 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: 50 # commencer petit ; mettre null pour tout le corpus + limit: 10 # commencer petit ; mettre null pour tout le corpus grid: scheme: simplifiee # cible principale 1/erreur/0 (voir CLAUDE.md §3) From 24faff31b248fd5fb264f4c5a083b2df7f10ebe6 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Fri, 26 Jun 2026 13:08:02 +0000 Subject: [PATCH 03/14] up --- pyproject.toml | 2 +- scripts/run_benchmark.py | 23 +++++++++++++++-------- src/evaluation_dictee/models/vlm.py | 6 +++++- uv.lock | 16 ++++++++-------- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1a423d9..36fe459 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "scikit-learn>=1.4", # Suivi d'expériences "mlflow>=2.12", - "langfuse>=2.30", + "langfuse>=4,<5", # Confort "tqdm>=4.66", "rich>=13.7", diff --git a/scripts/run_benchmark.py b/scripts/run_benchmark.py index a0d6324..df09f1e 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -12,6 +12,8 @@ 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.evaluation.calibration import referral_curve @@ -42,14 +44,19 @@ def main() -> None: scheme=config.grid.scheme, ) - 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( + { + "raw_agreement": result.metrics.raw_agreement, + "cohen_kappa": result.metrics.cohen_kappa, + } + ) + finally: + # Langfose envoie les traces de façon asynchrone : sans flush explicite, + # le script peut se terminer avant l'envoi et perdre les dernières traces. + get_client().flush() logger.info("Accord brut : %.1f%%", result.metrics.raw_agreement * 100) logger.info("Kappa de Cohen : %.3f", result.metrics.cohen_kappa) diff --git a/src/evaluation_dictee/models/vlm.py b/src/evaluation_dictee/models/vlm.py index 84d8734..60d9fa1 100644 --- a/src/evaluation_dictee/models/vlm.py +++ b/src/evaluation_dictee/models/vlm.py @@ -11,7 +11,7 @@ import io import json -from openai import OpenAI +from langfuse.openai import OpenAI from PIL import Image from evaluation_dictee.config import ModelConfig, PromptConfig @@ -100,6 +100,10 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: temperature=temp, max_tokens=self.model_config.max_tokens, messages=messages, + # Métadonnées Langfuse : retrouver une copie précise et compter + # les tentatives directement dans l'UI de tracing. + name="score_copy", + metadata={"copy_id": copy.copy_id, "attempt": attempt + 1}, ) content = response.choices[0].message.content or "{}" prediction = self._parse_response(copy, content) diff --git a/uv.lock b/uv.lock index aa3c249..9995230 100644 --- a/uv.lock +++ b/uv.lock @@ -1260,7 +1260,7 @@ wheels = [ ] [[package]] -name = "evaluation-ecrit" +name = "evaluation-dictee" version = "0.1.0" source = { editable = "." } dependencies = [ @@ -1301,7 +1301,7 @@ gpu = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, - { name = "langfuse", specifier = ">=2.30" }, + { name = "langfuse", specifier = ">=4,<5" }, { name = "mlflow", specifier = ">=2.12" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "numpy", specifier = ">=1.26" }, @@ -3689,7 +3689,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", marker = "sys_platform != 'darwin'" }, ] 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 +3720,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", marker = "sys_platform != 'darwin'" }, ] 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 +3750,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", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, ] 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 +3764,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", marker = "sys_platform != 'darwin'" }, ] 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" }, From 06212c491ab1958573c58f158a5076439a9c2b88 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Thu, 16 Jul 2026 12:51:53 +0000 Subject: [PATCH 04/14] add prompts langfuse versionning --- pyproject.toml | 1 + src/evaluation_dictee/pipeline/prompts.py | 402 ++++++++++++------ .../utils/add_langfuse_prompt.py | 56 +++ 3 files changed, 333 insertions(+), 126 deletions(-) create mode 100644 src/evaluation_dictee/utils/add_langfuse_prompt.py diff --git a/pyproject.toml b/pyproject.toml index 36fe459..ca01939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ gpu = [ [project.scripts] eval-ecrit = "evaluation_dictee.cli:app" +add-langfuse-prompt = "evaluation_dictee.utils.add_langfuse_prompt:push_prompts" [build-system] requires = ["hatchling"] diff --git a/src/evaluation_dictee/pipeline/prompts.py b/src/evaluation_dictee/pipeline/prompts.py index fb7fb43..fecb72a 100644 --- a/src/evaluation_dictee/pipeline/prompts.py +++ b/src/evaluation_dictee/pipeline/prompts.py @@ -9,13 +9,36 @@ 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. + +Versionnement Langfuse +---------------------- +Chaque prompt est un template « chat » (message system + message user) versionné +dans Langfuse. Le template est le MASQUE : il contient des placeholders +`{{variable}}` remplis à l'exécution. La logique conditionnelle (schéma de codage, +flags de PromptConfig) reste ICI, dans le code : elle décide de la VALEUR injectée +dans chaque variable (texte d'un bloc optionnel, ou chaîne vide). On garde donc un +seul prompt par fonction, pas une version par combinaison de flags. + +Les templates ci-dessous servent à la fois de source poussée vers Langfuse +(voir `utils/add_langfuse_prompt.py`) et de REPLI hors-ligne : si Langfuse est +indisponible (tests, incident), on compile la structure locale à l'identique. """ from __future__ import annotations +from typing import cast + +from langfuse import get_client +from langfuse.model import ChatMessageDict + from evaluation_dictee.config import PromptConfig from evaluation_dictee.data.reference 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" '- "1" : le mot (ou la ponctuation) attendu est présent et correctement orthographié ;\n' @@ -38,57 +61,241 @@ ) _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, 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}, ...]}' ) +_FORMAT_ITEMS_SIMPLE = ( + "Réponds UNIQUEMENT par un objet JSON, sans texte autour, de la forme :\n" + '{"items": [{"item_id": "...", "transcription": "ce que l\'élève a écrit", ' + '"code": "1", "confidence": 0.95}, ...]}\n' +) + +# 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" +) + + +# ───────────────────────────────────────────────────────────────────────────── +# Templates chat (masques versionnés dans Langfuse). Les {{...}} sont remplis +# par les fonctions build_* selon leurs inputs. Ces mêmes structures servent de +# repli hors-ligne. +# ───────────────────────────────────────────────────────────────────────────── + +_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}}" + ), + }, + { + "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.""" + 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).""" + 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], +) -> str: + """Récupère un prompt chat versionné dans Langfuse, le compile et l'aplatit. + + Le prompt est récupéré via son label de production. `fallback` est la structure + locale (identique à celle poussée dans Langfuse) : elle sert de repli si le + prompt n'existe pas encore ou si Langfuse est indisponible (tests, incident). + Le résultat est aplati en une seule chaîne car les appelants envoient le prompt + dans un unique bloc de texte, souvent accompagné de l'image. + + Args: + name: nom du prompt dans Langfuse. + fallback: messages du template local (repli et source de vérité hors-ligne). + variables: valeurs à injecter dans les placeholders {{...}}. + + Returns: + Le prompt compilé, messages system + user concaténé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 de message (sans "content") ; nos + # prompts n'en contiennent pas, on aplatit donc simplement le champ texte. + plain = cast("list[dict[str, object]]", messages) + return "\n\n".join(str(m["content"]) for m in plain if m.get("content")) def build_dictation_prompt( @@ -99,6 +306,10 @@ def build_dictation_prompt( ) -> str: """Construit le prompt d'évaluation d'une dictée (méthode C, end-to-end). + Charge le template versionné « dictee_methode_c » depuis Langfuse et le remplit + selon les inputs : les blocs conditionnels (fidélité, ratures, chain-of-thought) + et le schéma de codage déterminent la valeur injectée dans chaque variable. + Args: reference_text: texte exact de la dictée attendue. items: items de la grille (id + mot attendu + type), dans l'ordre du texte. @@ -111,61 +322,37 @@ def build_dictation_prompt( Le prompt complet à envoyer au modèle. """ 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, + }, + ) # ───────────────────────────────────────────────────────────────────────────── @@ -176,9 +363,10 @@ 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. + Charge le template versionné « dictee_transcription » depuis Langfuse. 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. Il restitue exactement ce que l'élève a écrit, fautes + comprises. Args: read_final_state: si True, consigne de lire l'état final en cas de rature. @@ -186,27 +374,12 @@ def build_transcription_prompt(read_final_state: bool = True) -> str: Returns: Le prompt de transcription. """ - 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( @@ -217,10 +390,9 @@ def build_text_coding_prompt( ) -> 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. + Charge le template versionné « dictee_codage_texte » depuis Langfuse. 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. Args: reference_text: texte de référence de la dictée. @@ -232,36 +404,14 @@ def build_text_coding_prompt( Le prompt de codage textuel. """ 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/utils/add_langfuse_prompt.py b/src/evaluation_dictee/utils/add_langfuse_prompt.py new file mode 100644 index 0000000..f827dda --- /dev/null +++ b/src/evaluation_dictee/utils/add_langfuse_prompt.py @@ -0,0 +1,56 @@ +"""Initialise / met à jour les prompts d'évaluation dans Langfuse. + +Pousse les trois templates chat (message system + message user) définis dans +`pipeline/prompts.py` vers le gestionnaire de prompts de Langfuse. Chaque appel +crée une NOUVELLE version de chaque prompt et la promeut en production via le +label "production". + +Les variables entre doubles accolades ({{reference_text}}, {{grille}}, ...) sont +des placeholders Langfuse. Elles sont remplies à l'exécution par les fonctions +build_* de prompts.py via compile(). La logique conditionnelle (schéma de codage, +flags de PromptConfig) reste dans le code : elle choisit la valeur injectée dans +chaque variable (texte d'un bloc optionnel, ou chaîne vide). On garde donc un seul +prompt versionné par fonction, pas une version par combinaison de flags. + +Prérequis : LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY et LANGFUSE_SECRET_KEY définis dans +l'environnement (ou le fichier .env). Voir .env.example. + +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"], # promeut cette version comme version de prod + ) + logger.info( + "Prompt « %s » poussé (version %s), labels : %s", + prompt.name, + prompt.version, + prompt.labels, + ) + + # Langfuse envoie ses requêtes de façon asynchrone : flush explicite avant de + # terminer le script, sinon les créations peuvent être perdues. + langfuse.flush() + + +if __name__ == "__main__": + push_prompts() From b32dd26d6be1e6bf5f1804b2e63ac9b424c1a580 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Thu, 16 Jul 2026 13:57:31 +0000 Subject: [PATCH 05/14] up LLM call with system/user prompt --- src/evaluation_dictee/config.py | 2 +- src/evaluation_dictee/models/two_stage.py | 37 +++--- src/evaluation_dictee/models/vlm.py | 41 +++--- src/evaluation_dictee/pipeline/prompts.py | 122 +++++++++++++----- .../transcription/__init__.py | 2 +- .../transcription/htr_benchmark.py | 21 ++- tests/test_chain_of_thought.py | 11 +- tests/test_two_stage.py | 10 +- 8 files changed, 164 insertions(+), 82 deletions(-) diff --git a/src/evaluation_dictee/config.py b/src/evaluation_dictee/config.py index 4027abe..d2644ac 100644 --- a/src/evaluation_dictee/config.py +++ b/src/evaluation_dictee/config.py @@ -174,4 +174,4 @@ def load_htr_config(path: str | Path) -> HTRExperimentConfig: """ 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/models/two_stage.py b/src/evaluation_dictee/models/two_stage.py index 9f7ab24..9f7275e 100644 --- a/src/evaluation_dictee/models/two_stage.py +++ b/src/evaluation_dictee/models/two_stage.py @@ -22,8 +22,10 @@ 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.loaders import Copy, load_image @@ -32,6 +34,7 @@ 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, ) @@ -151,16 +154,13 @@ def _transcribe(self, copy: Copy) -> tuple[str, int]: (transcription, n_attempts). Transcription vide si échec total. """ 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( @@ -196,11 +196,14 @@ def _code_text(self, copy: Copy, reference_text: str, transcription: str) -> Cop parasite, troncature) codait toute la copie en '?' silencieusement. """ 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,7 +211,7 @@ 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. @@ -316,4 +319,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 203d19b..d5bcedb 100644 --- a/src/evaluation_dictee/models/vlm.py +++ b/src/evaluation_dictee/models/vlm.py @@ -10,8 +10,10 @@ import base64 import io import json +from typing import Any, cast from langfuse.openai import OpenAI +from openai.types.chat import ChatCompletionMessageParam from PIL import Image from evaluation_dictee.config import ModelConfig, PromptConfig @@ -19,7 +21,12 @@ 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: @@ -73,21 +80,22 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: 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 Langfuse est hors ligne ou si le prompt vient du repli local. + prompt_ref = fetch_prompt(PROMPT_DICTATION) + trace_kwargs: dict[str, Any] = {"langfuse_prompt": prompt_ref} if prompt_ref else {} # Tentatives successives tant que la réponse n'est pas exploitable. # On augmente légèrement la température aux essais suivants pour sortir @@ -109,6 +117,7 @@ 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, + **trace_kwargs, ) content = response.choices[0].message.content or "{}" prediction = self._parse_response(copy, content) @@ -186,4 +195,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/prompts.py b/src/evaluation_dictee/pipeline/prompts.py index fecb72a..e547e72 100644 --- a/src/evaluation_dictee/pipeline/prompts.py +++ b/src/evaluation_dictee/pipeline/prompts.py @@ -29,7 +29,7 @@ from typing import cast from langfuse import get_client -from langfuse.model import ChatMessageDict +from langfuse.model import ChatMessageDict, ChatPromptClient from evaluation_dictee.config import PromptConfig from evaluation_dictee.data.reference import GridItem @@ -153,7 +153,8 @@ "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" + "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" @@ -190,7 +191,8 @@ "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" + "4 - Respecte l'ordre exact des items écrits sur la copie " + "(mots, ponctuation, chiffres, ...).\n" "{{consigne_ratures}}" ), }, @@ -207,15 +209,17 @@ { "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" + "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 : " + "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" - ), }, { @@ -270,14 +274,15 @@ def _compile_prompt( name: str, fallback: list[ChatMessageDict], variables: dict[str, object], -) -> str: - """Récupère un prompt chat versionné dans Langfuse, le compile et l'aplatit. +) -> list[ChatMessageDict]: + """Récupère un prompt chat versionné dans Langfuse et le compile. Le prompt est récupéré via son label de production. `fallback` est la structure locale (identique à celle poussée dans Langfuse) : elle sert de repli si le prompt n'existe pas encore ou si Langfuse est indisponible (tests, incident). - Le résultat est aplati en une seule chaîne car les appelants envoient le prompt - dans un unique bloc de texte, souvent accompagné de l'image. + On conserve la structure chat (message system + message user) : la séparation + des rôles est justement ce qu'on versionne dans Langfuse, et c'est le format + attendu par l'API (paramètre `messages`). Args: name: nom du prompt dans Langfuse. @@ -285,7 +290,7 @@ def _compile_prompt( variables: valeurs à injecter dans les placeholders {{...}}. Returns: - Le prompt compilé, messages system + user concaténés. + Les messages chat compilés (rôle + contenu), system puis user. """ try: prompt = get_client().get_prompt(name, type="chat", fallback=fallback) @@ -293,9 +298,65 @@ def _compile_prompt( except Exception: # pragma: no cover - repli défensif si Langfuse indisponible messages = _render_local(fallback, variables) # compile() peut renvoyer des placeholders de message (sans "content") ; nos - # prompts n'en contiennent pas, on aplatit donc simplement le champ texte. + # prompts n'en contiennent pas, on ne garde donc que les messages role/content. plain = cast("list[dict[str, object]]", messages) - return "\n\n".join(str(m["content"]) for m in plain if m.get("content")) + 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) d'une liste de messages chat. + + Les fonctions build_* renvoient des messages à contenu texte simple. Pour un + appel multimodal, l'image doit être jointe au message user sous forme de contenu + structuré (bloc texte + bloc image), format attendu par l'API OpenAI/vLLM. Les + messages précédents (system) sont laissés intacts. + + Args: + messages: messages chat (system + user) à contenu texte. + image_data_url: image encodée en data URL base64. + + Returns: + Les messages, avec l'image ajoutée au 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 `name`, ou None si indisponible. + + Sert à lier une génération à la VERSION du prompt dans les traces Langfuse + (paramètre `langfuse_prompt` du client `langfuse.openai`). On renvoie None si + Langfuse est injoignable OU si le prompt est servi depuis le repli local + (`is_fallback`) : il n'y a alors pas de version distante à référencer, et lier + une trace à un repli fausserait l'analyse. La récupération est mise en cache + côté client par Langfuse : l'appel reste bon marché même répété par copie. + + Args: + name: nom du prompt versionné (une clé de PROMPT_TEMPLATES). + + Returns: + L'objet prompt chat, ou None si aucun lien fiable n'est possible. + """ + 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( @@ -303,12 +364,12 @@ def build_dictation_prompt( items: list[GridItem], config: PromptConfig, scheme: str = "simplifiee", -) -> str: +) -> list[ChatMessageDict]: """Construit le prompt d'évaluation d'une dictée (méthode C, end-to-end). - Charge le template versionné « dictee_methode_c » depuis Langfuse et le remplit - selon les inputs : les blocs conditionnels (fidélité, ratures, chain-of-thought) - et le schéma de codage déterminent la valeur injectée dans chaque variable. + Charge le template versionné « Dictation » depuis Langfuse et le remplit selon + les inputs : les blocs conditionnels (fidélité, ratures, chain-of-thought) et le + schéma de codage déterminent la valeur injectée dans chaque variable. Args: reference_text: texte exact de la dictée attendue. @@ -319,7 +380,8 @@ def build_dictation_prompt( code dans le MÊME jeu de modalités que les experts après normalisation. Returns: - Le prompt complet à envoyer au modèle. + Les messages chat (system + user) à envoyer au modèle. L'image est ajoutée + au message user par l'appelant via `attach_image`. """ grille = _GRILLE_COMPLETE if scheme == "complete" else _GRILLE_SIMPLIFIEE @@ -360,11 +422,11 @@ def build_dictation_prompt( # ───────────────────────────────────────────────────────────────────────────── -def build_transcription_prompt(read_final_state: bool = True) -> str: +def build_transcription_prompt(read_final_state: bool = True) -> list[ChatMessageDict]: """Prompt de l'ÉTAPE 1 (HTR) : transcrire fidèlement l'image, sans coder. - Charge le template versionné « dictee_transcription » depuis Langfuse. Le modèle - ne reçoit PAS le texte de référence : on veut une lecture brute, non biaisée par + Charge le template versionné « Transcription » depuis Langfuse. 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. Il restitue exactement ce que l'élève a écrit, fautes comprises. @@ -372,7 +434,8 @@ def build_transcription_prompt(read_final_state: bool = True) -> str: read_final_state: si True, consigne de lire l'état final en cas de rature. Returns: - Le prompt de transcription. + Les messages chat (system + user). L'image est ajoutée au message user par + l'appelant via `attach_image`. """ consigne_ratures = ("\n" + _CONSIGNE_RATURES_TRANSCRIPTION) if read_final_state else "" return _compile_prompt( @@ -387,12 +450,13 @@ def build_text_coding_prompt( transcription: str, items: list[GridItem], scheme: str = "simplifiee", -) -> str: +) -> list[ChatMessageDict]: """Prompt de l'ÉTAPE 2 : coder à partir du TEXTE transcrit (sans image). - Charge le template versionné « dictee_codage_texte » depuis Langfuse. 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. + Charge le template versionné « Text coding » depuis Langfuse. 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, + et les messages sont envoyés tels quels (pas d'image à joindre). Args: reference_text: texte de référence de la dictée. @@ -401,7 +465,7 @@ def build_text_coding_prompt( scheme: "simplifiee" ou "complete". Returns: - Le prompt de codage textuel. + Les messages chat (system + user) à envoyer au modèle. """ grille = _GRILLE_COMPLETE if scheme == "complete" else _GRILLE_SIMPLIFIEE return _compile_prompt( 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..39747e3 100644 --- a/src/evaluation_dictee/transcription/htr_benchmark.py +++ b/src/evaluation_dictee/transcription/htr_benchmark.py @@ -15,15 +15,17 @@ import json 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, @@ -62,7 +64,9 @@ def __init__( """ 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) + # Messages (system + user) du prompt de transcription ; l'image est jointe + # au message user à chaque appel via attach_image. + 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). @@ -74,15 +78,10 @@ def transcribe(self, image_path: str) -> str: La transcription produite par le modèle (chaîne vide si échec). """ 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( diff --git a/tests/test_chain_of_thought.py b/tests/test_chain_of_thought.py index be1cddc..9cd5e63 100644 --- a/tests/test_chain_of_thought.py +++ b/tests/test_chain_of_thought.py @@ -10,14 +10,19 @@ ] +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 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 @@ -26,6 +31,6 @@ def test_cot_active_ajoute_champ_comparaison() -> None: def test_cot_ordre_du_raisonnement_est_impose() -> 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))) # La consigne indique bien AVANT le code (ordre du raisonnement) assert "AVANT de choisir le code" in prompt diff --git a/tests/test_two_stage.py b/tests/test_two_stage.py index 8f9d518..2f1e4d5 100644 --- a/tests/test_two_stage.py +++ b/tests/test_two_stage.py @@ -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) From 8bdad505f47deb8c9fab252d05b8ce8ecfbc1eb8 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Mon, 20 Jul 2026 16:45:44 +0000 Subject: [PATCH 06/14] Clean config yaml --- configs/README.md | 159 +++++++++++++++++++++++ configs/dictee_gemma4_2stages.yaml | 42 ------ configs/dictee_gemma4_cot.yaml | 32 ----- configs/dictee_gemma4_zeroshot.yaml | 37 ------ configs/dictee_qwen3-6-35b-moe_cot.yaml | 41 ------ configs/dictee_qwen3vl_cot.yaml | 34 ----- configs/dictee_qwen3vl_zeroshot.yaml | 37 ------ configs/finetune/finetune_REFERENCE.yaml | 78 +++++++++++ configs/finetune_htr_gemma4.yaml | 57 -------- configs/htr/htr_REFERENCE.yaml | 40 ++++++ configs/htr_gemma4_scoledit.yaml | 19 --- configs/htr_qwen3vl_scoledit.yaml | 19 --- configs/scoring/dictee_REFERENCE.yaml | 112 ++++++++++++++++ 13 files changed, 389 insertions(+), 318 deletions(-) create mode 100644 configs/README.md delete mode 100644 configs/dictee_gemma4_2stages.yaml delete mode 100644 configs/dictee_gemma4_cot.yaml delete mode 100644 configs/dictee_gemma4_zeroshot.yaml delete mode 100644 configs/dictee_qwen3-6-35b-moe_cot.yaml delete mode 100644 configs/dictee_qwen3vl_cot.yaml delete mode 100644 configs/dictee_qwen3vl_zeroshot.yaml create mode 100644 configs/finetune/finetune_REFERENCE.yaml delete mode 100644 configs/finetune_htr_gemma4.yaml create mode 100644 configs/htr/htr_REFERENCE.yaml delete mode 100644 configs/htr_gemma4_scoledit.yaml delete mode 100644 configs/htr_qwen3vl_scoledit.yaml create mode 100644 configs/scoring/dictee_REFERENCE.yaml diff --git a/configs/README.md b/configs/README.md new file mode 100644 index 0000000..671e2e6 --- /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). +python 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..adac02b --- /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 : +# pip install unsloth trl peft transformers bitsandbytes \ +# accelerate datasets pillow s3fs pyyaml pydantic mlflow +# Lancer : +# python 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..f50b28f --- /dev/null +++ b/configs/htr/htr_REFERENCE.yaml @@ -0,0 +1,40 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# 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 + +# ── 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..3549d54 --- /dev/null +++ b/configs/scoring/dictee_REFERENCE.yaml @@ -0,0 +1,112 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# 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 + +# 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: 20 + +# ── 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 From 8eedc087a44a5a2717ff7e903974271c52d67b3e Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Mon, 20 Jul 2026 16:46:50 +0000 Subject: [PATCH 07/14] Clean logging langfuse --- .env.example | 17 +- CLAUDE.md | 26 +- README.md | 80 +- pyproject.toml | 3 +- src/evaluation_dictee/cli.py | 30 +- src/evaluation_dictee/config.py | 10 +- src/evaluation_dictee/models/vlm.py | 48 + src/evaluation_dictee/pipeline/benchmark.py | 117 ++- src/evaluation_dictee/pipeline/prompts.py | 10 +- src/evaluation_dictee/utils/tracking.py | 204 ++++- uv.lock | 916 +------------------- 11 files changed, 407 insertions(+), 1054 deletions(-) diff --git a/.env.example b/.env.example index 307e3a5..f7f924e 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,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/CLAUDE.md b/CLAUDE.md index cee2972..a0beab1 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) @@ -135,8 +135,14 @@ evaluation_ecrit/ - **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:`). @@ -153,7 +159,7 @@ pytest # tests mypy src # typage # Lancer un benchmark à partir d'une config -python scripts/run_benchmark.py --config configs/dictee_gemma4_zeroshot.yaml +python 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 +python 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 python 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 5db0165..47179c2 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,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,7 +43,7 @@ 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`. @@ -93,11 +93,13 @@ messages=[{'role':'user','content':'Dis bonjour'}], max_tokens=10).choices[0].me **Commande de base** (test rapide, terminal foreground) : ```bash -uv run 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. @@ -110,15 +112,15 @@ et il saute les copies déjà traitées. Voir « Runs longs » pour les détails **Pour l'évaluation de la transcription (HTR)** sur Scoledit : ```bash -python scripts/run_htr_benchmark.py --config configs/htr_gemma4_scoledit.yaml +python 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`. **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 +python 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`). @@ -169,7 +171,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 +python 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 @@ -182,18 +184,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 python 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 @@ -203,13 +205,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 @@ -230,8 +232,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 | |-------|------| @@ -253,13 +257,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 @@ -283,7 +286,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 @@ -329,16 +332,17 @@ 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 -# ─────────── 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). +python 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 +python 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 +python scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml # ─────────── Runs longs (session détachable) ─────────── mkdir -p logs # toujours créer d'abord @@ -348,12 +352,12 @@ 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 python 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 diff --git a/pyproject.toml b/pyproject.toml index ca01939..4db269c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,7 @@ dependencies = [ "httpx>=0.27", # Métriques "scikit-learn>=1.4", - # Suivi d'expériences - "mlflow>=2.12", + # Suivi d'expériences (traces LLM, prompts, scores) "langfuse>=4,<5", # Confort "tqdm>=4.66", diff --git a/src/evaluation_dictee/cli.py b/src/evaluation_dictee/cli.py index facd79b..db68ada 100644 --- a/src/evaluation_dictee/cli.py +++ b/src/evaluation_dictee/cli.py @@ -1,17 +1,19 @@ """Interface en ligne de commande du projet. 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 +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.evaluation.metrics import ScoringMetrics from evaluation_dictee.models.factory import build_scorer from evaluation_dictee.pipeline.benchmark import run_benchmark from evaluation_dictee.utils.tracking import experiment_run, log_metrics @@ -40,20 +42,26 @@ 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: + # Langfuse envoie ses requêtes de façon asynchrone : sans flush explicite, + # le script peut se terminer avant l'envoi et perdre les dernières traces. + get_client().flush() _print_metrics(result.metrics) -def _print_metrics(metrics) -> None: # type: ignore[no-untyped-def] +def _print_metrics(metrics: ScoringMetrics) -> None: """Affiche les métriques dans un tableau lisible.""" table = Table(title="Résultats du benchmark") table.add_column("Métrique") diff --git a/src/evaluation_dictee/config.py b/src/evaluation_dictee/config.py index d2644ac..7ee6868 100644 --- a/src/evaluation_dictee/config.py +++ b/src/evaluation_dictee/config.py @@ -30,8 +30,6 @@ class Secrets(BaseSettings): 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.""" @@ -55,6 +53,12 @@ class ModelConfig(BaseModel): # QwQ, DeepSeek-R1, etc. # Sans effet sur les modèles classiques (gemma4, Qwen2.5-VL, ...). disable_thinking: bool = True + # Forcer une sortie JSON conforme au schéma via le décodage contraint de vLLM + # (response_format json_schema). Le modèle ne peut alors PAS produire de texte + # hors JSON (commentaire, note), ce qui supprime les copies « non transcrites » + # dues à un parsing impossible. Mettre à False si l'endpoint/modèle ne supporte + # pas json_schema (le décodage retombe alors sur un prompt non contraint). + structured_output: bool = True class DataConfig(BaseModel): @@ -100,7 +104,7 @@ class PromptConfig(BaseModel): 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. diff --git a/src/evaluation_dictee/models/vlm.py b/src/evaluation_dictee/models/vlm.py index d5bcedb..945e89b 100644 --- a/src/evaluation_dictee/models/vlm.py +++ b/src/evaluation_dictee/models/vlm.py @@ -37,6 +37,41 @@ def _image_to_data_url(image: Image.Image) -> str: 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. + + Contraint la STRUCTURE de chaque item, pas leur nombre : celui-ci varie d'une + copie à l'autre et n'est pas connu à l'avance. Le champ « comparaison » n'est + exigé qu'en mode chain-of-thought (aligné sur le prompt). + + Args: + chain_of_thought: si True, ajoute le champ « comparaison » au schéma. + + Returns: + Un schéma JSON exploitable par `response_format` (json_schema) de vLLM. + """ + 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).""" @@ -97,6 +132,18 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: prompt_ref = fetch_prompt(PROMPT_DICTATION) trace_kwargs: dict[str, Any] = {"langfuse_prompt": prompt_ref} if prompt_ref else {} + # Décodage contraint : le modèle est FORCÉ de produire un JSON conforme au + # schéma (aucun texte hors JSON), ce qui garantit une réponse parsable. + 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. @@ -117,6 +164,7 @@ 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 "{}" diff --git a/src/evaluation_dictee/pipeline/benchmark.py b/src/evaluation_dictee/pipeline/benchmark.py index efcbacb..a9104fc 100644 --- a/src/evaluation_dictee/pipeline/benchmark.py +++ b/src/evaluation_dictee/pipeline/benchmark.py @@ -25,6 +25,7 @@ from evaluation_dictee.evaluation.metrics import ScoringMetrics, compute_scoring_metrics from evaluation_dictee.models.base import Scorer from evaluation_dictee.utils.logging import get_logger +from evaluation_dictee.utils.tracking import copy_trace logger = get_logger(__name__) @@ -161,52 +162,76 @@ def run_benchmark( # 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. - 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, - ) - 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, - ) - continue - - pred_by_id = {it.item_id: it for it in prediction.items} - - # Écriture incrémentale de chaque item de la copie - 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()) + # Une trace Langfuse par copie : les appels LLM du scorer s'y + # imbriquent, et on y rattache l'issue (sortie, score, statut). + # No-op transparent si Langfuse est indisponible (trace vaut None). + with copy_trace(copy) as trace: + # Chaque copie est isolée dans un try/except : une erreur API sur + # UNE copie ne fait plus perdre les précédentes. + 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, + ) + failed_copies.append((copy.copy_id, str(exc))) + if trace is not None: + trace.update(level="ERROR", status_message=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, + ) + if trace is not None: + trace.update( + level="WARNING", + status_message="copie non transcrite", + output={"transcribed": False, "n_attempts": prediction.n_attempts}, + ) + continue + + pred_by_id = {it.item_id: it for it in prediction.items} + + # Écriture incrémentale de chaque item + comptage de l'accord copie. + n_items_copie = 0 + 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") + n_items_copie += 1 + if pred_code == true_code: + n_accord += 1 + + # 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()) + + # Sortie + score d'accord au niveau de la copie (onglet Scores). + if trace is not None and n_items_copie: + accord_copie = n_accord / n_items_copie + trace.update(output={"n_items": n_items_copie, "raw_agreement": accord_copie}) + # data_type inféré NUMERIC (valeur flottante) — voir doc Langfuse Scores. + trace.score_trace(name="raw_agreement", value=accord_copie) # Sauvegarde de la liste des échecs (utile pour relancer sélectivement) if failed_copies: diff --git a/src/evaluation_dictee/pipeline/prompts.py b/src/evaluation_dictee/pipeline/prompts.py index e547e72..2b1a0f1 100644 --- a/src/evaluation_dictee/pipeline/prompts.py +++ b/src/evaluation_dictee/pipeline/prompts.py @@ -122,15 +122,17 @@ # 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, de la forme :\n" + "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}, ...]}' + '"code": "1", "confidence": 0.95}, ...]}.\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, de la forme :\n" + "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}, ...]}\n' + '"code": "1", "confidence": 0.95}, ...]}.\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). diff --git a/src/evaluation_dictee/utils/tracking.py b/src/evaluation_dictee/utils/tracking.py index 39d4d2c..29cb506 100644 --- a/src/evaluation_dictee/utils/tracking.py +++ b/src/evaluation_dictee/utils/tracking.py @@ -1,64 +1,192 @@ -"""Suivi d'expériences avec MLflow. +"""Suivi d'expériences avec Langfuse (traces, prompts, scores). -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. +Structure conforme aux recommandations Langfuse pour un benchmark : + + session (= un lancement) → trace (= une copie) → generation (= appel LLM) + +- `experiment_run` ouvre le contexte du run. Il rattache un `session_id` UNIQUE + PAR LANCEMENT (nom du run + horodatage), un `user_id` (le namespace Kubernetes + de l'utilisateur, cf. `KUBERNETES_NAMESPACE` sur Onyxia) et les paramètres de + l'expérience (modèle, méthode, schéma, corpus) en tags/metadata, PROPAGÉS à + toutes les traces créées pendant le run (`propagate_attributes`). Chaque exécution de l'évaluation + ouvre ainsi une NOUVELLE session Langfuse : relancer la même config (reprise + après crash comprise) ne réutilise pas la session précédente, qui est close de + fait — plus aucune trace ne s'y rattache. Le nom du run reste porté en tag/metadata + (`run:`) pour regrouper au besoin les lancements d'une même config. + Il n'ouvre volontairement PAS de trace englobante : + chaque copie devient ainsi une trace autonome — inspectable individuellement, + avec ses propres entrée/sortie et scores — regroupée dans la session du run. + (Une trace unique de plusieurs milliers de générations serait ingérable dans + l'UI et ferait perdre l'inspection copie par copie.) +- `copy_trace` ouvre une trace par copie ; les générations LLM émises par + `VLMScorer` (client `langfuse.openai`) s'y imbriquent automatiquement. +- `log_metrics` enregistre les métriques agrégées du run à la fois en metadata ET + en Scores Langfuse (onglet « Scores » de l'UI), sur une trace de synthèse + rattachée à la session. + +Tout est optionnel : si Langfuse est indisponible ou mal configuré, les fonctions +deviennent silencieuses (no-op) afin de ne pas bloquer un développement 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 US-ASCII (contrainte Langfuse metadata).""" + 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.""" + return [ + config.prompt.method, + config.data.corpus, + f"scheme:{config.grid.scheme}", + f"model:{config.model.name}", + # Le session_id étant unique par lancement, ce tag regroupe tous les + # lancements d'une même config dans l'UI. + f"run:{config.name}", + ] + + +def _user_id() -> str | None: + """Identifiant d'utilisateur Langfuse, basé sur le namespace Kubernetes. + + Sur le SSP Cloud (Onyxia), chaque utilisateur dispose d'un namespace dédié + (variable `KUBERNETES_NAMESPACE`, typiquement « user- »), qui sert + d'identifiant naturel pour attribuer les traces dans l'UI Langfuse. + + Returns: + La valeur de `KUBERNETES_NAMESPACE`, ou None hors de ce contexte (le + `user_id` est alors simplement omis des traces). + """ + return os.environ.get("KUBERNETES_NAMESPACE") or None + + +def _launch_session_id(config: ExperimentConfig) -> str: + """Identifiant de session Langfuse UNIQUE PAR LANCEMENT (nom du run + horodatage). + + Chaque exécution de l'évaluation ouvre ainsi une nouvelle session, même en + relançant la même config. Format trié chronologiquement : « -AAAAMMJJ-HHMMSS ». + + Args: + config: configuration de l'expérience (fournit le nom du run). + + Returns: + L'identifiant de session du lancement courant. + """ + return f"{config.name}-{time.strftime('%Y%m%d-%H%M%S')}" + + +@contextmanager +def experiment_run(config: ExperimentConfig) -> Iterator[str]: + """Ouvre le contexte Langfuse d'un run de benchmark, dans une session neuve. + + Génère un `session_id` unique pour CE lancement (voir `_launch_session_id`) et + le rattache — avec le `user_id` (namespace Kubernetes, voir `_user_id`) et les + paramètres de l'expérience — en tags/metadata propagés à toutes les traces + créées dans ce contexte. Chaque copie évaluée devient alors + une trace autonome regroupée dans cette session, et ses appels LLM s'y + imbriquent (voir `copy_trace`). Relancer la même config ouvre une nouvelle + session (l'ancienne est close de fait). + + Args: + config: configuration de l'expérience (nom du run, paramètres). + + Yields: + Le `session_id` du lancement courant (utile pour le journaliser). + """ + 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 experiment_run(config: ExperimentConfig) -> Iterator[None]: - """Ouvre un run MLflow nommé d'après la config (no-op si MLflow absent). +def copy_trace(copy: Copy) -> Iterator[LangfuseSpan | None]: + """Ouvre une trace Langfuse pour une copie (à utiliser dans `experiment_run`). + + Les générations LLM émises pendant l'évaluation de la copie (client + `langfuse.openai` de `VLMScorer`, tentatives successives comprises) + s'imbriquent automatiquement sous cette trace. L'appelant complète l'objet + renvoyé : `.update(output=..., level=...)`, `.score_trace(...)`. Args: - config: configuration de l'expérience (sert de nom de run et de params). + copy: copie évaluée (fournit l'identifiant et l'entrée de la trace). Yields: - Rien ; le contexte gère le cycle de vie du run. + La trace (span racine de la copie), ou None si Langfuse est indisponible. """ - 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). + 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 dans Langfuse. + + Crée une trace de synthèse (« run-summary: ») rattachée à la session du + run, portant les métriques à la fois en metadata ET en Scores Langfuse + (onglet « Scores » de l'UI). No-op si Langfuse est indisponible. Args: + config: configuration de l'expérience (nomme la trace, rattache à la session). metrics: dictionnaire {nom: valeur} de métriques scalaires. """ - 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/uv.lock b/uv.lock index 9995230..a77fbbd 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" @@ -452,15 +438,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/5c/dbd00727a3dd165d7e0e8af40e630cd7e45d77b525a3218afaff8a87358e/blake3-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:421b99cdf1ff2d1bf703bc56c454f4b286fce68454dd8711abbcb5a0df90c19a", size = 215133, upload-time = "2025-10-14T06:47:16.069Z" }, ] -[[package]] -name = "blinker" -version = "1.9.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" } -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" }, -] - [[package]] name = "botocore" version = "1.43.0" @@ -745,89 +722,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/52/93833dc1610e017ac5b7dcd59b8304d8ef67d1114c2d124e728a2cbbea12/compressed_tensors-0.15.0.1-py3-none-any.whl", hash = "sha256:e1b1f322e82e475715e242bad46925a304ea8e5c98b5055a15b8eb22fb6bfea9", size = 194260, upload-time = "2026-04-10T14:23:53.098Z" }, ] -[[package]] -name = "contourpy" -version = "1.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/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, -] - [[package]] name = "coverage" version = "7.14.1" @@ -1099,61 +993,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'" }, -] - -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "databricks-sdk" -version = "0.117.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "protobuf" }, - { name = "requests" }, - { name = "urllib3" }, -] -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" } -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" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -1214,20 +1084,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" @@ -1266,7 +1122,6 @@ 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" }, @@ -1302,7 +1157,6 @@ gpu = [ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "langfuse", specifier = ">=4,<5" }, - { name = "mlflow", specifier = ">=2.12" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "numpy", specifier = ">=1.26" }, { name = "openai", specifier = ">=1.30" }, @@ -1564,85 +1418,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" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, - { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, - { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, - { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, - { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, - { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, - { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, - { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, - { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, - { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, - { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, - { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, - { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, - { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, - { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, - { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, - { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, - { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, - { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, - { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, - { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, - { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, - { 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 = "frozenlist" version = "1.8.0" @@ -1773,43 +1548,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 +1560,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 +1611,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 +1732,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 +1874,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" @@ -2286,15 +1892,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/01/72d6472f80651673716d1deda2a5bbb633e563ecf94f4479da5519d69d25/interegular-0.3.3-py37-none-any.whl", hash = "sha256:b0c07007d48c89d6d19f7204972d369b2a77222722e126b6aa63aa721dc3b19c", size = 23635, upload-time = "2024-01-06T23:01:20.829Z" }, ] -[[package]] -name = "itsdangerous" -version = "2.2.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" } -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" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -2442,112 +2039,6 @@ 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 = "kiwisolver" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, - { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, - { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, - { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, - { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, - { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, - { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, - { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, - { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, - { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, - { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, - { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, - { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, - { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, - { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, - { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, - { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, - { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, - { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, - { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, - { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, -] - [[package]] name = "langfuse" version = "4.7.1" @@ -2723,18 +2214,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" @@ -2821,71 +2300,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "matplotlib" -version = "3.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { 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 = "pillow" }, - { 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" }, -] - [[package]] name = "mcp" version = "1.27.2" @@ -2987,88 +2401,6 @@ wheels = [ { 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]] -name = "mlflow" -version = "3.13.0" -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" } -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" }, -] - [[package]] name = "model-hosting-container-standards" version = "0.1.15" @@ -3689,7 +3021,7 @@ name = "nvidia-cudnn-cu13" version = "9.19.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "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 +3052,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "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 +3082,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'darwin'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'darwin'" }, - { name = "nvidia-nvjitlink", marker = "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 +3096,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "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" }, @@ -4315,18 +3647,6 @@ 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" }, ] -[[package]] -name = "prettytable" -version = "3.17.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" } -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" }, -] - [[package]] name = "prometheus-client" version = "0.25.0" @@ -4512,77 +3832,6 @@ 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" }, ] -[[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" -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" } -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" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -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" } -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" }, -] - [[package]] name = "pybase64" version = "1.4.3" @@ -4935,15 +4184,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -5928,32 +5168,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" @@ -5963,63 +5177,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sqlalchemy" -version = "2.0.50" -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" }, -] - [[package]] name = "sse-starlette" version = "3.4.4" @@ -6692,15 +5849,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" @@ -6805,15 +5953,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] -[[package]] -name = "wcwidth" -version = "0.8.1" -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" } -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" }, -] - [[package]] name = "websockets" version = "16.0" @@ -6873,18 +6012,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 +6234,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" }, -] From 32f0bbefb3b2bdc463954e26f78ab20ecdf6fa3c Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Mon, 20 Jul 2026 16:47:39 +0000 Subject: [PATCH 08/14] Clean script test with new config file --- scripts/finetune_htr_scoledit.py | 2 +- scripts/run_benchmark.py | 8 +++++--- scripts/run_htr_benchmark.py | 2 +- tests/test_config.py | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/finetune_htr_scoledit.py b/scripts/finetune_htr_scoledit.py index ed7f7ae..89e9aeb 100644 --- a/scripts/finetune_htr_scoledit.py +++ b/scripts/finetune_htr_scoledit.py @@ -16,7 +16,7 @@ datasets pillow s3fs pyyaml pydantic mlflow Usage : - python scripts/finetune_htr_scoledit.py --config configs/finetune_htr_gemma4.yaml + python scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml Ce script s'exécute sur une H100/A100 80 Go du SSP Cloud. Ne pas lancer sans GPU. """ diff --git a/scripts/run_benchmark.py b/scripts/run_benchmark.py index b46bbb6..38aae67 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -5,7 +5,7 @@ de `run_benchmark` (dans src/.../pipeline/benchmark.py) pour comprendre le projet. Usage : - python scripts/run_benchmark.py --config configs/dictee_qwen7b_zeroshot.yaml + python scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml """ from __future__ import annotations @@ -46,13 +46,15 @@ def main() -> None: 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: - # Langfose envoie les traces de façon asynchrone : sans flush explicite, + # Langfuse envoie les traces de façon asynchrone : sans flush explicite, # le script peut se terminer avant l'envoi et perdre les dernières traces. get_client().flush() diff --git a/scripts/run_htr_benchmark.py b/scripts/run_htr_benchmark.py index efa30b0..2c3de7a 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 + python scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml """ from __future__ import annotations 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" From e805cfff9d839c77a37cb18854f0356b635109bb Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Tue, 21 Jul 2026 12:08:01 +0000 Subject: [PATCH 09/14] refactor uv and organized uv build with dependency-groups and optional dependencies --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 24 +- README.md | 67 +- configs/README.md | 2 +- configs/finetune/finetune_REFERENCE.yaml | 4 +- notebooks/05_analyse_transcription_htr.ipynb | 2 +- pyproject.toml | 24 +- scripts/finetune_htr_scoledit.py | 4 +- scripts/run_benchmark.py | 2 +- scripts/run_htr_benchmark.py | 2 +- src/evaluation_dictee/pipeline/benchmark.py | 2 +- uv.lock | 1199 +++++++++++++++++- 12 files changed, 1266 insertions(+), 68 deletions(-) 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/CLAUDE.md b/CLAUDE.md index a0beab1..1fa697e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,9 +126,9 @@ 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). @@ -149,17 +149,17 @@ evaluation_ecrit/ ## 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/scoring/dictee_REFERENCE.yaml +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml ``` ### Runs longs — utiliser screen ou nohup @@ -180,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/scoring/dictee_REFERENCE.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 @@ -189,7 +189,7 @@ 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/scoring/dictee_REFERENCE.yaml \ +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 diff --git a/README.md b/README.md index 47179c2..998a657 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,11 @@ Vérifier l'accès aux données et au modèle : ```bash # S3 accessible ? -uv run 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 ? -uv run 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)" @@ -112,7 +112,7 @@ et il saute les copies déjà traitées. Voir « Runs longs » pour les détails **Pour l'évaluation de la transcription (HTR)** sur Scoledit : ```bash -python scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml +uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml ``` Cela produit `data/processed/htr_REFERENCE_htr_predictions.jsonl` et affiche @@ -120,14 +120,22 @@ le CER/WER moyens. Analyse dans `notebooks/05_analyse_transcription_htr.ipynb`. **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/finetune_REFERENCE.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 | |----------|---------------|-----------| @@ -171,7 +179,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/scoring/dictee_REFERENCE.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 @@ -184,7 +192,7 @@ python scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml ```bash mkdir -p logs # créer le dossier si pas encore fait -nohup python scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml \ +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 @@ -306,11 +314,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/`), @@ -323,26 +331,32 @@ 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]" +uv sync # environnement Python (groupe dev inclus d'office) cp .env.example .env && nano .env # renseigner LLM_API_KEY et 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 # ─────────── 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). -python scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml +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/htr_REFERENCE.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/finetune_REFERENCE.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 @@ -352,7 +366,7 @@ 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/scoring/dictee_REFERENCE.yaml \ +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 ─────────── @@ -360,14 +374,15 @@ 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 # ─────────── 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 index 671e2e6..685f622 100644 --- a/configs/README.md +++ b/configs/README.md @@ -32,7 +32,7 @@ uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml # 3. Fine-tuning HTR (à lancer sur un service GPU). -python scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml +uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml ``` > **Astuce paramétrage** : dans presque tous les fichiers, **`limit`** borne le diff --git a/configs/finetune/finetune_REFERENCE.yaml b/configs/finetune/finetune_REFERENCE.yaml index adac02b..dfc4cdd 100644 --- a/configs/finetune/finetune_REFERENCE.yaml +++ b/configs/finetune/finetune_REFERENCE.yaml @@ -10,10 +10,10 @@ # Schéma de validation : FinetuneConfig (scripts/finetune_htr_scoledit.py). # # Prérequis : -# pip install unsloth trl peft transformers bitsandbytes \ +# uv pip install unsloth trl peft transformers bitsandbytes \ # accelerate datasets pillow s3fs pyyaml pydantic mlflow # Lancer : -# python scripts/finetune_htr_scoledit.py --config configs/finetune/.yaml +# uv run scripts/finetune_htr_scoledit.py --config configs/finetune/.yaml # # Voir configs/README.md pour la logique d'ensemble. # ═══════════════════════════════════════════════════════════════════════════ 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 4db269c..0ee8b3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,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", @@ -39,17 +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/finetune_htr_scoledit.py b/scripts/finetune_htr_scoledit.py index 89e9aeb..0e8186d 100644 --- a/scripts/finetune_htr_scoledit.py +++ b/scripts/finetune_htr_scoledit.py @@ -12,11 +12,11 @@ hyperparamètres pour la reproductibilité. Prérequis (à installer sur le service GPU Onyxia) : - pip install unsloth trl peft transformers bitsandbytes accelerate + 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/finetune_REFERENCE.yaml + uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml Ce script s'exécute sur une H100/A100 80 Go du SSP Cloud. Ne pas lancer sans GPU. """ diff --git a/scripts/run_benchmark.py b/scripts/run_benchmark.py index 38aae67..81605b8 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -5,7 +5,7 @@ de `run_benchmark` (dans src/.../pipeline/benchmark.py) pour comprendre le projet. Usage : - python scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml + uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml """ from __future__ import annotations diff --git a/scripts/run_htr_benchmark.py b/scripts/run_htr_benchmark.py index 2c3de7a..2de67a1 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/htr_REFERENCE.yaml + uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml """ from __future__ import annotations diff --git a/src/evaluation_dictee/pipeline/benchmark.py b/src/evaluation_dictee/pipeline/benchmark.py index a9104fc..dcd1554 100644 --- a/src/evaluation_dictee/pipeline/benchmark.py +++ b/src/evaluation_dictee/pipeline/benchmark.py @@ -126,7 +126,7 @@ 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')\"" diff --git a/uv.lock b/uv.lock index a77fbbd..9d2fb71 100644 --- a/uv.lock +++ b/uv.lock @@ -260,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" @@ -309,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" @@ -346,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" @@ -355,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" @@ -438,6 +543,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/5c/dbd00727a3dd165d7e0e8af40e630cd7e45d77b525a3218afaff8a87358e/blake3-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:421b99cdf1ff2d1bf703bc56c454f4b286fce68454dd8711abbcb5a0df90c19a", size = 215133, upload-time = "2025-10-14T06:47:16.069Z" }, ] +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +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/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]] name = "botocore" version = "1.43.0" @@ -707,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" @@ -722,6 +853,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/52/93833dc1610e017ac5b7dcd59b8304d8ef67d1114c2d124e728a2cbbea12/compressed_tensors-0.15.0.1-py3-none-any.whl", hash = "sha256:e1b1f322e82e475715e242bad46925a304ea8e5c98b5055a15b8eb22fb6bfea9", size = 194260, upload-time = "2026-04-10T14:23:53.098Z" }, ] +[[package]] +name = "contourpy" +version = "1.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/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + [[package]] name = "coverage" version = "7.14.1" @@ -1026,6 +1240,58 @@ nvtx = [ { name = "nvidia-nvtx" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +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" }, +] + +[[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/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]] name = "depyf" version = "0.20.0" @@ -1138,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" }, @@ -1146,41 +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 = "jupyterlab", marker = "extra == 'notebooks'", specifier = ">=4.6.1" }, { name = "langfuse", specifier = ">=4,<5" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { 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" @@ -1352,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" @@ -1418,6 +1714,64 @@ 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 = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { 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" @@ -1892,6 +2246,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/01/72d6472f80651673716d1deda2a5bbb633e563ecf94f4479da5519d69d25/interegular-0.3.3-py37-none-any.whl", hash = "sha256:b0c07007d48c89d6d19f7204972d369b2a77222722e126b6aa63aa721dc3b19c", size = 23635, upload-time = "2024-01-06T23:01:20.829Z" }, ] +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +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/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]] name = "jinja2" version = "3.1.6" @@ -2012,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" @@ -2027,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" @@ -2039,6 +2507,280 @@ 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + [[package]] name = "langfuse" version = "4.7.1" @@ -2300,6 +3042,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { 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 = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +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]] name = "mcp" version = "1.27.2" @@ -2359,6 +3178,15 @@ image = [ { name = "opencv-python-headless" }, ] +[[package]] +name = "mistune" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +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/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 = "ml-dtypes" version = "0.5.4" @@ -2662,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" @@ -2697,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" @@ -3456,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" @@ -3533,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" @@ -3551,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" @@ -3638,6 +4581,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +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/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 = "pluggy" version = "1.6.0" @@ -3669,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" @@ -3823,6 +4787,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +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/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 = "py-cpuinfo" version = "9.0.0" @@ -4184,6 +5166,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -4284,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" @@ -4561,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" @@ -5001,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" @@ -5177,6 +6230,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "soupsieve" +version = "2.9" +source = { registry = "https://pypi.org/simple" } +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]] name = "sse-starlette" version = "3.4.4" @@ -5190,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" @@ -5233,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" @@ -5321,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" @@ -5569,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" @@ -5581,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" @@ -5694,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" @@ -5953,6 +7090,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] +[[package]] +name = "wcwidth" +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/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/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]] name = "websockets" version = "16.0" From 2d1d4971b0343ccd380bee6ca3b1bb7bb60a2f26 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Wed, 22 Jul 2026 09:40:59 +0000 Subject: [PATCH 10/14] add models properties on Langfuse + fix prompt --- src/evaluation_dictee/pipeline/prompts.py | 15 ++- .../utils/add_langfuse_models.py | 104 ++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 src/evaluation_dictee/utils/add_langfuse_models.py diff --git a/src/evaluation_dictee/pipeline/prompts.py b/src/evaluation_dictee/pipeline/prompts.py index 2b1a0f1..a8fb775 100644 --- a/src/evaluation_dictee/pipeline/prompts.py +++ b/src/evaluation_dictee/pipeline/prompts.py @@ -125,22 +125,30 @@ "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}, ...]}.\n' + '"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}, ...]}.\n' + '"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.' ) +# Consignes sur les ratures # 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). Les {{...}} sont remplis @@ -163,7 +171,8 @@ + _CONSIGNE_ALIGNEMENT + "{{consignes_optionnelles}}\n\n" + _CONSIGNE_CONFIANCE - + "{{consigne_cot}}" + + "{{consigne_cot}}\n\n" + + _CONSIGNE_RATURES_DICTATION ), }, { 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..d6395ae --- /dev/null +++ b/src/evaluation_dictee/utils/add_langfuse_models.py @@ -0,0 +1,104 @@ +"""Enregistre les prix THÉORIQUES des modèles dans Langfuse (coût par token). + +`gemma4-26b-moe` et `qwen3.6-35b-moe` sont auto-hébergés sur llm.lab (vLLM, GPU +SSP Cloud) : ils n'ont pas de tarif commercial. On enregistre donc un coût +théorique **amorti sur le GPU**, pour que Langfuse valorise chaque trace +(coût = tokens × prix, calculé côté serveur, y compris rétroactivement). + +Base de calcul (GPU amorti) : + prix_sortie (€/token) = COUT_GPU_EUR_H / (débit_sortie_tok_s × 3600) + prix_entrée (€/token) = prix_sortie × RATIO_ENTREE_SORTIE +Le prefill (tokens d'entrée) est plus rapide que le decode (tokens de sortie) : +on approxime le coût d'un token d'entrée à ~1/4 de celui d'un token de sortie. + +⚠ TOUTES les hypothèses ci-dessous sont des ordres de grandeur à AJUSTER : + - COUT_GPU_EUR_H : coût horaire réel d'une H100 sur SSP Cloud (souvent < 2,5 €/h + car mutualisé / on-prem). + - DEBIT_SORTIE_TOK_S : débit de sortie AGRÉGÉ mesuré sous charge (vLLM, batché). +Mesure le débit réel (tokens/s) puis réajuste ces constantes : les prix se +recalculent automatiquement. + +NB Langfuse : le montant est stocké sans unité (l'UI affiche « $ »). On raisonne +ici en euros — interpréter les montants comme des euros, ou convertir. + +Prérequis : LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY définis +dans l'environnement. Comme rien ne charge .env dans os.environ, lancer avec +--env-file pour injecter le .env : + +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é.""" + 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.""" + 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() From 87820798fd64d048225b386e0017b4b28cb896f2 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Thu, 23 Jul 2026 20:24:48 +0000 Subject: [PATCH 11/14] add quarto website --- tests/{test_grid.py => test_codes.py} | 0 website/_quarto.yml | 41 ++++ website/architecture.qmd | 111 +++++++++++ website/evaluation.qmd | 188 ++++++++++++++++++ website/finetuning.qmd | 118 ++++++++++++ website/index.qmd | 83 ++++++++ website/resultats.qmd | 266 ++++++++++++++++++++++++++ 7 files changed, 807 insertions(+) rename tests/{test_grid.py => test_codes.py} (100%) create mode 100644 website/_quarto.yml create mode 100644 website/architecture.qmd create mode 100644 website/evaluation.qmd create mode 100644 website/finetuning.qmd create mode 100644 website/index.qmd create mode 100644 website/resultats.qmd diff --git a/tests/test_grid.py b/tests/test_codes.py similarity index 100% rename from tests/test_grid.py rename to tests/test_codes.py 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)). From 9ee07cc61a598eefe8bcaaaa803dfe45604a0757 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Thu, 23 Jul 2026 20:27:46 +0000 Subject: [PATCH 12/14] Refactor comments and docstring --- .env.example | 3 + .gitignore | 10 ++ README.md | 110 +++++++++++-- configs/scoring/dictee_REFERENCE.yaml | 2 +- scripts/_generate_notebook_03.py | 14 +- scripts/export_predictions.py | 70 ++++++++ scripts/finetune_htr_scoledit.py | 80 ++------- scripts/run_benchmark.py | 17 +- scripts/run_htr_benchmark.py | 2 +- src/evaluation_dictee/cli.py | 62 ++++++- src/evaluation_dictee/config.py | 99 ++++------- src/evaluation_dictee/data/grid.py | 108 +++++------- src/evaluation_dictee/data/loaders.py | 64 ++++---- src/evaluation_dictee/data/reference.py | 111 ++++++++----- .../evaluation/calibration.py | 69 +++----- .../evaluation/diagnostics.py | 122 ++++++-------- .../evaluation/html_report.py | 124 +++++++------- src/evaluation_dictee/evaluation/metrics.py | 23 +-- .../evaluation/multi_model.py | 136 +++++++--------- src/evaluation_dictee/evaluation/report.py | 94 ++++------- .../evaluation/results_summary.py | 136 ++++++++++++++++ .../evaluation/statistics.py | 50 ++---- .../evaluation/visual_diff.py | 121 +++++++------- src/evaluation_dictee/models/base.py | 47 ++---- src/evaluation_dictee/models/factory.py | 18 +- src/evaluation_dictee/models/two_stage.py | 116 ++++++------- src/evaluation_dictee/models/vlm.py | 85 +++++----- src/evaluation_dictee/pipeline/alignment.py | 125 +++++++------- src/evaluation_dictee/pipeline/benchmark.py | 96 ++++------- src/evaluation_dictee/pipeline/prompts.py | 154 ++++++------------ .../transcription/htr_benchmark.py | 74 ++++----- .../transcription/htr_metrics.py | 52 +++--- .../transcription/scoledit.py | 71 ++++---- .../transcription/visual_diff.py | 92 +++++++---- .../utils/add_langfuse_models.py | 58 +++---- .../utils/add_langfuse_prompt.py | 24 +-- src/evaluation_dictee/utils/logging.py | 7 +- src/evaluation_dictee/utils/s3_export.py | 87 ++++++++++ src/evaluation_dictee/utils/tracking.py | 111 +++++-------- tests/test_alignment.py | 3 - tests/test_benchmark_resume.py | 1 - tests/test_chain_of_thought.py | 9 +- tests/test_code_coherence.py | 31 ++-- tests/test_codes.py | 10 +- tests/test_diagnostics.py | 6 +- tests/test_html_report.py | 2 - tests/test_multi_model.py | 3 - tests/test_results_summary.py | 122 ++++++++++++++ tests/test_retry_transcription.py | 2 +- tests/test_s3_export.py | 68 ++++++++ tests/test_two_stage.py | 2 +- tests/test_two_stage_parser.py | 1 - 52 files changed, 1649 insertions(+), 1455 deletions(-) create mode 100644 scripts/export_predictions.py create mode 100644 src/evaluation_dictee/evaluation/results_summary.py create mode 100644 src/evaluation_dictee/utils/s3_export.py create mode 100644 tests/test_results_summary.py create mode 100644 tests/test_s3_export.py diff --git a/.env.example b/.env.example index f7f924e..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, diff --git a/.gitignore b/.gitignore index 3ffb305..b56b358 100644 --- a/.gitignore +++ b/.gitignore @@ -50,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/README.md b/README.md index 998a657..61aaa40 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/). --- @@ -47,6 +46,26 @@ erreurs de lecture de celles de jugement. Config : `configs/htr/htr_REFERENCE.ya 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) @@ -63,18 +82,61 @@ uv sync > (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 +### 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`. -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 : +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) + ``` + +> 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. + +#### 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 @@ -118,6 +180,25 @@ uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml Cela produit `data/processed/htr_REFERENCE_htr_predictions.jsonl` et affiche le CER/WER moyens. Analyse dans `notebooks/05_analyse_transcription_htr.ipynb`. +**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 uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml @@ -253,7 +334,7 @@ rangées par famille (`scoring/`, `htr/`, `finetune/`) et documentées dans | `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é | --- @@ -303,8 +384,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 ``` --- @@ -332,7 +414,9 @@ README, avec ses prérequis et son contexte d'usage. ```bash # ─────────── Installation & configuration (une seule fois) ─────────── uv sync # environnement Python (groupe dev inclus d'office) -cp .env.example .env && nano .env # renseigner LLM_API_KEY et S3 +# 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. @@ -379,6 +463,10 @@ uv run jupyter lab notebooks/03_analyse_resultats.ipynb # analyse statistique 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) ─────────── uv run ruff format src tests scripts uv run ruff check src tests scripts diff --git a/configs/scoring/dictee_REFERENCE.yaml b/configs/scoring/dictee_REFERENCE.yaml index 3549d54..683735d 100644 --- a/configs/scoring/dictee_REFERENCE.yaml +++ b/configs/scoring/dictee_REFERENCE.yaml @@ -81,7 +81,7 @@ data: 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: 20 + limit: null # ── Grille de codage ──────────────────────────────────────────────────────── grid: 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 0e8186d..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) : +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 : - uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.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 81605b8..d479a70 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -1,11 +1,6 @@ -"""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 : - uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml +Usage : uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml """ from __future__ import annotations @@ -15,7 +10,7 @@ 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 @@ -26,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() @@ -54,8 +49,8 @@ def main() -> None: }, ) finally: - # Langfuse envoie les traces de façon asynchrone : sans flush explicite, - # le script peut se terminer avant l'envoi et perdre les dernières traces. + # 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) diff --git a/scripts/run_htr_benchmark.py b/scripts/run_htr_benchmark.py index 2de67a1..ab2e1eb 100644 --- a/scripts/run_htr_benchmark.py +++ b/scripts/run_htr_benchmark.py @@ -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() diff --git a/src/evaluation_dictee/cli.py b/src/evaluation_dictee/cli.py index db68ada..6065b72 100644 --- a/src/evaluation_dictee/cli.py +++ b/src/evaluation_dictee/cli.py @@ -1,21 +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/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).") @@ -26,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() @@ -54,15 +58,59 @@ def benchmark(config_path: str) -> None: }, ) finally: - # Langfuse envoie ses requêtes de façon asynchrone : sans flush explicite, - # le script peut se terminer avant l'envoi et perdre les dernières traces. + # Flush explicite : Langfuse envoie en asynchrone, sinon les dernières traces sont perdues. get_client().flush() _print_metrics(result.metrics) +@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.""" + """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 7ee6868..554c334 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,6 +19,10 @@ 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" @@ -34,30 +31,19 @@ class Secrets(BaseSettings): 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 - # Forcer une sortie JSON conforme au schéma via le décodage contraint de vLLM - # (response_format json_schema). Le modèle ne peut alors PAS produire de texte - # hors JSON (commentaire, note), ce qui supprime les copies « non transcrites » - # dues à un parsing impossible. Mettre à False si l'endpoint/modèle ne supporte - # pas json_schema (le décodage retombe alors sur un prompt non contraint). + # 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 @@ -65,20 +51,12 @@ 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) class GridConfig(BaseModel): @@ -95,9 +73,7 @@ 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 @@ -106,14 +82,11 @@ class ExperimentConfig(BaseModel): 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. + # "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() @@ -124,10 +97,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) @@ -142,39 +115,29 @@ 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 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) 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..07ecc8a 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 @@ -21,14 +16,7 @@ @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,21 +25,26 @@ 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())) @@ -59,15 +52,15 @@ def load_image(path: str) -> Image.Image: 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 +76,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 +95,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 9f7275e..68bbe26 100644 --- a/src/evaluation_dictee/models/two_stage.py +++ b/src/evaluation_dictee/models/two_stage.py @@ -1,21 +1,6 @@ -"""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 @@ -28,8 +13,8 @@ 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 @@ -44,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 [] @@ -97,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*"[^"]+"[^{}]*\}', @@ -124,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 @@ -146,12 +124,14 @@ 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) messages = cast( @@ -177,23 +157,36 @@ 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] messages = cast( @@ -214,7 +207,6 @@ def _code_text(self, copy: Copy, reference_text: str, transcription: str) -> Cop 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( @@ -223,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). " @@ -292,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 ] diff --git a/src/evaluation_dictee/models/vlm.py b/src/evaluation_dictee/models/vlm.py index 945e89b..e5c7139 100644 --- a/src/evaluation_dictee/models/vlm.py +++ b/src/evaluation_dictee/models/vlm.py @@ -1,9 +1,4 @@ -"""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 @@ -17,8 +12,8 @@ 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 ( @@ -30,7 +25,14 @@ 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") @@ -38,17 +40,15 @@ def _image_to_data_url(image: Image.Image) -> str: 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. + """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 : celui-ci varie d'une - copie à l'autre et n'est pas connu à l'avance. Le champ « comparaison » n'est - exigé qu'en mode chain-of-thought (aligné sur le prompt). + Contraint la structure de chaque item, pas leur nombre (variable selon la copie). Args: - chain_of_thought: si True, ajoute le champ « comparaison » au schéma. + chain_of_thought: Si True, ajoute le champ obligatoire `comparaison` à chaque item. Returns: - Un schéma JSON exploitable par `response_format` (json_schema) de vLLM. + Schéma JSON de la réponse attendue (objet avec une liste `items`). """ properties: dict[str, Any] = { "item_id": {"type": "string"}, @@ -84,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 @@ -103,17 +102,17 @@ 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] messages = cast( "list[ChatCompletionMessageParam]", @@ -127,13 +126,10 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: _image_to_data_url(image), ), ) - # Lie la génération à la version du prompt dans les traces Langfuse. - # No-op si Langfuse est hors ligne ou si le prompt vient du repli local. + # 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 {} - # Décodage contraint : le modèle est FORCÉ de produire un JSON conforme au - # schéma (aucun texte hors JSON), ce qui garantit une réponse parsable. response_format_kwargs: dict[str, Any] = {} if self.model_config.structured_output: response_format_kwargs["response_format"] = { @@ -144,15 +140,11 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: }, } - # 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 @@ -172,19 +164,18 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: 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("```") @@ -194,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] @@ -207,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 ] @@ -226,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: 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 dcd1554..a9bca47 100644 --- a/src/evaluation_dictee/pipeline/benchmark.py +++ b/src/evaluation_dictee/pipeline/benchmark.py @@ -1,12 +1,6 @@ -"""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 @@ -19,9 +13,9 @@ from tqdm import tqdm from evaluation_dictee.config import ExperimentConfig -from evaluation_dictee.data import grid +from evaluation_dictee.data import reference +from evaluation_dictee.data.grid import load_grid from evaluation_dictee.data.loaders import Copy, load_dataset -from evaluation_dictee.data.reference import load_grid from evaluation_dictee.evaluation.metrics import ScoringMetrics, compute_scoring_metrics from evaluation_dictee.models.base import Scorer from evaluation_dictee.utils.logging import get_logger @@ -32,17 +26,7 @@ @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] @@ -55,17 +39,13 @@ class BenchmarkResult: 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. - - 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. + """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() @@ -79,7 +59,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 @@ -91,23 +71,21 @@ def run_benchmark( ) -> 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`. - - 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. + É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`. 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. 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) @@ -132,7 +110,6 @@ def run_benchmark( "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" @@ -153,23 +130,20 @@ 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 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. + # Mode APPEND : conserve les copies déjà traitées lors d'une reprise. with open(out_path, "a", encoding="utf-8") as f_pred: for copy in tqdm(copies_a_traiter, desc=f"Évaluation ({config.name})"): - # Une trace Langfuse par copie : les appels LLM du scorer s'y - # imbriquent, et on y rattache l'issue (sortie, score, statut). - # No-op transparent si Langfuse est indisponible (trace vaut None). + # Une trace Langfuse par copie (no-op si Langfuse indisponible, trace vaut None). with copy_trace(copy) as trace: - # Chaque copie est isolée dans un try/except : une erreur API sur - # UNE copie ne fait plus perdre les précédentes. + # try/except par copie : une erreur API sur une copie ne perd pas les précédentes. try: - prediction = scorer.score_copy(copy, reference) + prediction = scorer.score_copy(copy, reference_text) except Exception as exc: # noqa: BLE001 — on veut TOUT rattraper ici logger.error( "Échec sur la copie %s : %s. On passe à la suivante.", @@ -198,13 +172,12 @@ def run_benchmark( pred_by_id = {it.item_id: it for it in prediction.items} - # Écriture incrémentale de chaque item + comptage de l'accord copie. n_items_copie = 0 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 "?" + true_code = reference.normalize(expert_code, scheme) + pred_code = reference.normalize(pred.code, scheme) if pred else "?" conf = pred.confidence if pred else 0.0 record = { "copy_id": copy.copy_id, @@ -221,19 +194,15 @@ def run_benchmark( if pred_code == true_code: n_accord += 1 - # 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. + # Flush + fsync : garantit que la copie écrite survit à un crash ultérieur. f_pred.flush() os.fsync(f_pred.fileno()) - # Sortie + score d'accord au niveau de la copie (onglet Scores). if trace is not None and n_items_copie: accord_copie = n_accord / n_items_copie trace.update(output={"n_items": n_items_copie, "raw_agreement": accord_copie}) - # data_type inféré NUMERIC (valeur flottante) — voir doc Langfuse Scores. trace.score_trace(name="raw_agreement", value=accord_copie) - # Sauvegarde de la liste des échecs (utile pour relancer sélectivement) if failed_copies: with open(failed_path, "w", encoding="utf-8") as f: for cid, msg in failed_copies: @@ -247,9 +216,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] = [] @@ -269,10 +236,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: diff --git a/src/evaluation_dictee/pipeline/prompts.py b/src/evaluation_dictee/pipeline/prompts.py index a8fb775..6a55b3d 100644 --- a/src/evaluation_dictee/pipeline/prompts.py +++ b/src/evaluation_dictee/pipeline/prompts.py @@ -1,27 +1,8 @@ -"""Construction des prompts d'évaluation. - -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. - -Versionnement Langfuse ----------------------- -Chaque prompt est un template « chat » (message system + message user) versionné -dans Langfuse. Le template est le MASQUE : il contient des placeholders -`{{variable}}` remplis à l'exécution. La logique conditionnelle (schéma de codage, -flags de PromptConfig) reste ICI, dans le code : elle décide de la VALEUR injectée -dans chaque variable (texte d'un bloc optionnel, ou chaîne vide). On garde donc un -seul prompt par fonction, pas une version par combinaison de flags. - -Les templates ci-dessous servent à la fois de source poussée vers Langfuse -(voir `utils/add_langfuse_prompt.py`) et de REPLI hors-ligne : si Langfuse est -indisponible (tests, incident), on compile la structure locale à l'identique. +"""Construction des prompts d'évaluation (décisions méthodologiques CLAUDE.md §3-4). + +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 @@ -32,7 +13,7 @@ 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" @@ -126,16 +107,15 @@ '{"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.' + "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.' + "Tout ajout de texte hors de la structure du JSON sera pris comme une erreur par le pipeline." ) -# Consignes sur les ratures # 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 " @@ -151,9 +131,7 @@ # ───────────────────────────────────────────────────────────────────────────── -# Templates chat (masques versionnés dans Langfuse). Les {{...}} sont remplis -# par les fonctions build_* selon leurs inputs. Ces mêmes structures servent de -# repli hors-ligne. +# Templates chat (masques versionnés dans Langfuse, {{...}} remplis par les build_*). # ───────────────────────────────────────────────────────────────────────────── _TEMPLATE_DICTATION: list[ChatMessageDict] = [ @@ -260,7 +238,14 @@ def _format_items(items: list[GridItem]) -> str: - """Formate la liste des items « N. identifiant → « mot » (nature) », un par ligne.""" + """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" @@ -271,7 +256,15 @@ def _format_items(items: list[GridItem]) -> str: def _render_local( messages: list[ChatMessageDict], variables: dict[str, object] ) -> list[ChatMessageDict]: - """Compile un template localement (repli si Langfuse est indisponible).""" + """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"] @@ -286,30 +279,22 @@ def _compile_prompt( fallback: list[ChatMessageDict], variables: dict[str, object], ) -> list[ChatMessageDict]: - """Récupère un prompt chat versionné dans Langfuse et le compile. - - Le prompt est récupéré via son label de production. `fallback` est la structure - locale (identique à celle poussée dans Langfuse) : elle sert de repli si le - prompt n'existe pas encore ou si Langfuse est indisponible (tests, incident). - On conserve la structure chat (message system + message user) : la séparation - des rôles est justement ce qu'on versionne dans Langfuse, et c'est le format - attendu par l'API (paramètre `messages`). + """Récupère et compile un prompt chat Langfuse (repli sur `fallback` local si indisponible). Args: - name: nom du prompt dans Langfuse. - fallback: messages du template local (repli et source de vérité hors-ligne). - variables: valeurs à injecter dans les placeholders {{...}}. + name: nom du prompt versionné dans Langfuse. + fallback: template local utilisé si Langfuse est indisponible. + variables: valeurs à injecter dans le template. Returns: - Les messages chat compilés (rôle + contenu), system puis user. + 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 de message (sans "content") ; nos - # prompts n'en contiennent pas, on ne garde donc que les messages role/content. + # 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: @@ -319,19 +304,14 @@ def _compile_prompt( def attach_image(messages: list[ChatMessageDict], image_data_url: str) -> list[dict[str, object]]: - """Attache une image au dernier message (user) d'une liste de messages chat. - - Les fonctions build_* renvoient des messages à contenu texte simple. Pour un - appel multimodal, l'image doit être jointe au message user sous forme de contenu - structuré (bloc texte + bloc image), format attendu par l'API OpenAI/vLLM. Les - messages précédents (system) sont laissés intacts. + """Attache une image au dernier message user, en contenu structuré (format multimodal vLLM). Args: - messages: messages chat (system + user) à contenu texte. - image_data_url: image encodée en data URL base64. + 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 dernier message user. + 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] @@ -348,20 +328,13 @@ def attach_image(messages: list[ChatMessageDict], image_data_url: str) -> list[d def fetch_prompt(name: str) -> ChatPromptClient | None: - """Renvoie l'objet prompt Langfuse pour `name`, ou None si indisponible. - - Sert à lier une génération à la VERSION du prompt dans les traces Langfuse - (paramètre `langfuse_prompt` du client `langfuse.openai`). On renvoie None si - Langfuse est injoignable OU si le prompt est servi depuis le repli local - (`is_fallback`) : il n'y a alors pas de version distante à référencer, et lier - une trace à un repli fausserait l'analyse. La récupération est mise en cache - côté client par Langfuse : l'appel reste bon marché même répété par copie. + """Renvoie l'objet prompt Langfuse pour lier une trace à sa version (None si repli local). Args: - name: nom du prompt versionné (une clé de PROMPT_TEMPLATES). + name: nom du prompt versionné dans Langfuse. Returns: - L'objet prompt chat, ou None si aucun lien fiable n'est possible. + 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]) @@ -376,23 +349,16 @@ def build_dictation_prompt( config: PromptConfig, scheme: str = "simplifiee", ) -> list[ChatMessageDict]: - """Construit le prompt d'évaluation d'une dictée (méthode C, end-to-end). - - Charge le template versionné « Dictation » depuis Langfuse et le remplit selon - les inputs : les blocs conditionnels (fidélité, ratures, chain-of-thought) et le - schéma de codage déterminent la valeur injectée dans chaque variable. + """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: - Les messages chat (system + user) à envoyer au modèle. L'image est ajoutée - au message user par l'appelant via `attach_image`. + Les messages du prompt compilés, prêts à recevoir l'image. """ grille = _GRILLE_COMPLETE if scheme == "complete" else _GRILLE_SIMPLIFIEE @@ -404,9 +370,6 @@ def build_dictation_prompt( 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. consigne_cot = "\n\n" + _CONSIGNE_COT format_sortie = _FORMAT_ITEMS_COT else: @@ -434,19 +397,13 @@ def build_dictation_prompt( def build_transcription_prompt(read_final_state: bool = True) -> list[ChatMessageDict]: - """Prompt de l'ÉTAPE 1 (HTR) : transcrire fidèlement l'image, sans coder. - - Charge le template versionné « Transcription » depuis Langfuse. 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. Il restitue exactement ce que l'élève a écrit, fautes - comprises. + """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: - Les messages chat (system + user). L'image est ajoutée au message user par - l'appelant via `attach_image`. + Les messages du prompt de transcription compilés. """ consigne_ratures = ("\n" + _CONSIGNE_RATURES_TRANSCRIPTION) if read_final_state else "" return _compile_prompt( @@ -462,21 +419,16 @@ def build_text_coding_prompt( items: list[GridItem], scheme: str = "simplifiee", ) -> list[ChatMessageDict]: - """Prompt de l'ÉTAPE 2 : coder à partir du TEXTE transcrit (sans image). - - Charge le template versionné « Text coding » depuis Langfuse. 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, - et les messages sont envoyés tels quels (pas d'image à joindre). + """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: - Les messages chat (system + user) à envoyer au modèle. + Les messages du prompt de codage textuel compilés. """ grille = _GRILLE_COMPLETE if scheme == "complete" else _GRILLE_SIMPLIFIEE return _compile_prompt( diff --git a/src/evaluation_dictee/transcription/htr_benchmark.py b/src/evaluation_dictee/transcription/htr_benchmark.py index 39747e3..2bec298 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 @@ -34,18 +30,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, @@ -54,28 +53,26 @@ 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) - # Messages (system + user) du prompt de transcription ; l'image est jointe - # au message user à chaque appel via attach_image. 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 = cast( @@ -98,7 +95,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() @@ -109,14 +113,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 @@ -135,13 +132,14 @@ def run_htr_benchmark( """Transcrit tous les échantillons et calcule les métriques HTR. 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. + run_name: Nom du run (préfixe du fichier de prédictions JSONL). + output_dir: Dossier de sortie des prédictions. 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 = [] @@ -167,7 +165,7 @@ def run_htr_benchmark( ) 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 index d6395ae..7b161de 100644 --- a/src/evaluation_dictee/utils/add_langfuse_models.py +++ b/src/evaluation_dictee/utils/add_langfuse_models.py @@ -1,29 +1,8 @@ -"""Enregistre les prix THÉORIQUES des modèles dans Langfuse (coût par token). +"""Enregistre dans Langfuse un prix par token THÉORIQUE (GPU amorti) pour les modèles llm.lab. -`gemma4-26b-moe` et `qwen3.6-35b-moe` sont auto-hébergés sur llm.lab (vLLM, GPU -SSP Cloud) : ils n'ont pas de tarif commercial. On enregistre donc un coût -théorique **amorti sur le GPU**, pour que Langfuse valorise chaque trace -(coût = tokens × prix, calculé côté serveur, y compris rétroactivement). - -Base de calcul (GPU amorti) : - prix_sortie (€/token) = COUT_GPU_EUR_H / (débit_sortie_tok_s × 3600) - prix_entrée (€/token) = prix_sortie × RATIO_ENTREE_SORTIE -Le prefill (tokens d'entrée) est plus rapide que le decode (tokens de sortie) : -on approxime le coût d'un token d'entrée à ~1/4 de celui d'un token de sortie. - -⚠ TOUTES les hypothèses ci-dessous sont des ordres de grandeur à AJUSTER : - - COUT_GPU_EUR_H : coût horaire réel d'une H100 sur SSP Cloud (souvent < 2,5 €/h - car mutualisé / on-prem). - - DEBIT_SORTIE_TOK_S : débit de sortie AGRÉGÉ mesuré sous charge (vLLM, batché). -Mesure le débit réel (tokens/s) puis réajuste ces constantes : les prix se -recalculent automatiquement. - -NB Langfuse : le montant est stocké sans unité (l'UI affiche « $ »). On raisonne -ici en euros — interpréter les montants comme des euros, ou convertir. - -Prérequis : LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY définis -dans l'environnement. Comme rien ne charge .env dans os.environ, lancer avec ---env-file pour injecter le .env : +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 @@ -41,25 +20,42 @@ 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 +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 + "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é.""" + """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.""" + """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") diff --git a/src/evaluation_dictee/utils/add_langfuse_prompt.py b/src/evaluation_dictee/utils/add_langfuse_prompt.py index f827dda..cc4a9a6 100644 --- a/src/evaluation_dictee/utils/add_langfuse_prompt.py +++ b/src/evaluation_dictee/utils/add_langfuse_prompt.py @@ -1,19 +1,8 @@ -"""Initialise / met à jour les prompts d'évaluation dans Langfuse. +"""Pousse les templates chat de `pipeline/prompts.py` vers Langfuse. -Pousse les trois templates chat (message system + message user) définis dans -`pipeline/prompts.py` vers le gestionnaire de prompts de Langfuse. Chaque appel -crée une NOUVELLE version de chaque prompt et la promeut en production via le -label "production". - -Les variables entre doubles accolades ({{reference_text}}, {{grille}}, ...) sont -des placeholders Langfuse. Elles sont remplies à l'exécution par les fonctions -build_* de prompts.py via compile(). La logique conditionnelle (schéma de codage, -flags de PromptConfig) reste dans le code : elle choisit la valeur injectée dans -chaque variable (texte d'un bloc optionnel, ou chaîne vide). On garde donc un seul -prompt versionné par fonction, pas une version par combinaison de flags. - -Prérequis : LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY et LANGFUSE_SECRET_KEY définis dans -l'environnement (ou le fichier .env). Voir .env.example. +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 @@ -38,7 +27,7 @@ def push_prompts() -> None: name=name, type="chat", prompt=list(messages), - labels=["production"], # promeut cette version comme version de prod + labels=["production"], ) logger.info( "Prompt « %s » poussé (version %s), labels : %s", @@ -47,8 +36,7 @@ def push_prompts() -> None: prompt.labels, ) - # Langfuse envoie ses requêtes de façon asynchrone : flush explicite avant de - # terminer le script, sinon les créations peuvent être perdues. + # Flush explicite : Langfuse envoie en asynchrone, sinon les créations sont perdues. langfuse.flush() 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 29cb506..1840715 100644 --- a/src/evaluation_dictee/utils/tracking.py +++ b/src/evaluation_dictee/utils/tracking.py @@ -1,31 +1,9 @@ -"""Suivi d'expériences avec Langfuse (traces, prompts, scores). - -Structure conforme aux recommandations Langfuse pour un benchmark : - - session (= un lancement) → trace (= une copie) → generation (= appel LLM) - -- `experiment_run` ouvre le contexte du run. Il rattache un `session_id` UNIQUE - PAR LANCEMENT (nom du run + horodatage), un `user_id` (le namespace Kubernetes - de l'utilisateur, cf. `KUBERNETES_NAMESPACE` sur Onyxia) et les paramètres de - l'expérience (modèle, méthode, schéma, corpus) en tags/metadata, PROPAGÉS à - toutes les traces créées pendant le run (`propagate_attributes`). Chaque exécution de l'évaluation - ouvre ainsi une NOUVELLE session Langfuse : relancer la même config (reprise - après crash comprise) ne réutilise pas la session précédente, qui est close de - fait — plus aucune trace ne s'y rattache. Le nom du run reste porté en tag/metadata - (`run:`) pour regrouper au besoin les lancements d'une même config. - Il n'ouvre volontairement PAS de trace englobante : - chaque copie devient ainsi une trace autonome — inspectable individuellement, - avec ses propres entrée/sortie et scores — regroupée dans la session du run. - (Une trace unique de plusieurs milliers de générations serait ingérable dans - l'UI et ferait perdre l'inspection copie par copie.) -- `copy_trace` ouvre une trace par copie ; les générations LLM émises par - `VLMScorer` (client `langfuse.openai`) s'y imbriquent automatiquement. -- `log_metrics` enregistre les métriques agrégées du run à la fois en metadata ET - en Scores Langfuse (onglet « Scores » de l'UI), sur une trace de synthèse - rattachée à la session. - -Tout est optionnel : si Langfuse est indisponible ou mal configuré, les fonctions -deviennent silencieuses (no-op) afin de ne pas bloquer un développement local. +"""Suivi d'expériences Langfuse : session (= un lancement) → trace (= une copie) → generation. + +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 @@ -50,7 +28,14 @@ def _run_metadata(config: ExperimentConfig) -> dict[str, str]: - """Paramètres de l'expérience, en chaînes US-ASCII (contrainte Langfuse metadata).""" + """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, @@ -61,64 +46,57 @@ def _run_metadata(config: ExperimentConfig) -> dict[str, str]: def _run_tags(config: ExperimentConfig) -> list[str]: - """Tags de filtrage/regroupement dans l'UI Langfuse.""" + """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}", - # Le session_id étant unique par lancement, ce tag regroupe tous les - # lancements d'une même config dans l'UI. + # session_id unique par lancement : ce tag regroupe les lancements d'une config f"run:{config.name}", ] def _user_id() -> str | None: - """Identifiant d'utilisateur Langfuse, basé sur le namespace Kubernetes. - - Sur le SSP Cloud (Onyxia), chaque utilisateur dispose d'un namespace dédié - (variable `KUBERNETES_NAMESPACE`, typiquement « user- »), qui sert - d'identifiant naturel pour attribuer les traces dans l'UI Langfuse. + """Identifiant utilisateur Langfuse = namespace Kubernetes (KUBERNETES_NAMESPACE), ou None. Returns: - La valeur de `KUBERNETES_NAMESPACE`, ou None hors de ce contexte (le - `user_id` est alors simplement omis des traces). + 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: - """Identifiant de session Langfuse UNIQUE PAR LANCEMENT (nom du run + horodatage). - - Chaque exécution de l'évaluation ouvre ainsi une nouvelle session, même en - relançant la même config. Format trié chronologiquement : « -AAAAMMJJ-HHMMSS ». + """Session Langfuse UNIQUE PAR LANCEMENT : « -AAAAMMJJ-HHMMSS » (triable dans le temps). Args: - config: configuration de l'expérience (fournit le nom du run). + config: Configuration de l'expérience (fournit `config.name`). Returns: - L'identifiant de session du lancement courant. + 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[str]: - """Ouvre le contexte Langfuse d'un run de benchmark, dans une session neuve. + """Ouvre le contexte Langfuse d'un run (session neuve, attributs propagés aux traces). - Génère un `session_id` unique pour CE lancement (voir `_launch_session_id`) et - le rattache — avec le `user_id` (namespace Kubernetes, voir `_user_id`) et les - paramètres de l'expérience — en tags/metadata propagés à toutes les traces - créées dans ce contexte. Chaque copie évaluée devient alors - une trace autonome regroupée dans cette session, et ses appels LLM s'y - imbriquent (voir `copy_trace`). Relancer la même config ouvre une nouvelle - session (l'ancienne est close de fait). + 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 (nom du run, paramètres). + config: Configuration de l'expérience à tracer. Yields: - Le `session_id` du lancement courant (utile pour le journaliser). + L'identifiant de session généré pour ce lancement. """ session_id = _launch_session_id(config) user_id = _user_id() @@ -139,18 +117,13 @@ def experiment_run(config: ExperimentConfig) -> Iterator[str]: @contextmanager def copy_trace(copy: Copy) -> Iterator[LangfuseSpan | None]: - """Ouvre une trace Langfuse pour une copie (à utiliser dans `experiment_run`). - - Les générations LLM émises pendant l'évaluation de la copie (client - `langfuse.openai` de `VLMScorer`, tentatives successives comprises) - s'imbriquent automatiquement sous cette trace. L'appelant complète l'objet - renvoyé : `.update(output=..., level=...)`, `.score_trace(...)`. + """Ouvre une trace Langfuse pour une copie (les générations LLM s'y imbriquent). Args: - copy: copie évaluée (fournit l'identifiant et l'entrée de la trace). + copy: Copie à tracer (fournit `copy_id` et `item_ids`). Yields: - La trace (span racine de la copie), ou None si Langfuse est indisponible. + Le span Langfuse de la copie, ou None si Langfuse est indisponible. """ langfuse = get_client() try: @@ -166,15 +139,15 @@ def copy_trace(copy: Copy) -> Iterator[LangfuseSpan | None]: def log_metrics(config: ExperimentConfig, metrics: dict[str, Any]) -> None: - """Enregistre les métriques agrégées du run dans Langfuse. + """Enregistre les métriques agrégées du run (metadata + Scores Langfuse). No-op si absent. - Crée une trace de synthèse (« run-summary: ») rattachée à la session du - run, portant les métriques à la fois en metadata ET en Scores Langfuse - (onglet « Scores » de l'UI). No-op si Langfuse est indisponible. + 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: - config: configuration de l'expérience (nomme la trace, rattache à la session). - 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. """ langfuse = get_client() numeric = {k: float(v) for k, v in metrics.items() if isinstance(v, (int, float))} 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_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 9cd5e63..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 = [ @@ -18,19 +18,16 @@ def _texte(messages: list[dict]) -> str: def test_cot_desactive_pas_de_champ_comparaison() -> None: 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 = _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 = _texte(build_dictation_prompt("Le soir", ITEMS, PromptConfig(chain_of_thought=True))) - # La consigne indique bien AVANT le code (ordre du raisonnement) 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_codes.py b/tests/test_codes.py index 42cee52..9ce1dfb 100644 --- a/tests/test_codes.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_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_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 2f1e4d5..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 = [ 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" From 1d50b1f1718a0d8ae2845533798d6dba190af491 Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Fri, 24 Jul 2026 12:53:29 +0000 Subject: [PATCH 13/14] parrallel scoring to reduce pipeline computation time --- README.md | 7 + configs/scoring/dictee_REFERENCE.yaml | 7 + src/evaluation_dictee/config.py | 4 + src/evaluation_dictee/pipeline/benchmark.py | 166 +++++++++++++------- tests/test_benchmark_concurrency.py | 161 +++++++++++++++++++ 5 files changed, 288 insertions(+), 57 deletions(-) create mode 100644 tests/test_benchmark_concurrency.py diff --git a/README.md b/README.md index 61aaa40..3c466fc 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,13 @@ 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 diff --git a/configs/scoring/dictee_REFERENCE.yaml b/configs/scoring/dictee_REFERENCE.yaml index 683735d..8e96dd0 100644 --- a/configs/scoring/dictee_REFERENCE.yaml +++ b/configs/scoring/dictee_REFERENCE.yaml @@ -23,6 +23,13 @@ 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. diff --git a/src/evaluation_dictee/config.py b/src/evaluation_dictee/config.py index 554c334..1bc91c9 100644 --- a/src/evaluation_dictee/config.py +++ b/src/evaluation_dictee/config.py @@ -82,6 +82,10 @@ class ExperimentConfig(BaseModel): name: str = Field(..., description="Nom unique du run, utilisé comme nom de trace Langfuse") seed: int = 42 + # 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" diff --git a/src/evaluation_dictee/pipeline/benchmark.py b/src/evaluation_dictee/pipeline/benchmark.py index a9bca47..820ab06 100644 --- a/src/evaluation_dictee/pipeline/benchmark.py +++ b/src/evaluation_dictee/pipeline/benchmark.py @@ -5,8 +5,10 @@ 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 @@ -38,6 +40,17 @@ class BenchmarkResult: non_transcribed: list[str] = field(default_factory=list) +@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") + + 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). @@ -68,6 +81,7 @@ 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. @@ -75,10 +89,16 @@ def run_benchmark( 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`. + 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 (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: Le résultat du run : métriques agrégées, labels/prédictions alignés et @@ -132,76 +152,108 @@ def run_benchmark( reference_text = load_grid(config.data.grid_path).reference_text scheme = config.grid.scheme + workers = concurrency if concurrency is not None else config.concurrency non_transcrites: list[str] = [] failed_copies: list[tuple[str, str]] = [] # (copy_id, message d'erreur) - # Mode APPEND : conserve les copies déjà traitées lors d'une reprise. - with open(out_path, "a", encoding="utf-8") as f_pred: - for copy in tqdm(copies_a_traiter, desc=f"Évaluation ({config.name})"): - # Une trace Langfuse par copie (no-op si Langfuse indisponible, trace vaut None). - with copy_trace(copy) as trace: - # try/except par copie : une erreur API sur une copie ne perd pas les précédentes. - try: - prediction = scorer.score_copy(copy, reference_text) - 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, - ) - failed_copies.append((copy.copy_id, str(exc))) - if trace is not None: - trace.update(level="ERROR", status_message=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, + 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: + 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}, ) - if trace is not None: - trace.update( - level="WARNING", - status_message="copie non transcrite", - output={"transcribed": False, "n_attempts": prediction.n_attempts}, - ) - continue - - pred_by_id = {it.item_id: it for it in prediction.items} - - n_items_copie = 0 - 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 = reference.normalize(expert_code, scheme) - pred_code = reference.normalize(pred.code, scheme) if pred else "?" - conf = pred.confidence if pred else 0.0 - record = { + return _CopyOutcome( + copy.copy_id, "non_transcribed", n_attempts=prediction.n_attempts + ) + + pred_by_id = {it.item_id: it for it in prediction.items} + 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 = 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": conf, + "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, } - f_pred.write(json.dumps(record, ensure_ascii=False) + "\n") - n_items_copie += 1 - if pred_code == true_code: - n_accord += 1 - - # Flush + fsync : garantit que la copie écrite survit à un crash ultérieur. - f_pred.flush() - os.fsync(f_pred.fileno()) - - if trace is not None and n_items_copie: - accord_copie = n_accord / n_items_copie - trace.update(output={"n_items": n_items_copie, "raw_agreement": accord_copie}) - trace.score_trace(name="raw_agreement", value=accord_copie) + ) + 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}) + 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) + + 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" + 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: diff --git a/tests/test_benchmark_concurrency.py b/tests/test_benchmark_concurrency.py new file mode 100644 index 0000000..138ef33 --- /dev/null +++ b/tests/test_benchmark_concurrency.py @@ -0,0 +1,161 @@ +"""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) + + +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_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 From c44d81b43aa65fbaade0ff477f6195c562a2f53c Mon Sep 17 00:00:00 2001 From: Lauragaimard Date: Sat, 25 Jul 2026 14:59:15 +0000 Subject: [PATCH 14/14] Fix empty pages (transcription error) --- README.md | 2 + configs/htr/htr_REFERENCE.yaml | 5 + configs/scoring/dictee_REFERENCE.yaml | 10 +- configs/scoring/dictee_end2end.yaml | 45 +++++++++ configs/scoring/dictee_two_stage.yaml | 52 ++++++++++ scripts/run_benchmark.py | 7 ++ scripts/run_htr_benchmark.py | 7 +- src/evaluation_dictee/config.py | 7 ++ src/evaluation_dictee/data/loaders.py | 17 ++++ src/evaluation_dictee/pipeline/benchmark.py | 76 ++++++++++++--- .../transcription/htr_benchmark.py | 65 ++++++++----- tests/test_benchmark_concurrency.py | 29 ++++++ tests/test_htr_concurrency.py | 95 +++++++++++++++++++ 13 files changed, 373 insertions(+), 44 deletions(-) create mode 100644 configs/scoring/dictee_end2end.yaml create mode 100644 configs/scoring/dictee_two_stage.yaml create mode 100644 tests/test_htr_concurrency.py diff --git a/README.md b/README.md index 3c466fc..78e503b 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml 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 diff --git a/configs/htr/htr_REFERENCE.yaml b/configs/htr/htr_REFERENCE.yaml index f50b28f..ef6aaa8 100644 --- a/configs/htr/htr_REFERENCE.yaml +++ b/configs/htr/htr_REFERENCE.yaml @@ -17,6 +17,11 @@ # 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) : diff --git a/configs/scoring/dictee_REFERENCE.yaml b/configs/scoring/dictee_REFERENCE.yaml index 8e96dd0..9b20820 100644 --- a/configs/scoring/dictee_REFERENCE.yaml +++ b/configs/scoring/dictee_REFERENCE.yaml @@ -68,11 +68,11 @@ model: # ── 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 +model_stage2: + name: gemma4-26b-moe + kind: llm + temperature: 0.0 + max_tokens: 8192 # ── Données ─────────────────────────────────────────────────────────────── data: 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/scripts/run_benchmark.py b/scripts/run_benchmark.py index d479a70..b6351f7 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -46,6 +46,8 @@ def main() -> None: "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: @@ -55,6 +57,11 @@ def main() -> None: 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 ab2e1eb..30ad1d0 100644 --- a/scripts/run_htr_benchmark.py +++ b/scripts/run_htr_benchmark.py @@ -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/config.py b/src/evaluation_dictee/config.py index 1bc91c9..1e73504 100644 --- a/src/evaluation_dictee/config.py +++ b/src/evaluation_dictee/config.py @@ -57,6 +57,11 @@ class DataConfig(BaseModel): # Grille JSON (versionnée dans configs/) : mot attendu + fautes connues par item. grid_path: str = "configs/grille_dictee_2015.json" 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): @@ -129,6 +134,8 @@ class HTRExperimentConfig(BaseModel): 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 read_final_state: bool = True # en cas de rature, lire l'état final diff --git a/src/evaluation_dictee/data/loaders.py b/src/evaluation_dictee/data/loaders.py index 07ecc8a..63c8903 100644 --- a/src/evaluation_dictee/data/loaders.py +++ b/src/evaluation_dictee/data/loaders.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field import fsspec +import numpy as np from PIL import Image @@ -51,6 +52,22 @@ def load_image(path: str) -> Image.Image: 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 (séparateur ';', local ou S3). diff --git a/src/evaluation_dictee/pipeline/benchmark.py b/src/evaluation_dictee/pipeline/benchmark.py index 820ab06..6ec2050 100644 --- a/src/evaluation_dictee/pipeline/benchmark.py +++ b/src/evaluation_dictee/pipeline/benchmark.py @@ -17,9 +17,9 @@ from evaluation_dictee.config import ExperimentConfig from evaluation_dictee.data import reference from evaluation_dictee.data.grid import load_grid -from evaluation_dictee.data.loaders import Copy, load_dataset +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 @@ -38,6 +38,7 @@ 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) @dataclass @@ -49,6 +50,7 @@ class _CopyOutcome: 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") def _load_processed_copy_ids(predictions_path: Path) -> set[str]: @@ -153,9 +155,11 @@ def run_benchmark( 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) + 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). @@ -166,23 +170,45 @@ def _score_one(copy: Copy) -> _CopyOutcome: """ # 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_text) - except Exception as exc: # noqa: BLE001 — on veut TOUT rattraper ici + is_blank = ( + blank_threshold > 0 and ink_ratio(load_image(copy.image_path)) < blank_threshold + ) + 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 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 + 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 + ], ) + 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} records = [] @@ -201,6 +227,8 @@ def _score_one(copy: Copy) -> _CopyOutcome: "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: @@ -208,11 +236,17 @@ def _score_one(copy: Copy) -> _CopyOutcome: if trace is not None and records: accord_copie = n_accord / len(records) - trace.update(output={"n_items": len(records), "raw_agreement": accord_copie}) + 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) + 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).""" @@ -231,6 +265,8 @@ def _consume(outcome: _CopyOutcome, f_pred: object) -> None: 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] @@ -309,6 +345,15 @@ def _consume(outcome: _CopyOutcome, f_pred: object) -> None: 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, @@ -319,4 +364,5 @@ def _consume(outcome: _CopyOutcome, f_pred: object) -> None: copy_ids=copy_ids, predictions_path=out_path, non_transcribed=non_transcrites, + blank_copies=blank_copies, ) diff --git a/src/evaluation_dictee/transcription/htr_benchmark.py b/src/evaluation_dictee/transcription/htr_benchmark.py index 2bec298..a021974 100644 --- a/src/evaluation_dictee/transcription/htr_benchmark.py +++ b/src/evaluation_dictee/transcription/htr_benchmark.py @@ -9,6 +9,7 @@ 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 @@ -128,41 +129,59 @@ 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 à transcrire. - transcriber: Transcripteur VLM à utiliser. + 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: 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 diff --git a/tests/test_benchmark_concurrency.py b/tests/test_benchmark_concurrency.py index 138ef33..27192e5 100644 --- a/tests/test_benchmark_concurrency.py +++ b/tests/test_benchmark_concurrency.py @@ -85,6 +85,9 @@ def _no_trace(copy): 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: @@ -144,6 +147,32 @@ def test_failures_and_non_transcribed(patched, monkeypatch, tmp_path: Path) -> N 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) 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