diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cecb671 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tooling + run: pip install ruff pytest + + - name: Lint + run: ruff check . + + - name: Syntax check + run: python -m compileall -q src tests + + # Unit tests are dependency-free (pure-Python metrics), so no `uv sync` + # is needed; keep the CI fast until integration tests exist. + - name: Unit tests + run: pytest -q diff --git a/.gitignore b/.gitignore index fcb1181..0a30224 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,19 @@ # Personnal usage test.ipynb +# Generated evaluation run artifacts (predictions, metrics, diagnostics, review +# logs) - reproducible outputs of scripts in src/evaluation/ and the root-level +# eval scripts, not source. Versioned inputs (eval_set*.parquet) are not ignored. +data/eval/results/ +data/eval/results_sample*/ +data/eval/train_verification*/ +data/eval/embedding_diagnostics/ +data/eval/human_review/ + +# Notebooks rendered from jupytext percent-format scripts (evaluate_train_labels.py +# and similar) via `jupytext --to notebook` - the .py is the source of truth. +evaluate_train_labels.ipynb + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/data/eval/eval_set.parquet b/data/eval/eval_set.parquet new file mode 100644 index 0000000..2121661 Binary files /dev/null and b/data/eval/eval_set.parquet differ diff --git a/data/eval/eval_set_sample15.parquet b/data/eval/eval_set_sample15.parquet new file mode 100644 index 0000000..8ef0edd Binary files /dev/null and b/data/eval/eval_set_sample15.parquet differ diff --git a/data/eval/eval_set_sample30.parquet b/data/eval/eval_set_sample30.parquet new file mode 100644 index 0000000..6509b33 Binary files /dev/null and b/data/eval/eval_set_sample30.parquet differ diff --git a/docs/cadrage_2026-07.md b/docs/cadrage_2026-07.md new file mode 100644 index 0000000..bc23614 --- /dev/null +++ b/docs/cadrage_2026-07.md @@ -0,0 +1,128 @@ +# Document de cadrage — Projet GRAAL + +--- + +## 1. Description du projet + +**GRAAL** (*Graph-based Reasoning Agents for Automatic Labelling*) est un framework qui combine : + +- une **base de données graphe** (Neo4j) représentant une nomenclature statistique hiérarchique (codes, libellés, notices explicatives, relations parent/enfant) ; +- des **agents LLM à outils** (*tool-calling*), qui interrogent et parcourent ce graphe pour raisonner sur la classification d'un texte libre vers un code de nomenclature. + +Le projet est actuellement instancié sur la **NAF** (Nomenclature d'Activités Française, ~700 codes terminaux, 5 niveaux hiérarchiques) toutefois le pipeline a vocataion à être générique et la construction du graphe est déjà paramétrée pour d'autres nomenclatures (NACE en anglais, COICOP FR/EN). + +### 1.1 Objectifs + +1. **S'affranchir de la dépendance aux données labellisées.** Le modèle de production actuel (classification supervisée par *deep learning*, package `torchTextClassifiers`) est frugal et performant, mais nécessite 100k à 1M d'observations labellisées pour être entraîné — un pré-requis qui n'est pas toujours disponible (nouvelle nomenclature, nouveau domaine, etc.). +2. **Introduire du raisonnement et de la traçabilité.** Contrairement à une approche par similarité d'embeddings (RAG « plat »), un agent doit pouvoir justifier chaque étape de sa décision (*chain-of-thought*) et exploiter explicitement la structure hiérarchique de la nomenclature plutôt que de comparer des vecteurs. +3. **Généricité.** Le framework ne doit rien connaître de la nomenclature métier : il suffit de peupler une base Neo4j (codes, libellés, notices, hiérarchie) pour que les agents fonctionnent. GRAAL se positionne comme un « distributeur » d'outils, de prompts et de contrats d'appel LLM (validation stricte des entrées/sorties via Pydantic). + +### 1.2 Enjeux + +- **Enjeu métier** : disposer d'une solution de codification qui fonctionne (a) quand on n'a pas ou peu de données labellisées, (b) quand une nomenclature est révisée par le métier et qu'il faut recoder l'historique, (c) quand les données labellisées existantes sont de qualité incertaine et doivent être fiabilisées/corrigées. +- **Enjeu MLOps** : la chaîne de production actuelle (stockage S3/Datalab, entraînement distribué Argo Workflows, *serving* FastAPI conteneurisé) est mature sur les volets Data, Modèle et Déploiement, mais **le monitoring en production reste le point faible** : sans annotation humaine continue, il n'existe pas aujourd'hui de moyen de contrôler la dérive du modèle déployé. GRAAL est une piste pour combler ce manque. +- **Enjeu méthodologique** : un premier prototype zero-shot par RAG a été testé et a révélé plusieurs limites structurelles (dépendance au découpage des notices, dépendance au modèle d'embedding, saturation du contexte, hétérogénéité de la comparaison notice/libellé, absence de traçabilité, non prise en compte de la hiérarchie). Ces limites motivent directement le choix de l'approche agentique. + +### 1.3 Positionnement par rapport aux besoins de codification automatique + +GRAAL **ne vise pas à remplacer** le modèle supervisé en production : une architecture agentique multi-appels LLM est aujourd'hui trop coûteuse en latence et en ressources de calcul (pas de GPU dédié, temps d'inférence incompatible avec un usage de masse en production). GRAAL se positionne comme une **brique complémentaire**, mobilisable sur trois cas d'usage bien délimités : + +| Cas d'usage | Description | Composant GRAAL mobilisé | +|---|---|---| +| **Monitoring du modèle en production** | Analyser en continu (ou en échantillonnage) les prédictions du modèle supervisé et détecter les codifications douteuses, sans annotation humaine | *MatchVerifier* (vérifie une correspondance libellé ↔ code), avec appel au *Navigator* pour proposer un recodage si besoin | +| **Recodage d'une base existante** | Recoder un stock de données suite à une révision de nomenclature, ou fiabiliser un historique de labels incertains | *Navigator* (classification hiérarchique agentique) | +| **Génération de données d'entraînement synthétiques** | Produire des libellés synthétiques par code, pour densifier les classes rares et ré-entraîner/améliorer le modèle supervisé | *Code2Text* / *NaiveCode2Text* | + +Ce positionnement conditionne la roadmap : la priorité n'est pas de « battre » le modèle supervisé en production, mais de **livrer une évaluation chiffrée et honnête** de ce que chacun de ces trois cas d'usage peut apporter concrètement. + +--- + +## 2. État d'avancement au 6 juillet 2026 + +### 2.1 Ce qui fonctionne + +- **Le graphe Neo4j de la nomenclature** est opérationnel de bout en bout : construction depuis un fichier de notices (parquet), calcul et stockage des embeddings, relations hiérarchiques `HAS_PARENT`. Le pipeline de construction (`src/neo4j_graph/graph_builder`) est déjà paramétré pour plusieurs nomenclatures (NAF, NACE EN, COICOP FR/EN), preuve concrète de la généricité recherchée. +- **L'agent *Navigator*** (classification hiérarchique agentique) fonctionne en usage individuel et en usage batch (fichier de requêtes), avec traçage complet des appels via Langfuse (observabilité, sessions, coûts). Il expose un ensemble d'outils Neo4j génériques et réutilisables (`get_current_children`, `get_current_siblings`, `go_to_parent`, `go_to_child`, `navigate_to`, etc.), communs à tous les agents du framework. +- **Les agents « closers »** (*CodeChooser* : choix argumenté parmi K codes candidats ; *MatchVerifier* : validation d'une correspondance libellé/code) sont implémentés et fonctionnels **isolément**, avec des contrats de sortie stricts (schémas Pydantic, score de confiance, explication). +- **Une première chaîne de génération de données synthétiques** (*NaiveCode2Text*) produit des libellés synthétiques exploitables à partir des notices officielles (plusieurs itérations de prompt testées : zero-shot 1-par-1, puis génération par lots de 10, orientation « métier »), avec des exemples de résultats déjà générés et sauvegardés. +- **Une CLI** (`src/main.py`) permet de lancer une classification unitaire ou en lot avec le *Navigator*. + +### 2.2 Ce qui ne fonctionne pas encore + +- **Le classifieur « Agentic RAG »** (approche alternative combinant recherche par embeddings + *CodeChooser*) n'est pas branché dans le pipeline principal : `main.py` appelle aujourd'hui une fonction *stub* (`classify_agentic_rag`) qui renvoie une valeur codée en dur. → **Corrigé le 6/07** : le classifieur est branché dans la CLI (`--agentic-rag`) ; validation fonctionnelle sur la base Neo4j à faire. **Redesigné le 8/07** : l'approche n'arbitre plus entre des candidats via *CodeChooser*, mais utilise le code le plus proche par similarité d'embeddings comme simple *point de départ* (*warm start*), que l'agent *Navigator* vérifie et corrige ensuite via ses outils habituels (cf. §2.2 ci-dessous et `docs/framework.md` §5.2) — voir aussi le résultat préoccupant du diagnostic de l'espace d'embedding qui sous-tend ce point de départ. +- **Le chaînage *Navigator* → *MatchVerifier*** n'est pas encore implémenté : le *Navigator* s'arrête aujourd'hui dès qu'il atteint un code terminal, sans double vérification automatique par un second agent. C'est pourtant l'articulation nécessaire au cas d'usage « monitoring en production ». → **Implémenté le 6/07** via l'option `--verify` de la CLI (en mode unitaire comme en batch) ; validation fonctionnelle sur données réelles à faire. +- **Aucune méthodologie d'évaluation n'est encore formalisée** : ni jeu de données de référence versionné, ni métriques automatisées, ni tableau de bord de suivi. À ce stade, l'évaluation n'existe qu'à l'état de script exploratoire ponctuel (projection UMAP/PaCMAP/t-SNE/PCA des embeddings, calcul de k-plus-proches-voisins). C'est le chantier prioritaire du mois (cf. §3). → **Socle posé et jeu d'évaluation construit le 6/07** : module `src/evaluation/` (métriques d'exactitude à la feuille et par niveau hiérarchique testées unitairement, échantillonnage stratifié reproductible, harnais de campagne) ; **`data/eval/eval_set.parquet` est constitué et versionné** (5 181 lignes, stratifié par code complet `apet2025`, ~10 exemples/code). Restent à mener : les campagnes chiffrées elles-mêmes (S3). +- **L'espace d'embedding utilisé par l'Agentic RAG comme *warm start* n'avait jamais été évalué formellement**, seulement via le script exploratoire ponctuel ci-dessus (`explorations.py`). → **Diagnostic formalisé le 8/07** : `evaluate_embeddings.py` (script autonome, hors `src/evaluation/`) calcule l'accuracy@1 / recall@5 d'un k-NN cosinus libellé → notice sur `data/eval/eval_set_sample15.parquet` / `eval_set_sample30.parquet`, avec projections UMAP/PaCMAP/t-SNE/PCA et comparaison multi-modèles. **Premier résultat préoccupant** : avec le modèle actuellement configuré (`qwen3-embedding-8b`), accuracy@1 = recall@5 = 0 % sur l'échantillon de 30 libellés (ex. « FOOTBALL FEMININ » classé 373ᵉ/747 par similarité cosinus à sa propre notice). Cause probable non tranchée (modèle non adapté au domaine, ou format d'instruction spécifique attendu par ce modèle différent du préfixe générique `"query : "` câblé dans `Graph.get_closest_codes`) — à investiguer avant de considérer l'Agentic RAG comme fiable. +- **Le modèle supervisé de production n'était pas mobilisable comme référence de comparaison** : aucun wrapper ne l'exposait avec le même contrat que les classifieurs agentiques. → **Ajouté le 6/07** : `SupervisedClassifier` (`src/agents/Text2Code/classifiers/supervised_classifier.py`) charge le modèle (`torchTextClassifiers`) via MLflow pyfunc et l'expose en `--supervised` dans la CLI et dans `run_eval.py` ; **non validé fonctionnellement** (format de sortie du modèle réel non vérifiable dans cet environnement). +- **La détection de dérive (§3.3-C) n'existait qu'à l'état d'idée.** → **Prototype ajouté le 6/07** : `src/evaluation/drift.py` (Wasserstein, PSI, Kolmogorov-Smirnov, seuils calibrés empiriquement par rééchantillonnage), testé sur données synthétiques (dérive simulée) — reste à valider sur un flux réel de prédictions (S4). +- **La génération synthétique agentique** (*Code2Text*, par opposition à l'approche *Naive*) est au stade de squelette de code, non encore évaluée ni comparée à l'approche naïve. +- **Aucune intégration continue (CI) ne teste le code applicatif** : les deux workflows GitHub existants ne font que déployer les slides de présentation et les embeddings, sans exécuter de tests. → **Corrigé le 6/07** : workflow CI ajouté (lint `ruff`, vérification de syntaxe, tests unitaires). +- **Pas encore de documentation technique détaillée du framework**, au-delà du README et de la présentation de démonstration — c'est l'objet du chantier lancé cette semaine (cf. §4). +- **Le traçage Langfuse était incomplet** (audit du 8/07, cf. `docs/framework.md` §6.1) : `classify_navigator` (chemin agentique principal) n'était pas tracé (`@observe` commenté), le repli de finalisation ajouté à `_run_navigator_loop` masquait les échecs plutôt que de les faire remonter en erreur dans les traces, `--experiment-name` n'était pas réellement attaché aux traces Langfuse malgré ce qu'indiquait `framework.md` §4, et les variables `LANGFUSE_*` n'étaient pas documentées (documentation corrigée le 8/07). → **Correctifs de code faits le 9/07** : `@observe` ré-activé sur `classify_navigator`, échecs de finalisation remontés en `ERROR` sur le span courant, `--experiment-name` propagé à la trace (nom/tag/métadonnée) dans les quatre points d'entrée. Reste à faire : `langfuse.flush()`/`shutdown()` explicite avant sortie CLI (cf. `docs/framework.md` §6.1). + +### 2.3 Synthèse + +L'architecture cible (graphe + agents génériques + closers de validation) est posée et sa faisabilité technique est démontrée sur le cas *Navigator*. Le mois de juillet doit servir à **consolider la base de code** (corriger le bug bloquant, brancher les composants manquants), puis, surtout, à **construire une évaluation chiffrée et reproductible**, condition nécessaire pour statuer sur la valeur ajoutée réelle de chacun des trois cas d'usage identifiés. + +--- + +## 3. Roadmap détaillée — juillet 2026 + +### 3.1 Méthode d'évaluation envisagée + +Méthode retenue pour l'évaluation du classifieur (*Navigator*, et *Agentic RAG* une fois réparé), comme identifié dès la présentation initiale du projet : + +- **Si le jeu de test est jugé fiable** : évaluation automatique classique — exactitude (*accuracy*) au code terminal, et par niveau de hiérarchie (section, division, groupe, classe) pour distinguer une erreur « proche » (bon niveau supérieur, mauvaise feuille) d'une erreur « lointaine ». +- **Si le jeu de test n'est pas jugé fiable** (label bruité, notices ambiguës) : évaluation manuelle sur échantillon, en mettant bout à bout la prédiction *ground truth*, la prédiction du classifieur, et les jugements du *CodeChooser* et du *MatchVerifier* — évaluation conjointe de toute la chaîne plutôt que du seul classifieur. +- Métriques complémentaires : taux de requêtes n'atteignant **pas** un code terminal (`is_final = 0`), nombre moyen d'étapes de navigation, taux d'accord entre *Navigator* et *MatchVerifier*, comparaison au modèle supervisé de référence sur le même échantillon. + +### 3.2 Jeu d'évaluation + +- **Source de référence** : réutiliser le jeu de test déjà utilisé pour le modèle supervisé de production (split `df_test` du projet APE, colonnes `libelle` / `apet2025`), déjà mobilisé de façon exploratoire dans `explorations.py`. Cela permet une comparaison directe et un jeu de labels dont la qualité est déjà connue. +- **Construction d'un échantillon d'évaluation stratifié** : tirage représentatif par code (pas uniquement aléatoire), pour garantir une couverture des cas rares et des codes à fort volume. → **Fait le 6/07** : `data/eval/eval_set.parquet`, 5 181 lignes, ~10 exemples par code complet. +- **Isolation d'un sous-échantillon à ré-annoter manuellement** : pour les cas où le label historique est jugé incertain, base du protocole d'évaluation manuelle décrit en §3.1. +- Le jeu d'évaluation et son protocole de constitution sont versionnés dans le dépôt (et documentés dans `docs/framework.md`, cf. §4) pour être réutilisables et auditable. + +### 3.3 Pistes à explorer + +**A. Fiabilisation et industrialisation du framework** +- **Nettoyage du dépôt** : suppression du code mort, structuration des scripts exploratoires (`explorations.py`), mise en cohérence des modules d'agents, mise en place d'une CI minimale (lint, vérification d'import) pour éviter que des fichiers cassés comme ceux identifiés en §2.2 ne passent inaperçus. +- Réparation et **branchement effectif du chaînage *Navigator* → *MatchVerifier*** (nécessaire au cas d'usage monitoring). +- **Réparation du traçage Langfuse** (audit du 8/07, cf. `docs/framework.md` §6.1) : ré-activer `@observe` sur `classify_navigator`, faire remonter en erreur les échecs de finalisation capturés par `_fallback_output`, propager réellement `--experiment-name` à la trace (nom/tag/métadonnée plutôt que simple argument de log). → **Fait le 9/07** ; reste `langfuse.flush()`/`shutdown()` explicite avant sortie CLI. +- **Automatisation de la pipeline d'évaluation via Argo Workflows** (déjà utilisé pour l'entraînement du modèle supervisé de production) : exécuter le jeu d'évaluation de façon reproductible et programmée, avec des métriques versionnées à chaque évolution du framework, plutôt que des lancements manuels ponctuels. + +**B. Diagnostic et évaluation** +- **Analyse des embeddings** : formaliser les explorations déjà amorcées (projections UMAP/PaCMAP/t-SNE/PCA, k-plus-proches-voisins dans `explorations.py`) en un diagnostic reproductible de la qualité de l'espace d'embedding utilisé par l'*Agentic RAG* — séparabilité des codes, zones de confusion, sensibilité au modèle d'embedding choisi. → **Formalisé le 8/07** (cf. §2.2) : `evaluate_embeddings.py`. Reste à faire : comprendre et corriger la cause du résultat quasi nul obtenu avec le modèle actuellement configuré. +- Comparaison **plusieurs modèles LLM** sous-jacents (le framework est agnostique au modèle via une API compatible OpenAI — variable d'environnement `GENERATION_MODEL`), pour arbitrer coût/latence/qualité. +- Étude du **nombre d'étapes de navigation et du taux d'échec** (requêtes n'atteignant pas de code terminal) en fonction de la formulation du prompt et des instructions du *Navigator*. +- **Rigueur statistique de la comparaison** : ne pas se limiter à un delta d'exactitude entre méthodes (*Navigator*, *Agentic RAG*, modèle supervisé) — calculer un intervalle de confiance (bootstrap sur le jeu d'évaluation) et un test de significativité avant de conclure qu'une méthode surpasse une autre, en particulier sur les strates à faible effectif (§3.2). +- **Analyse d'erreurs par segment** : ventiler l'exactitude et le taux d'échec par section/niveau de nomenclature plutôt que de s'arrêter à un score agrégé, pour identifier *où* et *pourquoi* chaque méthode échoue (ambiguïté de notice, code trop spécifique, hiérarchie profonde, etc.). +- **Note de conception (architecture) *Navigator* vs *Agentic RAG* vs modèle supervisé** : livrable écrit documentant les compromis mesurés (exactitude par niveau, latence, coût par requête, nombre d'appels LLM) et la recommandation d'usage qui en découle par cas d'usage — formalise la décision de conception plutôt que de la laisser implicite. + +**C. Monitoring en production** +- Premiers tests du cas d'usage **monitoring** : simulation d'un flux de prédictions du modèle de production, détection par *MatchVerifier* des cas à recoder. +- **Détection de dérive de distribution**, non supervisée, en amont ou en complément de *MatchVerifier* — répond directement à l'enjeu « comment contrôler le modèle en production sans annotation humaine continue ? » (§1.2). Plan de travail : + 1. implémenter et comparer plusieurs métriques de dérive sur les distributions de prédictions/embeddings dans le temps : **distance de Wasserstein**, **Population Stability Index (PSI)** et **test de Kolmogorov-Smirnov** — trois métriques usuelles en surveillance de modèles de risque (proches de ce qui est attendu dans un cadre de gouvernance de modèle type SR 11-7) ; → **Fait le 6/07** : `src/evaluation/drift.py`. + 2. calibrer des seuils d'alerte sur des fenêtres temporelles glissantes, à partir de l'historique de prédictions du modèle de production ; → **Fait le 6/07** pour la calibration (empirique, par rééchantillonnage de la référence) ; l'application à un historique réel de prédictions reste à faire. + 3. valider sur un cas de dérive simulée (ex. injection artificielle d'un décalage de distribution) avant tout test sur données réelles. → **Fait le 6/07** (tests unitaires avec dérive synthétique injectée) ; validation sur données réelles toujours à faire (S4). + +**D. Génération de données et industrialisation du modèle agentique** +- Premiers tests du cas d'usage **génération synthétique pour ré-entraînement**, avec une métrique proxy (gain de performance du modèle supervisé sur les classes enrichies). +- **LoRA / QLoRA** : explorer le fine-tuning léger d'un modèle plus petit sur les trajectoires de raisonnement du *Navigator* (ou sur les données synthétiques de *Code2Text*), pour réduire le coût et la latence d'inférence — piste qui répondrait directement à la limite actuelle « un modèle agentique est trop lourd pour la production » (§1.3). Piste plus exploratoire, à horizon au-delà de juillet mais à garder en ligne de mire. + +### 3.4 Objectifs hebdomadaires — juillet 2026 + +| Semaine | Dates | Objectifs | +|---|---|---| +| **S1** | 6 → 11 juillet | Corriger le bug bloquant (`agentic_rag.py`) et **nettoyer le dépôt** (code mort, artefacts de build committés par erreur, structuration des scripts exploratoires, CI minimale de lint/import) pour fiabiliser la base de code. Poser les bases de la **documentation détaillée du framework** (architecture, modèle de données, contrats des agents — voir §4), première brique valorisable en document de travail DMCSI. **Réparer le traçage Langfuse** (audit du 8/07, cf. §3.3-A) : tracer `classify_navigator`, faire remonter les échecs de finalisation, propager `--experiment-name` aux traces, documenter les variables `LANGFUSE_*` — ✅ fait le 9/07 (documentation le 8/07, correctifs de code le 9/07) ; reste `langfuse.flush()`/`shutdown()` explicite. | +| **S2** | 14 → 18 juillet | Formaliser la méthode d'évaluation (§3.1) et constituer le **jeu d'évaluation stratifié** (§3.2), versionné dans le dépôt — ✅ fait le 6/07 (`data/eval/eval_set.parquet`) — en s'appuyant sur un premier **diagnostic de l'espace d'embedding** — ✅ formalisé le 8/07 (`evaluate_embeddings.py`, cf. §2.2), résultat préoccupant à creuser. Brancher le classifieur *Agentic RAG* dans le pipeline principal — ✅ fait le 6/07. | +| **S3** | 21 → 25 juillet | Implémenter le chaînage *Navigator* → *MatchVerifier* — ✅ fait le 6/07. Lancer les **premières campagnes d'évaluation chiffrées** du *Navigator*, de l'*Agentic RAG* **et du modèle supervisé de référence** (`--supervised`, ✅ branché le 6/07, non validé fonctionnellement) sur le jeu d'évaluation, assorties d'intervalles de confiance et d'une analyse d'erreurs par segment (§3.3-B). Amorcer un **prototype d'automatisation de la pipeline d'évaluation via Argo Workflows**. Rédiger la **note de conception *Navigator* vs *Agentic RAG* vs supervisé** (compromis exactitude/latence/coût). | +| **S4** | 28 → 31 juillet | Évaluer la génération synthétique (*Code2Text* / *NaiveCode2Text*) via la métrique proxy de ré-entraînement. **Prototype de détection de dérive** (Wasserstein, PSI, Kolmogorov-Smirnov) — ✅ fait le 6/07 (`src/evaluation/drift.py`), validé sur dérive simulée ; validation sur un flux réel de prédictions à faire. Synthèse des résultats du mois et note de recommandation sur les cas d'usage à prioriser pour la suite (monitoring, recodage, génération de données). | + +> Pistes identifiées mais volontairement hors périmètre resserré de juillet, à ré-examiner ensuite : industrialisation complète (au-delà du prototype) de la pipeline Argo Workflows ; exploration **LoRA/QLoRA** pour un modèle agentique plus léger (cf. §3.3-D). + +--- + +## 4. Documentation du framework + +Un premier document technique a été créé : [`docs/framework.md`](./framework.md). diff --git a/docs/framework.md b/docs/framework.md new file mode 100644 index 0000000..6cac0cc --- /dev/null +++ b/docs/framework.md @@ -0,0 +1,213 @@ +# Documentation technique du framework GRAAL + +> **Statut : document en cours de construction.** Cette première version pose les bases d'une documentation détaillée du framework (semaine du 6 juillet 2026, cf. [`docs/cadrage_2026-07.md`](./cadrage_2026-07.md)). Elle sera enrichie au fil des semaines de juillet — les sections marquées **[à compléter]** seront traitées lors des prochaines itérations — avant d'être valorisée sous la forme d'un document de travail DMCSI. + +## 1. Vue d'ensemble + +GRAAL combine trois briques : + +1. **Un graphe de connaissance** (Neo4j) représentant une nomenclature hiérarchique : codes, libellés, notices explicatives, relations parent/enfant. +2. **Des agents LLM à outils** (*tool-calling*, librairie [`openai-agents`](https://github.com/openai/openai-agents-python)) qui interrogent ce graphe pour raisonner sur une tâche de classification ou de génération. +3. **Des contrats d'entrée/sortie stricts** (schémas Pydantic) qui structurent les échanges entre agents et garantissent la composabilité du pipeline. + +Principe de conception central : **le framework ne connaît rien de la nomenclature métier**. Toute la connaissance (codes, hiérarchie, notices) vit dans la base Neo4j ; le code Python ne fait que peupler cette base et fournir des outils génériques de navigation/interrogation. Changer de nomenclature (NAF → COICOP, par exemple) ne nécessite donc pas de modifier le code des agents, seulement de reconstruire le graphe avec un nouveau jeu de notices. + +## 2. Modèle de données Neo4j + +Le graphe est construit par `src/neo4j_graph/graph_builder/build_graph_db.py` à partir d'un fichier de notices au format parquet (une ligne par code de nomenclature). + +### 2.1 Colonnes sources (`COLUMNS_TO_KEEP`, `graph_builder/config/config.py`) + +| Colonne | Rôle | +|---|---| +| `ID` | Identifiant unique du nœud | +| `CODE` | Code de nomenclature (ex. `62.01`, `J`) | +| `NAME` | Libellé du code | +| `PARENT_ID` / `PARENT_CODE` | Référence au nœud parent dans la hiérarchie | +| `LEVEL` | Niveau hiérarchique (0 = racine) | +| `FINAL` | Indique si le code est un code terminal (feuille) | +| `Implementation_rule` | Règle d'affectation officielle du code | +| `Includes` / `IncludesAlso` | Contenu inclus dans le code (notice officielle) | +| `Excludes` | Contenu explicitement exclu du code | +| `text_content` | Texte brut de la notice | + +Le texte utilisé pour le calcul des embeddings (`text_to_embed`) est la concaténation de `NAME`, `Implementation_rule`, `Includes` et `IncludesAlso`. + +### 2.2 Nomenclatures déjà paramétrées + +Le pipeline de construction a déjà été testé/configuré pour plusieurs jeux de notices (`graph_builder/config/config.py`) : + +- NAF 2025 (français) — nomenclature de référence actuelle du projet ; +- NACE Rev2.1 (anglais) ; +- COICOP 2018 (français et anglais). + +**[à compléter]** : schéma exact des relations Neo4j (type `HAS_PARENT`, propriétés indexées), procédure de reconstruction complète du graphe pas à pas, gestion des mises à jour de nomenclature (recodage d'un graphe existant vers une nouvelle version). + +## 3. Architecture des agents + +### 3.1 `BaseAgent` (`src/agents/base_agent.py`) + +Classe abstraite dont héritent tous les agents. Elle encapsule : + +- la connexion à un client LLM compatible OpenAI (`OPENAI_BASE_URL` / `OPENAI_API_KEY`), avec tracing désactivé au niveau du SDK `agents` (le traçage applicatif passe par Langfuse, voir §5) ; +- le modèle utilisé pour la génération (`GENERATION_MODEL`), avec une température fixée à 0 par défaut (`get_model_settings`) ; +- un contrat commun : chaque sous-classe doit définir un nom d'agent (`get_agent_name`), des instructions système (`get_instructions`), un type de sortie structuré (`get_output_type`, un modèle Pydantic) et une méthode de construction du prompt (`build_prompt`) ; +- l'exécution (`__call__`) via `Runner.run` du SDK `agents`, avec un nombre maximal de tours (`MAX_TURNS`). Cette description vaut pour `CodeChooser`/`MatchVerifier`/`SupervisedClassifier` ; `BaseClassifier` (§3.4) remplace cette boucle par sa propre boucle pas-à-pas. + +Chaque agent = **un prompt + un jeu d'outils + un contrat de sortie typé**. C'est cette homogénéité qui permet de composer des agents entre eux sans coder de logique de parsing ad hoc. + +### 3.2 Outils partagés (`Graph.get_tools`, `src/neo4j_graph/graph.py`) + +Un socle d'outils Neo4j est exposé à tous les agents via `@function_tool` (SDK `agents`) : + +- `get_code_information(code)` — fiche complète d'un code (nom, niveau, description, inclusions/exclusions, parent, enfants) ; +- `get_children(code)` / `get_siblings(code)` / `get_descendants(code, levels)` — navigation locale dans la hiérarchie ; +- mise en cache (`functools.lru_cache`) des requêtes Neo4j les plus fréquentes, avec (dé)sérialisation dict ↔ tuple pour rendre les résultats hashables (`_freeze_dict` / `_unfreeze_dict`). + +### 3.3 Le *Navigator* (`src/navigator/navigator.py`) + +Le *Navigator* hérite de `Graph` et ajoute un **état de position courante** dans la hiérarchie (`current_code`) ainsi qu'un historique de navigation (`history`). Il expose un jeu d'outils dédié, avec état : + +- `get_current_information` / `get_code_information(code)` — information sur la position courante ou sur un code arbitraire (sans déplacement) ; +- `get_current_children` / `get_current_siblings` / `get_current_parent` — exploration locale relative à la position courante ; +- `navigate_to(code)` / `go_to_child(child_code)` / `go_to_parent()` — déplacement dans la hiérarchie, avec validation (un `go_to_child` vers un code qui n'est pas un enfant direct échoue explicitement) ; `go_to_child`/`go_to_parent` renvoient directement les enfants de la nouvelle position, pour fusionner « se déplacer » et « voir les options » en un seul appel ; +- `reset_to_root()` — réinitialisation avant une nouvelle requête ; +- `is_current_final()` — vérité terrain lue directement sur le graphe (jamais déduite d'une auto-évaluation du LLM), utilisée par `BaseClassifier` (§3.4) pour savoir quand arrêter l'exploration. + +Chaque appel d'outil est journalisé (position avant/après, données renvoyées au LLM), ce qui fournit une trace complète et rejouable du raisonnement de l'agent. + +### 3.4 Classifieurs (`src/agents/Text2Code/`) + +- `BaseClassifier` — spécialise `BaseAgent` en fixant le type de sortie à `MatchVerificationInput` (activité, code proposé, explication, confiance), le format commun attendu par les agents « closers ». Remplace la boucle unique de `BaseAgent` par une boucle pas-à-pas pilotée en Python (`_run_navigator_loop`) : un seul `Runner.run` piloté par le LLM ne peut pas à la fois utiliser les outils de façon fiable et savoir quand s'arrêter (le SDK ne réinitialise `tool_choice` que sur « un outil a été utilisé », sans notion du critère métier `is_final`). La boucle alterne donc entre deux variantes d'`Agent` (`Agent.clone()`) : un agent d'exploration (outils forcés via `tool_choice="required"`, sans `output_type`) et un agent de finalisation (`tool_choice="none"`, outils gardés déclarés pour éviter un blocage du rendu du chat-template côté serveur, `output_type=MatchVerificationInput`). L'arrêt est décidé par `Navigator.is_current_final()` et seulement juste après un déplacement réel (jamais après une simple consultation), pour éviter qu'une position de départ RAG erronée soit « vérifiée » puis renvoyée telle quelle. +- `NavigatorAgenticClassifier` — classifieur concret : instructions demandant au *Navigator* de descendre jusqu'à un code terminal (`is_final = 1`) en justifiant chaque choix, en démarrant systématiquement par `get_current_children()`. +- `AgenticRAGClassifier` (`agentic_rag.py`) — approche hybride : récupération du code le plus proche par similarité d'embedding (`Graph.get_closest_codes`, recherche vectorielle Neo4j filtrée sur les codes finaux), utilisé comme point de départ (*warm start*) pour le *Navigator* plutôt que la racine. L'agent vérifie ce point de départ avec les outils du *Navigator* (informations du noeud, enfants, frères, parent) et navigue pour le corriger si besoin, avant de rendre un `MatchVerificationInput`. Branché dans la CLI via `--agentic-rag`. +- `SupervisedClassifier` (`supervised_classifier.py`) — **pas un agent LLM** : appelle le modèle supervisé de production via l'API déployée `codif-ape-API` (authentification HTTP Basic, `CODIF_APE_API_USERNAME` / `CODIF_APE_API_PASSWORD` / `CODIF_APE_API_URL`), plutôt que chargé en local via MLflow, pour éviter d'ajouter torch/transformers/torchfasttext aux dépendances de ce dépôt. L'expose avec le même contrat de sortie (`MatchVerificationInput`) que les deux classifieurs agentiques, pour servir de référence dans la comparaison chiffrée (cf. cadrage §3.3-B, note de conception). Branché dans la CLI via `--supervised`. + +### 3.5 Agents « closers » (`src/agents/closers/`) + +Agents de validation, appelés en fin de chaîne : + +- **`CodeChooser`** — arbitre entre plusieurs codes candidats pour une activité donnée ; sortie : code choisi, niveau de confiance, explication. +- **`MatchVerifier`** — vérifie qu'une correspondance libellé ↔ code proposée est valide ; sortie : booléen de validité, confiance, explication. C'est cet agent qui porte le cas d'usage « monitoring du modèle en production » (cf. cadrage §1.3). + +Le chaînage classifieur → *MatchVerifier* est disponible via l'option `--verify` de la CLI : la sortie du classifieur (un `MatchVerificationInput` : activité, code proposé, explication, confiance) est passée telle quelle au *MatchVerifier*, qui rend un verdict indépendant (`is_match`, confiance, explication). Ce chaînage fonctionne en mode unitaire comme en mode batch et constitue la brique de base du cas d'usage « monitoring » (cf. cadrage §1.3). **[à compléter]** : retour d'expérience et calibrage des seuils de confiance après les premières campagnes d'évaluation. + +### 3.6 Génération de données synthétiques (`src/agents/Code2Text/`, `src/agents/NaiveCode2Text/`) + +Deux approches, à des stades de maturité différents : + +- **`NaiveCode2Text`** — approche « classique » (non agentique) : échantillonnage aléatoire d'éléments de notice (loi géométrique, `code_retrieval/code_sampler.py`) pour construire des prompts de générations variées, plusieurs itérations testées (génération unitaire puis par lots de 10, orientation « métier » vs. « notice détaillée »). Des exemples de résultats sont disponibles dans `sample_results/`. +- **`Code2Text`** (`agent/code2text_agent.py`) — version agentique, au stade de squelette de code, non encore évaluée. **[à compléter]** une fois les premiers tests réalisés (semaine 4 de la roadmap). + +## 4. Point d'entrée et CLI (`src/main.py`) + +La CLI (`src/utils/parser.py`) expose deux méthodes de classification, avec vérification optionnelle : + +```bash +uv run -m src.main --navigator "Boulangerie artisanale avec vente directe" +uv run -m src.main --agentic-rag "Boulangerie" --verify +uv run -m src.main --supervised "Boulangerie" +uv run -m src.main --navigator --batch-file requetes.txt --experiment-name mon-experience +``` + +- `--navigator QUERY` — classification agentique par navigation hiérarchique (*Navigator*) ; +- `--agentic-rag QUERY` — classification par recherche vectorielle comme point de départ (*warm start*) du *Navigator* (cf. §3.4) ; +- `--supervised QUERY` — classification par le modèle supervisé de production via MLflow (cf. §3.4) ; +- `--verify` — chaîne la prédiction dans le *MatchVerifier* pour double vérification (cf. §3.5) ; +- `--batch-file FILE` — traite un fichier de requêtes (une par ligne) avec la méthode choisie ; +- `--experiment-name` — nom d'expérience propagé au traçage Langfuse. + +## 5. Module d'évaluation (`src/evaluation/`) + +Socle du chantier prioritaire du mois (cf. cadrage §3.1–3.2), en trois briques : + +- **`metrics.py`** — métriques pures Python (sans dépendance, testées unitairement dans `tests/`) : normalisation des codes (`"10.71C"` ≡ `"1071C"`), exactitude à la feuille, exactitude par niveau hiérarchique (préfixes : 2 = division, 3 = groupe, 4 = classe pour la NAF), taux d'échec (prédictions n'ayant pas atteint de code final, comptées comme erreurs), taux de faible confiance (`low_confidence_rate`, distinct du taux d'échec : un code peut être renvoyé avec une confiance nulle par le repli de finalisation `_fallback_output`). `accuracy_at_depth`/`evaluate` acceptent un paramètre `weights` optionnel pour une lecture pondérée en plus de la lecture non pondérée historique (jamais en remplacement, cf. ci-dessous). +- **`build_eval_set.py`** — construction du jeu d'évaluation stratifié : lecture du parquet labellisé (local ou S3/Datalab), stratification par préfixe de code (division par défaut), tirage plafonné par strate et reproductible (seed) — les strates plus petites que le plafond sont conservées en entier. Le sur-échantillonnage des strates rares casse la fréquence réelle des codes ; deux colonnes sont donc ajoutées au jeu produit : `eval_stratum` (clé de strate, réutilisée par le bootstrap stratifié) et `ipw_weight` (poids de repondération, population de la strate / lignes tirées) qui permet de reconstruire une exactitude représentative du trafic réel via `evaluate(..., weights=...)`. +- **`bootstrap.py`** — intervalle de confiance bootstrap (`bootstrap_ci`) pour une métrique, par rééchantillonnage en grappes **à l'intérieur de chaque strate** (jamais entre strates, pour respecter le plan d'échantillonnage de `build_eval_set.py`). +- **`compare.py`** — comparaison statistique appariée de deux campagnes exécutées sur le même jeu d'évaluation (mêmes lignes, même vérité terrain) : bootstrap apparié en grappes sur la différence d'exactitude, et test de McNemar en complément — répond au chantier « rigueur statistique » de la note de conception (cadrage §3.3-B) et évite l'erreur classique de comparer deux IC indépendants sur des données appariées. +- **`run_eval.py`** — harnais de campagne : exécute une méthode (`navigator`, `agentic-rag` ou `supervised`) sur le jeu d'évaluation, écrit les prédictions détaillées (parquet) et le rapport de métriques (JSON), avec exactitude pondérée automatique si `ipw_weight` est présent dans le jeu d'évaluation. Nécessite Neo4j et l'API LLM à l'exécution. + +```bash +uv run -m src.evaluation.build_eval_set --input --output data/eval/eval_set.parquet +uv run -m src.evaluation.run_eval --eval-set data/eval/eval_set.parquet --method navigator --bootstrap 1000 +uv run -m src.evaluation.compare --a data/eval/results/predictions_navigator.parquet --b data/eval/results/predictions_agentic-rag.parquet +``` + +Le jeu d'évaluation est désormais construit (`data/eval/eval_set.parquet`, 5 181 lignes, stratifié par code complet — `apet2025`, ~10 exemples/code) ; `run_eval.py` propose trois méthodes : `navigator`, `agentic-rag`, `supervised`. **[à compléter]** : le jeu d'évaluation versionné a été construit avant l'ajout d'`ipw_weight`/`eval_stratum` — à reconstruire depuis la source (`df_test`) pour bénéficier de poids non triviaux (voir `stratified_sample`) et d'un bootstrap qui n'ait pas à se rabattre sur une strate unique. + +### 5.1 Détection de dérive (`src/evaluation/drift.py`) + +Implémente le plan de travail du cas d'usage monitoring (cf. cadrage §3.3-C), sans dépendance à Neo4j ni à un LLM — testable sur données synthétiques : + +- `wasserstein_drift`, `ks_drift` — pour un signal continu (ex. scores de confiance renvoyés par `CodeChooser`/`MatchVerifier`). +- `psi` (continu, bins par quantiles de la référence) et `psi_categorical` (fréquences de catégories, ex. distribution des codes prédits). +- `calibrate_threshold` — calibre un seuil d'alerte **empiriquement** à partir de la seule référence (rééchantillonnage sous l'hypothèse « pas de dérive »), plutôt qu'un seuil arbitraire. +- `drift_report` / `rolling_drift_reports` — combine les trois métriques (alerte si au moins 2 sur 3 concordent) sur une fenêtre, ou une suite de fenêtres temporelles glissantes. + +```python +from src.evaluation.drift import drift_report + +report = drift_report(reference_confidences, current_window_confidences) +report["any_drift"] # bool +``` + +**[à compléter]** : validation sur un flux réel de prédictions du modèle de production (données synthétiques uniquement à ce stade). + +### 5.2 Diagnostic de l'espace d'embedding (`evaluate_embeddings.py`) + +Script autonome à la racine du dépôt (volontairement hors `src/evaluation/`, cf. cadrage §3.3-B), qui évalue la qualité de la recherche par similarité (notices NAF2025 ↔ libellés) utilisée comme *warm start* par l'Agentic RAG (`Graph.get_closest_codes`, §3.4), indépendamment de la navigation LLM qui la suit : + +- **Quantitatif** : k-NN cosinus entre l'embedding d'un libellé (préfixé `"query : "`, comme au moment de l'inférence dans `graph.py`) et les embeddings des notices NAF2025 (codes terminaux uniquement) — accuracy@1 et recall@5 contre la vérité terrain (`apet2025`), sur `data/eval/eval_set_sample15.parquet` / `eval_set_sample30.parquet`. +- **Visuel** : projection 2D (UMAP/PaCMAP/t-SNE/PCA) des notices et des libellés, arêtes k-NN correctes (vert) / incorrectes (bleu) et vérité terrain, une figure Plotly comparative par modèle écrite dans `data/eval/embedding_diagnostics/_comparison.html`. +- **Comparaison multi-modèles** : éditer la liste `CANDIDATE_MODELS` en tête de script pour comparer plusieurs modèles d'embedding déployés derrière `URL_EMBEDDING_API`. + +```bash +uv run python evaluate_embeddings.py +``` + +**Résultat du premier diagnostic (8/07)** avec le modèle actuellement configuré (`qwen3-embedding-8b`) : accuracy@1 = recall@5 = 0 % sur l'échantillon de 30 libellés — voir la limite documentée en §8. + +## 6. Configuration (variables d'environnement) + +| Variable | Usage | +|---|---| +| `NEO4J_URL`, `NEO4J_USERNAME`, `NEO4J_PWD` | Connexion à la base de graphe | +| `OPENAI_BASE_URL`, `OPENAI_API_KEY` | Client LLM compatible OpenAI (génération) | +| `GENERATION_MODEL` | Modèle utilisé par les agents (`BaseAgent`) | +| `MAX_TURNS` | Nombre maximal de tours d'agent (boucle outil → réponse) | +| `EMBEDDING_MODEL`, `URL_EMBEDDING_API`, `MAX_TOKENS` | Modèle et service d'embedding utilisés lors de la construction du graphe | +| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_ENDPOINT_URL` | Accès S3 (Datalab/Onyxia) pour les données sources et notices | +| `MLFLOW_TRACKING_URI`, `MLFLOW_MODEL_URI` | Chargement du modèle supervisé de production par `SupervisedClassifier` | +| `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL` | Traçage applicatif Langfuse (voir ci-dessous) | + +Le traçage applicatif (sessions, coûts, latence, arbre d'appels des agents) est assuré par **Langfuse** (`get_client`, `propagate_attributes`, `@observe` dans `src/main.py`). + +### 6.1 Traçage Langfuse + +Audit du 8/07 : le traçage fonctionnait pour `classify_agentic_rag`, `classify_supervised` et `process_batch_file` (`@observe` actif, appels LLM individuels journalisés via `langfuse.openai.AsyncOpenAI` dans `base_agent.py`), mais restait incomplet sur plusieurs points. **Corrigé le 9/07** : + +- **`classify_navigator` est maintenant tracé** (`@observe` ré-activé dans `src/main.py`) alors que c'est le chemin agentique principal (cf. cadrage §2.1) — chaque appel LLM était déjà journalisé individuellement mais sans trace/span parent qui les relie en un arbre de raisonnement cohérent. +- **Les échecs de finalisation de `_run_navigator_loop` remontent maintenant comme des erreurs dans Langfuse** : en plus du repli `_fallback_output` (`base_classifier.py`), le span courant est explicitement marqué `level="ERROR"` (`get_client().update_current_span`) avant de retomber sur la dernière position connue, au lieu d'apparaître comme un succès normal (confiance 0.0). +- **`--experiment-name` est maintenant réellement propagé au traçage Langfuse** : la valeur est attachée à la trace courante (nom, tag et métadonnée via `get_client().update_current_trace`) dans les quatre points d'entrée (`classify_navigator`, `classify_agentic_rag`, `classify_supervised`, `process_batch_file`), donc filtrable/groupable par expérience dans l'UI Langfuse. + +Reste à faire : + +- Pas de `langfuse.flush()`/`shutdown()` explicite avant la sortie du script CLI (repose sur le hook `atexit` du SDK, suffisant en usage normal mais fragile en cas d'arrêt forcé d'un job batch). + +**Piège fréquent sur `MLFLOW_MODEL_URI`** : ce n'est pas le lien de la page MLflow ouverte dans le navigateur, mais une URI au schéma `models:` — ex. `models:/FastText-pytorch/9` (pas `https://.../#/models/FastText-pytorch/versions/9`). Et `MLFLOW_TRACKING_URI` doit pointer vers le serveur MLflow où ce modèle est **effectivement enregistré** (le plus souvent l'instance MLflow partagée du projet, ex. `projet-ape-mlflow.user.lab.sspcloud.fr`) — pas nécessairement l'instance MLflow personnelle par défaut sur le Datalab, qui n'a pas accès au registre d'un autre projet. `SupervisedClassifier` lève une erreur explicite si `MLFLOW_MODEL_URI` est un lien `http(s)://` plutôt qu'une URI `models:`. + +Un test de connectivité par service externe (Neo4j, LLM de génération, embedding, S3, Langfuse, MLflow) est disponible dans `tests/test_connections.py` — chaque test se saute automatiquement si les variables requises sont absentes, pour rester vert en CI sans secrets Datalab tout en détectant un endpoint/identifiant mal configuré quand ils sont présents. + +## 7. Comment étendre GRAAL à une nouvelle nomenclature **[à compléter]** + +Cette section documentera, une fois formalisée, le mode opératoire complet pour instancier GRAAL sur une nouvelle nomenclature : format attendu du fichier de notices, exécution du pipeline de construction du graphe, adaptation minimale des prompts si nécessaire. + +## 8. Limites connues et dette technique + +Recensées ici pour mémoire (suivi détaillé dans le document de cadrage) : + +- Les composants branchés le 6/07 (classifieur *Agentic RAG* dans la CLI, chaînage `--verify`, harnais `run_eval`) n'ont pas encore été évaluées. +- **L'espace d'embedding utilisé par l'Agentic RAG comme *warm start* est de très mauvaise qualité avec le modèle actuellement configuré** (`EMBEDDING_MODEL=qwen3-embedding-8b`) : premier diagnostic formalisé le 8/07 (`evaluate_embeddings.py`, cf. §5.2) — accuracy@1 = recall@5 = 0 % sur l'échantillon annoté de 30 libellés (ex. « FOOTBALL FEMININ » classé 373ᵉ/747 par similarité cosinus à sa propre notice). Cause probable non tranchée : modèle d'embedding non adapté au domaine, ou format d'instruction attendu par ce modèle (Qwen3-Embedding est *instruction-tuned*) différent du préfixe générique `"query : "` actuellement câblé dans `Graph.get_closest_codes`. À investiguer avant de considérer l'Agentic RAG comme fiable. +- `SupervisedClassifier` (modèle de production via MLflow) est également non validé fonctionnellement : le parsing de la sortie `.predict()` est écrit pour plusieurs formats plausibles mais n'a pas pu être testé contre le modèle réel dans cet environnement (pas d'accès au tracking MLflow). +- La CI couvre lint, syntaxe et tests unitaires purs (dont le module `drift.py`, testé sur données synthétiques), mais **pas de tests d'intégration** (agents + graphe + MLflow) : ils nécessiteraient les services correspondants dans le workflow. `tests/test_connections.py` comble partiellement ce manque en local/Datalab (smoke tests skippés si les identifiants sont absents). +--- diff --git a/evaluate_embeddings.py b/evaluate_embeddings.py new file mode 100644 index 0000000..2f35700 --- /dev/null +++ b/evaluate_embeddings.py @@ -0,0 +1,340 @@ +""" +Diagnostic de l'espace d'embedding : notices (NAF2025) vs libellés (exemples). + +Le classifieur Agentic RAG (src/agents/Text2Code/classifiers/agentic_rag.py) se sert +d'une recherche par similarité entre le libellé d'activité à classer et les embeddings +des notices de nomenclature stockées dans Neo4j (src/neo4j_graph/graph.py:get_closest_codes) +pour choisir un code de départ. Ce script évalue la qualité de cette recherche +indépendamment de la navigation LLM qui la suit, sur un petit échantillon annoté : + + - quantitatif : accuracy@1 et recall@k du k-NN libellé -> notice (cosinus) + - visuel : projection 2D (UMAP / PaCMAP / t-SNE / PCA) des notices et des libellés, + avec les arêtes k-NN correctes/incorrectes et la vérité terrain + +et permet de comparer plusieurs modèles d'embedding candidats (déployés derrière le +même endpoint URL_EMBEDDING_API) en éditant CANDIDATE_MODELS ci-dessous. + +Usage: + python evaluate_embeddings.py +""" + +# %% Imports +import json +import os + +import numpy as np +import pacmap +import pandas as pd +import plotly.graph_objects as go +import umap +from langchain_core.documents import Document +from langchain_openai import OpenAIEmbeddings +from plotly.subplots import make_subplots +from sklearn.decomposition import PCA +from sklearn.manifold import TSNE +from sklearn.metrics.pairwise import cosine_distances +from sklearn.neighbors import NearestNeighbors + +from src.neo4j_graph.graph_builder.config import COLUMNS_TO_KEEP, MAX_TOKENS, URL_EMBEDDING_API +from src.neo4j_graph.graph_builder.utils.embed_manager import truncate_docs_to_max_tokens +from src.neo4j_graph.graph_builder.utils.notice_manager import load_notices + +# %% Config +# Add more model names deployed behind URL_EMBEDDING_API to compare them. +CANDIDATE_MODELS = [os.environ["EMBEDDING_MODEL"]] + +EVAL_SAMPLE_PATH = "data/eval/eval_set_sample30.parquet" +# Ground truth (apet2025) is NAF2025-coded, so notices must come from the NAF2025 +# nomenclature regardless of whatever NOTICES_PATH graph_builder/config is set to. +NAF2025_NOTICES_PATH = "projet-ape/notices/Notices-NAF2025-FR.parquet" + +K_NN = 5 +REDUCTION_METHODS = ["umap", "pacmap", "tsne", "pca"] +OUTPUT_DIR = "data/eval/embedding_diagnostics" + + +def normalize_code(code: str) -> str: + return str(code).replace(".", "").replace(" ", "").upper() + + +def build_embedding_model(model_name: str) -> OpenAIEmbeddings: + # check_embedding_ctx_length=False is required for self-hosted, non-OpenAI models: + # otherwise langchain pre-tokenizes the text with tiktoken (falling back to + # cl100k_base for unrecognized model names) and sends the resulting integer + # token IDs as `input`, which the server then decodes with its own tokenizer's + # vocabulary — silently corrupting every embedding. Disabling it sends plain text. + return OpenAIEmbeddings( + model=model_name, + openai_api_base=URL_EMBEDDING_API, + openai_api_key=os.environ["OPENAI_API_KEY"], + check_embedding_ctx_length=False, + ) + + +# %% Data loading +def load_eval_sample(path: str) -> pd.DataFrame: + df = pd.read_parquet(path)[["libelle", "apet2025"]].dropna() + df["apet2025"] = df["apet2025"].map(normalize_code) + return df.reset_index(drop=True) + + +def load_leaf_notices(path: str) -> pd.DataFrame: + df = load_notices(path, COLUMNS_TO_KEEP) + df = df[df["FINAL"] == 1].copy() + df["CODE"] = df["CODE"].map(normalize_code) + df["text_to_embed"] = ( + df["NAME"].fillna("") + + "\n" + + df["Implementation_rule"].fillna("") + + "\n" + + df["Includes"].fillna("") + + "\n" + + df["IncludesAlso"].fillna("") + ) + return df.reset_index(drop=True) + + +def truncate_texts(texts: list[str], max_tokens: int) -> list[str]: + docs = [Document(page_content=t) for t in texts] + docs = truncate_docs_to_max_tokens(docs, max_tokens) + return [d.page_content for d in docs] + + +# %% k-NN retrieval +def compute_knn(label_embeddings: np.ndarray, notice_embeddings: np.ndarray, k: int): + """For each libellé embedding, find its k nearest notice embeddings (cosine).""" + nbrs = NearestNeighbors(n_neighbors=k, metric="cosine").fit(notice_embeddings) + distances, indices = nbrs.kneighbors(label_embeddings) + return indices, distances + + +def score_retrieval( + indices: np.ndarray, distances: np.ndarray, notice_codes: list[str], target_codes: list[str] +): + n = len(target_codes) + hits_at_1 = 0 + hits_at_k = 0 + correct_sims, incorrect_sims = [], [] + edges = [] # (label_idx, notice_idx, distance, is_correct) — notice_idx indexes notice_codes + + for label_idx, (idx_row, dist_row, target) in enumerate(zip(indices, distances, target_codes)): + retrieved_codes = [notice_codes[j] for j in idx_row] + hits_at_1 += int(retrieved_codes[0] == target) + hits_at_k += int(target in retrieved_codes) + + for notice_idx, dist in zip(idx_row, dist_row): + is_correct = notice_codes[notice_idx] == target + edges.append((label_idx, int(notice_idx), float(dist), is_correct)) + (correct_sims if is_correct else incorrect_sims).append(1 - dist) + + metrics = { + "n": n, + "accuracy_at_1": hits_at_1 / n, + f"recall_at_{indices.shape[1]}": hits_at_k / n, + "mean_cosine_sim_correct": float(np.mean(correct_sims)) if correct_sims else None, + "mean_cosine_sim_incorrect": float(np.mean(incorrect_sims)) if incorrect_sims else None, + } + return metrics, edges + + +# %% Dimensionality reduction +def reduce_dimensions(embeddings: np.ndarray, method: str, random_state: int = 42): + if method == "umap": + reducer = umap.UMAP( + random_state=random_state, n_neighbors=10, min_dist=0.1, metric="cosine" + ) + return reducer.fit_transform(embeddings), "UMAP (cosine)" + if method == "pacmap": + reducer = pacmap.PaCMAP( + n_components=2, + n_neighbors=10, + MN_ratio=0.5, + FP_ratio=2.0, + distance="angular", + random_state=random_state, + ) + return reducer.fit_transform(embeddings), "PaCMAP (angular)" + if method == "tsne": + distances = cosine_distances(embeddings) + reducer = TSNE( + n_components=2, + random_state=random_state, + perplexity=min(30, len(embeddings) - 1), + metric="precomputed", + init="random", + ) + return reducer.fit_transform(distances), "t-SNE (cosine)" + if method == "pca": + reducer = PCA(n_components=2, random_state=random_state) + coords = reducer.fit_transform(embeddings) + return coords, f"PCA (variance: {reducer.explained_variance_ratio_.sum():.1%})" + raise ValueError(f"Unknown reduction method: {method}") + + +# %% Plotting +def build_comparison_figure( + combined_embeddings: np.ndarray, + n_notices: int, + notice_hover: list[str], + label_hover: list[str], + edges: list[tuple], + methods: list[str], +) -> go.Figure: + positions = [(1, 1), (1, 2), (2, 1), (2, 2)] + fig = make_subplots( + rows=2, cols=2, subplot_titles=methods, horizontal_spacing=0.06, vertical_spacing=0.1 + ) + + for method, (row, col) in zip(methods, positions): + coords, method_name = reduce_dimensions(combined_embeddings, method) + X, Y = coords.T + is_first = row == 1 and col == 1 + + for label_idx, notice_idx, dist, is_correct in edges: + label_pt = n_notices + label_idx + color = ( + "rgba(50, 205, 50, 0.9)" + if is_correct + else f"rgba(100, 150, 255, {max(0.2, 1 - dist / 2):.2f})" + ) + fig.add_trace( + go.Scatter( + x=[X[label_pt], X[notice_idx]], + y=[Y[label_pt], Y[notice_idx]], + mode="lines", + line=dict( + color=color, + width=3 if is_correct else 1.5, + dash="solid" if is_correct else "dot", + ), + showlegend=False, + hoverinfo="skip", + ), + row=row, + col=col, + ) + + fig.add_trace( + go.Scatter( + x=X[:n_notices], + y=Y[:n_notices], + mode="markers", + name="Notices NAF", + legendgroup="notices", + showlegend=is_first, + marker=dict(size=6, color="steelblue", line=dict(width=0.3, color="white")), + text=notice_hover, + hovertemplate="%{text}", + ), + row=row, + col=col, + ) + fig.add_trace( + go.Scatter( + x=X[n_notices:], + y=Y[n_notices:], + mode="markers", + name="Libellés", + legendgroup="labels", + showlegend=is_first, + marker=dict( + size=11, color="red", symbol="star", line=dict(width=0.8, color="darkred") + ), + text=label_hover, + hovertemplate="%{text}", + ), + row=row, + col=col, + ) + fig.update_xaxes(showticklabels=False, title_text=method_name, row=row, col=col) + fig.update_yaxes(showticklabels=False, row=row, col=col) + + fig.add_trace( + go.Scatter( + x=[None], + y=[None], + mode="lines", + line=dict(color="rgba(50, 205, 50, 0.9)", width=3), + name="k-NN correct", + ), + row=1, + col=1, + ) + fig.add_trace( + go.Scatter( + x=[None], + y=[None], + mode="lines", + line=dict(color="rgba(100, 150, 255, 0.6)", width=1.5, dash="dot"), + name="k-NN incorrect", + ), + row=1, + col=1, + ) + fig.update_layout(height=1000, width=1600, hovermode="closest", plot_bgcolor="white") + return fig + + +# %% Per-model evaluation +def evaluate_model( + model_name: str, eval_df: pd.DataFrame, notices_df: pd.DataFrame, output_dir: str +) -> dict: + print(f"\n=== {model_name} ===") + emb_model = build_embedding_model(model_name) + + notice_texts = truncate_texts(notices_df["text_to_embed"].tolist(), MAX_TOKENS) + notice_codes = notices_df["CODE"].tolist() + notice_embeddings = np.array(emb_model.embed_documents(notice_texts)) + # Mirrors the "query : {activity}" prefix used at retrieval time in + # src/neo4j_graph/graph.py:get_closest_codes — required for a fair comparison with + # instruction-tuned/asymmetric embedding models (query vs. passage formatting). + query_texts = [f"query : {libelle}" for libelle in eval_df["libelle"]] + label_embeddings = np.array(emb_model.embed_documents(query_texts)) + + indices, distances = compute_knn( + label_embeddings, notice_embeddings, k=min(K_NN, len(notice_codes)) + ) + metrics, edges = score_retrieval(indices, distances, notice_codes, eval_df["apet2025"].tolist()) + print(json.dumps(metrics, indent=2)) + + combined_embeddings = np.vstack([notice_embeddings, label_embeddings]) + notice_hover = [ + f"{code}
{name}" for code, name in zip(notice_codes, notices_df["NAME"]) + ] + label_hover = [ + f"{libelle[:80]}
Cible: {target}" + for libelle, target in zip(eval_df["libelle"], eval_df["apet2025"]) + ] + fig = build_comparison_figure( + combined_embeddings, len(notice_codes), notice_hover, label_hover, edges, REDUCTION_METHODS + ) + fig.update_layout( + title_text=f"{model_name} — accuracy@1={metrics['accuracy_at_1']:.1%}, " + f"recall@{K_NN}={metrics[f'recall_at_{K_NN}']:.1%}" + ) + + os.makedirs(output_dir, exist_ok=True) + safe_name = model_name.replace("/", "_") + fig.write_html(os.path.join(output_dir, f"{safe_name}_comparison.html")) + + return metrics + + +# %% Main +def main(): + eval_df = load_eval_sample(EVAL_SAMPLE_PATH) + notices_df = load_leaf_notices(NAF2025_NOTICES_PATH) + print(f"Loaded {len(eval_df)} labeled examples and {len(notices_df)} leaf notices") + + summary = { + model: evaluate_model(model, eval_df, notices_df, OUTPUT_DIR) for model in CANDIDATE_MODELS + } + + os.makedirs(OUTPUT_DIR, exist_ok=True) + with open(os.path.join(OUTPUT_DIR, "summary.json"), "w") as f: + json.dump(summary, f, indent=2) + print(f"\nSummary written to {OUTPUT_DIR}/summary.json") + + +if __name__ == "__main__": + main() diff --git a/evaluate_train_labels.py b/evaluate_train_labels.py new file mode 100644 index 0000000..22204e1 --- /dev/null +++ b/evaluate_train_labels.py @@ -0,0 +1,233 @@ +""" +Audit interactif des labels du jeu d'entraînement, de bout en bout. + +On ne fait pas confiance aux labels du jeu d'entraînement (libelle -> nace2025). +Ce script enchaîne, sur un même échantillon, les briques déjà utilisées +séparément ailleurs dans le repo : + + 1. MatchVerifier juge si le label existant lui semble correct + (src/agents/closers/match_verifier.py) + 2. Les trois méthodes de classification (Navigator, Agentic-RAG, modèle + supervisé de production) proposent chacune un code (src/main.py) + 3. CodeChooser arbitre entre le label d'origine et les 3 prédictions + (src/agents/closers/code_chooser.py) + 4. MatchVerifier rejuge le choix final de CodeChooser + +C'est la démarche d'"évaluation manuelle" décrite dans +presentation/presentation.qmd (ground truth + prédiction + jugement des +closers, mis bout à bout), appliquée ici au jeu d'entraînement plutôt qu'à un +jeu de test annoté. + +Pensé pour être exécuté cellule par cellule (VS Code Jupyter interactive) +plutôt qu'en batch : les appels `await` nécessitent un kernel Jupyter +(top-level await), comme déjà fait dans explorations.py pour classify_navigator. + +Pour un rendu HTML via Quarto, il faut d'abord convertir ce script en notebook +avec un kernel pointant sur le venv du projet (Quarto ne détecte pas tout +seul un .py au format "percent", et le kernel `python3` par défaut n'a pas les +dépendances du projet) : + + uv run python -m ipykernel install --user --name graalbis \ + --display-name "Python (GRAALbis .venv)" # une seule fois + uv run jupytext --to notebook --set-kernel graalbis \ + evaluate_train_labels.py -o evaluate_train_labels.ipynb + quarto render evaluate_train_labels.ipynb --to html --execute +""" + +# %% Imports +import os + +import pandas as pd + +from src.agents.closers.code_chooser import CodeChoice, CodeChooser +from src.agents.closers.match_verifier import MatchVerificationInput, MatchVerifier +from src.config import neo4j_config +from src.evaluation.build_eval_set import load_dataframe +from src.evaluation.metrics import normalize_code +from src.evaluation.verify_train_labels import TRAIN_SET_PATH, verify_rows +from src.main import classify_agentic_rag, classify_navigator, classify_supervised +from src.neo4j_graph.graph import Graph + +# %% Config +# Cost per row: 1 verify + 3 reclassify + (at most) 1 arbitrate + 1 re-verify calls, +# and Navigator/Agentic-RAG are themselves multi-turn agents, so this adds up fast. +# Start small, bump this up once a run looks right. +N_SAMPLES = 10 +SEED = 42 +TEXT_COLUMN = "libelle" +CODE_COLUMN = "nace2025" +OUTPUT_PATH = "data/eval/train_verification/train_label_reclassification.parquet" + +# %% Sample the train set +df = load_dataframe(TRAIN_SET_PATH) +sample = df.sample(n=N_SAMPLES, seed=SEED) +labels = sample[TEXT_COLUMN].to_list() +train_codes = sample[CODE_COLUMN].to_list() +print(f"Sampled {len(labels)} train rows") + +graph = Graph(neo4j_config) +verifier = MatchVerifier(graph) + +# %% Step 1 - verify the original train labels +original_verifications = await verify_rows(verifier, sample.to_dicts(), TEXT_COLUMN, CODE_COLUMN) + +# %% Step 2 - reclassify with the other methods +navigator_preds = await classify_navigator(labels) +agentic_rag_preds = await classify_agentic_rag(labels) +supervised_preds = await classify_supervised(labels) + + +# %% Step 3 - arbitrate with CodeChooser among the 4 candidates +async def choose_best(activity: str, candidates: list[str | None]) -> CodeChoice | None: + """Dedupe candidate codes by normalized value, let CodeChooser pick the best. + + Returns None if the candidates all collapse to a single unique code: there is + nothing to arbitrate. + """ + unique = {} + for code in candidates: + if not code: + continue + norm = normalize_code(code) + if norm and norm not in unique: + unique[norm] = code + codes = list(unique.values()) + if len(codes) < 2: + return None + chooser = CodeChooser(graph, num_choices=len(codes)) + return await chooser(activity=activity, codes=codes) + + +chosen = [] +for i, activity in enumerate(labels): + candidates = [ + train_codes[i], + getattr(navigator_preds[i], "code", None), + getattr(agentic_rag_preds[i], "code", None), + getattr(supervised_preds[i], "code", None), + ] + chosen.append(await choose_best(activity, candidates)) + +# %% Step 4 - close the loop: re-verify CodeChooser's final pick +final_verifications = [] +for i, choice in enumerate(chosen): + if choice is None: + # Only one unique candidate: nothing was arbitrated, reuse the original verdict. + final_verifications.append(original_verifications[i]) + continue + verification = await verifier( + MatchVerificationInput( + activity=labels[i], + code=choice.chosen_code, + proposed_explanation=choice.explanation, + proposed_confidence=choice.confidence, + ) + ) + final_verifications.append( + { + "llm_is_match": verification.is_match, + "llm_confidence": verification.confidence, + "llm_explanation": verification.explanation, + } + ) + + +# %% Assemble the comparison table +def agrees(code: str | None, ref_norm: str | None) -> bool: + return bool(code) and normalize_code(code) == ref_norm + + +rows = [] +for i, activity in enumerate(labels): + ref_norm = normalize_code(train_codes[i]) + nav_code = getattr(navigator_preds[i], "code", None) + rag_code = getattr(agentic_rag_preds[i], "code", None) + sup_code = getattr(supervised_preds[i], "code", None) + choice = chosen[i] + chooser_code = choice.chosen_code if choice else train_codes[i] + + rows.append( + { + "libelle": activity, + "nace2025": train_codes[i], + "verifier_is_match": original_verifications[i]["llm_is_match"], + "verifier_confidence": original_verifications[i]["llm_confidence"], + "verifier_explanation": original_verifications[i]["llm_explanation"], + "navigator_code": nav_code, + "navigator_agrees": agrees(nav_code, ref_norm), + "agentic_rag_code": rag_code, + "agentic_rag_agrees": agrees(rag_code, ref_norm), + "supervised_code": sup_code, + "supervised_agrees": agrees(sup_code, ref_norm), + "chooser_code": chooser_code, + "chooser_confidence": choice.confidence if choice else None, + "chooser_explanation": ( + choice.explanation if choice else "Candidats identiques, pas d'arbitrage nécessaire." + ), + "chooser_agrees_with_label": agrees(chooser_code, ref_norm), + "final_verifier_is_match": final_verifications[i]["llm_is_match"], + "final_verifier_confidence": final_verifications[i]["llm_confidence"], + "final_verifier_explanation": final_verifications[i]["llm_explanation"], + } + ) + +comparison = pd.DataFrame(rows) + +os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) +comparison.to_parquet(OUTPUT_PATH) +print(f"Comparison table written to {OUTPUT_PATH}") + + +# %% Display - highlighted comparison table +def highlight_disagreements(row: pd.Series) -> list[str]: + styles = [""] * len(row) + agreement_columns = { + "navigator_code": "navigator_agrees", + "agentic_rag_code": "agentic_rag_agrees", + "supervised_code": "supervised_agrees", + "chooser_code": "chooser_agrees_with_label", + } + for code_col, agree_col in agreement_columns.items(): + if not row[agree_col]: + styles[row.index.get_loc(code_col)] = "background-color: #ffcccc" + if not row["verifier_is_match"]: + styles[row.index.get_loc("nace2025")] = "background-color: #ffe0b3" + if row["final_verifier_is_match"] and not row["verifier_is_match"]: + styles[row.index.get_loc("chooser_code")] = "background-color: #ccffcc" + return styles + + +comparison.style.apply(highlight_disagreements, axis=1) + +# %% Display - summary numbers +n = len(comparison) +summary = { + "n": n, + "pct_labels_flagged_by_verifier": float(1 - comparison["verifier_is_match"].mean()), + "navigator_agreement_rate": float(comparison["navigator_agrees"].mean()), + "agentic_rag_agreement_rate": float(comparison["agentic_rag_agrees"].mean()), + "supervised_agreement_rate": float(comparison["supervised_agrees"].mean()), + "pct_rescued_by_chooser": ( + float( + ( + ~comparison["verifier_is_match"] + & ~comparison["chooser_agrees_with_label"] + & comparison["final_verifier_is_match"] + ).sum() + / n + ) + if n + else None + ), +} +print(summary) + +# %% Display - standout relabeling candidates +relabeling_candidates = comparison[ + ~comparison["verifier_is_match"] + & ~comparison["chooser_agrees_with_label"] + & comparison["final_verifier_is_match"] +] +print(f"\n{len(relabeling_candidates)} candidate(s) for relabeling:") +for _, r in relabeling_candidates.iterrows(): + print(f" {r['libelle']!r}: {r['nace2025']} -> {r['chooser_code']} ({r['chooser_explanation']})") diff --git a/evaluation/evaluation.py b/evaluation/evaluation.py new file mode 100644 index 0000000..3b993b9 --- /dev/null +++ b/evaluation/evaluation.py @@ -0,0 +1,11 @@ +# %% +import polars as pl + +# %% +eval_dataset = pl.read_parquet("../data/eval/eval_set.parquet") +eval_dataset.head() + +# %% +eval = pl.read_parquet("dataset_eval.parquet") +eval.head() +# %% diff --git a/newplot.png b/newplot.png deleted file mode 100644 index 0f91e00..0000000 Binary files a/newplot.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index b1bd031..c1337bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "duckdb>=1.4.4", "fastparquet>=2025.12.0", + "flask>=3.1.3", "ipykernel>=7.1.0", "ipywidgets>=8.1.8", "jupyter-cache>=1.0.1", @@ -17,6 +18,7 @@ dependencies = [ "langchain-text-splitters>=1.1.0", "langfuse>=3.11.2", "matplotlib>=3.10.8", + "mlflow>=2.19.0", "nbclient>=0.10.4", "nbformat>=5.10.4", "networkx>=3.6.1", @@ -27,13 +29,16 @@ dependencies = [ "plotly>=6.5.1", "polars>=1.38.1", "pyarrow>=23.0.1", + "pytest>=9.1.1", "s3fs>=2024.12.0", + "scipy>=1.14.0", "transformers>=4.57.3", "umap-learn>=0.5.11", ] [dependency-groups] dev = [ + "jupytext>=1.19.4", "pre-commit>=4.5.1", "ruff>=0.14.9", ] @@ -41,3 +46,11 @@ dev = [ [tool.ruff] line-length = 100 extend-include = ["*.ipynb"] +# Cell-based scripts meant to be run interactively (VS Code / Jupytext) or +# rendered with `quarto render`, relying on top-level `await`/IPython magics +# that are not valid standalone Python syntax. +extend-exclude = ["explorations.py", "evaluate_train_labels.py"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/setup.sh b/setup.sh old mode 100644 new mode 100755 index 0215714..24c0533 --- a/setup.sh +++ b/setup.sh @@ -1,2 +1,3 @@ uv sync uv run pre-commit install +uv run pytest tests/test_connections.py diff --git a/src/agents/Text2Code/classifiers/agentic_rag.py b/src/agents/Text2Code/classifiers/agentic_rag.py index a833364..df907c6 100644 --- a/src/agents/Text2Code/classifiers/agentic_rag.py +++ b/src/agents/Text2Code/classifiers/agentic_rag.py @@ -1,54 +1,68 @@ import logging -from src.agents.closers.code_chooser import CodeChooser from src.agents.closers.match_verifier import MatchVerificationInput from src.agents.Text2Code.classifiers.base_classifier import BaseClassifier -from src.neo4j_graph.graph import Graph +from src.navigator.navigator import Navigator logger = logging.getLogger(__name__) class AgenticRAGClassifier(BaseClassifier): """ - 1. Retrive the top_k closest codes from the graph - 2. A CodeChooser agent to select the most appropriate code for the given activity. + 1. Retrieve the single closest code from the graph via embedding similarity search. + 2. Use it as a warm-start position for the Navigator agent, which verifies it against + the hierarchy (siblings, parent, children, is_final) and refines it if needed. - Output is a CodeChoice, which is converted in a MatchVerificationInput as per requirements for classifiers. - - Confidence of AgenticRAGClassifier is the confidence given by the CodeChooser. + Output is a MatchVerificationInput, produced directly by the agent's structured output + (same contract as NavigatorAgenticClassifier). + Confidence of AgenticRAGClassifier is the confidence given by the agent itself. """ - def __init__(self, graph: Graph, top_k): + def __init__(self, graph: Navigator): super().__init__(graph) - self.top_k = top_k - self.code_chooser = CodeChooser(graph, num_choices=top_k) - - async def __call__(self, activity: str) -> str: - closest_codes = await self.get_closest_codes(activity) - logger.info(f"Closest codes for activity '{activity}': {closest_codes}") - code_choice_result = await self.code_chooser(activity=activity, codes=closest_codes) - - # We need to convert the CodeChoice into MatchVerificationInput - # because all classifiers must return that type - code_choice_result = code_choice_result.final_output - result = MatchVerificationInput( - activity=activity, - code=code_choice_result.chosen_code, - proposed_explanation=code_choice_result.explanation, - ) - proposed_confidence=code_choice_result.confidence, # confidence from CodeChoice - - return result + + async def __call__(self, activity: str) -> MatchVerificationInput: + start_code = await self.get_starting_code(activity) + logger.info(f"RAG starting point for activity '{activity}': {start_code}") + + self.graph.current_code = start_code + self.graph.history = [start_code] + + return await self._run_navigator_loop(activity, self.build_prompt(activity, start_code)) def get_agent_name(self) -> str: return "Agentic RAG Classifier" def get_instructions(self) -> str: - return None + return """ + Vous êtes un expert en classification NACE. + + Une recherche par similarité d'embeddings vous propose un point de départ dans + l'arborescence, mais cette suggestion peut être imprécise ou erronée : ne la validez + jamais aveuglément. Vérifiez-la avec les outils disponibles (informations du noeud + courant, enfants, frères, parent) avant de conclure. + + Si le point de départ semble incorrect, naviguez vers une meilleure position (remontez + au parent, explorez les frères ou les enfants) plutôt que de le valider tel quel. + Commencez par get_current_information() pour examiner le point de départ proposé. + """ + + async def get_starting_code(self, activity: str) -> str: + closest_codes = await self.graph.get_closest_codes(activity, top_k=1) + return closest_codes[0] + + def build_prompt(self, activity: str, start_code: str) -> str: + return f""" + Vous êtes un classificateur NACE. + + Activité à classifier : {activity} - async def get_closest_codes(self, activity): - return await self.graph.get_closest_codes(activity, top_k=self.top_k) + Un système de recherche par similarité d'embeddings suggère de partir du code + {start_code} comme point de départ probable. Ce point de départ n'est qu'une + suggestion : il peut être imprécis ou erroné. - def build_prompt(self): - return None + Votre mission : vérifier rigoureusement ce point de départ et naviguer si nécessaire + vers le code le plus spécifique et approprié pour cette activité, jusqu'à atteindre + une position finale (is_final = 1). + """ diff --git a/src/agents/Text2Code/classifiers/base_classifier.py b/src/agents/Text2Code/classifiers/base_classifier.py index 65980c4..31454d3 100644 --- a/src/agents/Text2Code/classifiers/base_classifier.py +++ b/src/agents/Text2Code/classifiers/base_classifier.py @@ -1,7 +1,146 @@ +import logging +import os + +from langfuse import get_client + +from agents import Runner +from agents.model_settings import ModelSettings from src.agents.base_agent import BaseAgent from src.agents.closers.match_verifier import MatchVerificationInput +logger = logging.getLogger(__name__) + +_MOVEMENT_TOOLS = {"go_to_child", "go_to_parent"} + + +def _last_tool_call(result): + """(name, arguments) of the last tool call in a Runner.run result, or None.""" + for item in reversed(result.new_items): + if item.type == "tool_call_item": + name = getattr(item.raw_item, "name", None) + arguments = getattr(item.raw_item, "arguments", None) + if name is not None: + return (name, arguments) + return None + class BaseClassifier(BaseAgent): + """Classifiers that drive a Navigator through a Python-owned step loop instead of + one free-running agentic Runner.run call. + + A single model-controlled loop can't reliably both use tools and know when to + stop: the SDK only resets tool_choice on "any tool used", with no concept of the + domain stop condition (is_final=1). So exploration and finalization are split into + two Agent variants (see __init__) and Python decides when to switch between them, + using Navigator.is_current_final() as the ground truth instead of the model's + self-reported completion. + """ + def get_output_type(self): return MatchVerificationInput + + def get_model_settings(self) -> ModelSettings: + return ModelSettings(temperature=0, tool_choice="required") + + def get_finalize_instructions(self) -> str: + return """ + Vous avez terminé l'exploration de la hiérarchie NACE ci-dessus. Sur la base des + informations recueillies, renvoyez votre réponse finale : le code retenu (qui doit + être une position terminale, is_final = 1), une explication concise de votre choix, + et votre niveau de confiance entre 0 et 1. + """ + + def __init__(self, graph): + super().__init__(graph) + # Exploration: tools forced, no output_type — nothing to finalize yet, so no + # response_format competes with tool selection. Stops as soon as one tool call + # executes (no need for the model to react to the tool result itself — Python + # drives the next step). + self.exploration_agent = self.agent.clone( + output_type=None, + tool_use_behavior="stop_on_first_tool", + ) + # Finalize: tool_choice="none" rather than dropping `tools` — the conversation + # history carries prior tool_calls/tool messages, and clearing `tools` while + # those references remain hangs the backend's chat-template rendering instead + # of erroring. Keeping tools declared (but disallowed) avoids that. + self.finalize_agent = self.agent.clone( + instructions=self.get_finalize_instructions(), + model_settings=ModelSettings(temperature=0, tool_choice="none"), + ) + + def _fallback_output(self, activity: str) -> MatchVerificationInput: + return MatchVerificationInput( + activity=activity, + code=self.graph.current_code, + proposed_explanation=( + "Échec de la génération de la réponse finale par le modèle ; dernière " + f"position atteinte lors de l'exploration : {self.graph.current_code}." + ), + proposed_confidence=0.0, + ) + + async def _run_navigator_loop( + self, activity: str, initial_prompt: str + ) -> MatchVerificationInput: + conversation = initial_prompt + last_call = None + max_turns = int(os.environ["MAX_TURNS"]) + + for step in range(max_turns): + result = await Runner.run(self.exploration_agent, conversation, max_turns=1) + conversation = result.to_input_list() + call = _last_tool_call(result) + + if call is not None and call == last_call: + logger.info(f"Navigator loop: repeated call {call} at step {step}, retrying") + conversation = conversation + [ + { + "role": "user", + "content": ( + "Vous venez de refaire exactement le même appel qu'à l'étape " + "précédente, sans faire progresser la navigation. Choisissez une " + "action différente : si un enfant correspond à l'activité, appelez " + "go_to_child avec son code exact." + ), + } + ] + retry_agent = self.exploration_agent.clone( + model_settings=ModelSettings(temperature=0.4, tool_choice="required") + ) + result = await Runner.run(retry_agent, conversation, max_turns=1) + conversation = result.to_input_list() + call = _last_tool_call(result) + + last_call = call + + # Only stop right after an actual move: a read-only lookup (e.g. checking + # the RAG-suggested starting leaf) must never end the loop on its own, even + # if that position happens to already be is_final=1 — otherwise the model + # can "verify" a wrong RAG seed, say so, and still return it unchanged. + moved = call is not None and call[0] in _MOVEMENT_TOOLS + if moved and self.graph.is_current_final(): + break + else: + logger.warning("Navigator loop: step budget exhausted before reaching is_final") + + try: + result = await Runner.run(self.finalize_agent, conversation, max_turns=2) + final_output = result.final_output + except Exception as e: + logger.exception( + "Navigator loop: finalize call failed, falling back to current position" + ) + # Don't re-raise: the fallback below lets the caller's batch loop continue + # to the next query. But without this, the exception being swallowed here + # means the enclosing @observe span (classify_navigator/classify_agentic_rag) + # completes as a normal success in Langfuse — flag it as an error explicitly + # so it's visible/filterable there instead of only in application logs. + get_client().update_current_span( + level="ERROR", + status_message=f"Finalize call failed, fell back to last position: {e}", + ) + final_output = self._fallback_output(activity) + + logger.info(f"Result of the navigator loop: \n {final_output}") + return final_output diff --git a/src/agents/Text2Code/classifiers/navigator_classifier.py b/src/agents/Text2Code/classifiers/navigator_classifier.py index 2c7a76b..521baf8 100644 --- a/src/agents/Text2Code/classifiers/navigator_classifier.py +++ b/src/agents/Text2Code/classifiers/navigator_classifier.py @@ -5,6 +5,9 @@ class NavigatorAgenticClassifier(BaseClassifier): def __init__(self, navigator): super().__init__(navigator) + async def __call__(self, query: str): + return await self._run_navigator_loop(query, self.build_prompt(query)) + def get_agent_name(self) -> str: return "Navigator Agentic Classifier" @@ -19,11 +22,8 @@ def build_prompt(self, query: str) -> str: def get_instructions(self) -> str: return """ - Vous êtes un expert en classification NACE. Votre mission est de naviguer + Vous êtes un expert en classification NACE. Votre mission est de naviguer dans l'arborescence afin d'atteindre le code le plus spécifique caractérisant l'activité indiquée. - Après avoir vérifié que votre position actuelle est bien finale (is_final = 1), - vous renverrez votre position. - Si vous n'avez pas réussi à atteindre une position finale, dites-le. - Soyez méthodique et justifiez chaque choix ! Commence par get_current_children() pour voir les options disponibles. + Soyez méthodique et justifiez chaque choix ! Commencez par get_current_children() pour voir les + options disponibles. """ - diff --git a/src/agents/Text2Code/classifiers/supervised_classifier.py b/src/agents/Text2Code/classifiers/supervised_classifier.py new file mode 100644 index 0000000..1a7c941 --- /dev/null +++ b/src/agents/Text2Code/classifiers/supervised_classifier.py @@ -0,0 +1,58 @@ +"""Wrapper autour du modèle supervisé de production, exposé via l'API déployée +codif-ape-API (https://github.com/InseeFrLab/codif-ape-API). + +Sert de baseline de référence, comparée aux méthodes agentiques dans +`src/evaluation/run_eval.py` (cf. cadrage §3.3-B, note de conception), avec le +même contrat de sortie (`MatchVerificationInput`) que `NavigatorAgenticClassifier` +et `AgenticRAGClassifier`. + +Nécessite les variables d'environnement CODIF_APE_API_USERNAME et +CODIF_APE_API_PASSWORD (authentification HTTP Basic de l'API). CODIF_APE_API_URL +est optionnelle (défaut : l'instance de production sur le SSP Cloud). +""" + +import logging +import os + +import httpx + +logger = logging.getLogger(__name__) + +DEFAULT_API_URL = "https://codification-ape2025-pytorch.lab.sspcloud.fr" + + +class SupervisedClassifier: + """Classifieur de référence : modèle supervisé de production via l'API codif-ape-API.""" + + def __init__(self): + base_url = os.environ.get("CODIF_APE_API_URL", DEFAULT_API_URL) + auth = ( + os.environ["CODIF_APE_API_USERNAME"], + os.environ["CODIF_APE_API_PASSWORD"], + ) + self.client = httpx.AsyncClient(base_url=base_url, auth=auth, timeout=60.0) + + async def __call__(self, activity: str): + from src.agents.closers.match_verifier import MatchVerificationInput + + code, confidence, libelle = await self._predict(activity) + + return MatchVerificationInput( + activity=activity, + code=code, + proposed_explanation=( + f"Prédiction du modèle supervisé de production (codif-ape-API) : {libelle}" + ), + proposed_confidence=confidence, + ) + + async def _predict(self, activity: str) -> tuple[str, float, str]: + payload = {"forms": [{"description_activity": activity}]} + # nb_echos_max=1 makes the API crash with a 500 (server-side bug); ask + # for 2 and keep only the top prediction. + response = await self.client.post("/predict/", params={"nb_echos_max": 2}, json=payload) + response.raise_for_status() + result = response.json()[0] + + top_prediction = result["1"] + return top_prediction["code"], result["IC"], top_prediction["libelle"] diff --git a/src/agents/base_agent.py b/src/agents/base_agent.py index d2f7785..67a959d 100644 --- a/src/agents/base_agent.py +++ b/src/agents/base_agent.py @@ -61,13 +61,10 @@ def get_output_type(self) -> BaseModel: @abstractmethod def build_prompt(self, *args, **kwargs) -> str: pass - + async def __call__(self, *args, **kwargs): prompt = self.build_prompt(*args, **kwargs) - result = await Runner.run( - self.agent, - prompt, - max_turns=int(os.environ["MAX_TURNS"])) + result = await Runner.run(self.agent, prompt, max_turns=int(os.environ["MAX_TURNS"])) logger.info(f"Result of the __call__ in BaseAgent: \n {result.final_output}") return result.final_output diff --git a/src/agents/closers/code_chooser.py b/src/agents/closers/code_chooser.py index 2915f18..a6598bb 100644 --- a/src/agents/closers/code_chooser.py +++ b/src/agents/closers/code_chooser.py @@ -29,7 +29,7 @@ async def __call__(self, activity: str, codes: list[str]) -> str: prompt = self.build_prompt(activity, codes) result = await Runner.run(self.agent, prompt) - return result + return result.final_output def get_agent_name(self) -> str: return "Code Chooser Agent" diff --git a/src/agents/closers/match_verifier.py b/src/agents/closers/match_verifier.py index ba5572d..b925b8c 100644 --- a/src/agents/closers/match_verifier.py +++ b/src/agents/closers/match_verifier.py @@ -18,9 +18,16 @@ def __str__(self): class MatchVerificationInput(BaseModel): activity: str = Field(description="The textual label of the activity to verify") code: str = Field(description="The code that has been associated with the activity") - proposed_explanation: str = Field(description="The explanation provided for the proposed code") - proposed_confidence: float = Field( - description="The confidence level of the proposed match, between 0 and 1", ge=0, le=1 + proposed_explanation: str | None = Field( + default=None, + description="The explanation provided for the proposed code, if any (absent for a " + "raw ground-truth label with no accompanying model rationale)", + ) + proposed_confidence: float | None = Field( + default=None, + description="The confidence level of the proposed match, between 0 and 1, if any", + ge=0, + le=1, ) @@ -33,7 +40,8 @@ def get_agent_name(self) -> str: def get_instructions(self) -> str: return """ - Tu es un agent spécialisé dans la vérification de la validité d'une correspondance entre un libellé textuel et le code qui lui a été associé. + Tu es un agent spécialisé dans la vérification de la validité d'une + correspondance entre un libellé textuel et le code qui lui a été associé. """ def get_output_type(self): @@ -43,13 +51,37 @@ def build_prompt(self, match_verification_input: MatchVerificationInput) -> str: """ Construire le prompt pour l'agent de vérification de correspondance. """ + if match_verification_input.proposed_explanation: + explanation_line = ( + f"Explication proposée : {match_verification_input.proposed_explanation}" + ) + else: + explanation_line = ( + "Aucune explication n'est fournie : il s'agit probablement d'un code de " + "référence (vérité terrain), pas de la proposition d'un modèle. Juge la " + "correspondance activité/code sur le fond, sans présumer qu'elle est correcte." + ) + + code_info = self.graph.get_code_information(match_verification_input.code) + if code_info.get("description"): + code_definition_line = ( + f"Définition officielle du code ({code_info.get('name')}) : " + f"{code_info['description']}" + ) + else: + code_definition_line = ( + "Aucune définition trouvée dans la base pour ce code : appuie-toi sur " + "ta connaissance de la nomenclature." + ) + prompt = f""" Vérifie si le code suivant correspond bien à l'activité décrite. Activité : {match_verification_input.activity} Code proposé : {match_verification_input.code} - Explication proposée : {match_verification_input.proposed_explanation} + {code_definition_line} + {explanation_line} Réponds en fournissant : 1. Un booléen indiquant si la correspondance est valide. diff --git a/src/evaluation/__init__.py b/src/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/evaluation/bootstrap.py b/src/evaluation/bootstrap.py new file mode 100644 index 0000000..b7d67ef --- /dev/null +++ b/src/evaluation/bootstrap.py @@ -0,0 +1,76 @@ +"""Intervalles de confiance bootstrap, respectant la stratification du jeu d'évaluation. + +cf. cadrage_2026-07.md §3.3-B : ne pas se limiter à un delta d'exactitude entre +méthodes sans intervalle de confiance, en particulier sur les strates à faible +effectif. Le jeu d'évaluation (`build_eval_set.stratified_sample`) est construit +par strate (tirage plafonné par code) : un bootstrap qui rééchantillonne les +lignes empilées sans distinction de strate ne représente pas ce plan +d'échantillonnage. `bootstrap_ci` rééchantillonne donc à l'intérieur de chaque +strate (bootstrap en grappes), jamais entre strates. +""" + +import numpy as np + + +def bootstrap_ci( + y_true: list, + y_pred: list, + strata: list, + metric_fn, + n_resamples: int = 1000, + alpha: float = 0.05, + seed: int = 42, + weights: list | None = None, +) -> tuple[float, tuple[float, float]]: + """Intervalle de confiance bootstrap pour une métrique, par rééchantillonnage en grappes. + + Args: + y_true: Codes de référence. + y_pred: Codes prédits, alignés avec `y_true`. + strata: Clé de strate par ligne (ex. colonne `eval_stratum` de l'eval + set) ; chaque rééchantillonnage tire, pour chaque strate, autant + de lignes que la strate en contient, avec remise, uniquement + parmi les lignes de cette même strate. + metric_fn: Fonction `(y_true, y_pred, weights) -> float` (ex. + `lambda t, p, w: accuracy_at_depth(t, p, weights=w)`). + n_resamples: Nombre de tirages bootstrap. + alpha: Taux de risque (défaut 5 %, IC à 95 %). + seed: Graine, pour un IC reproductible. + weights: Poids par ligne, propagés au rééchantillonnage (ex. + `ipw_weight`) ; None = poids uniformes. + + Returns: + `(estimation ponctuelle, (borne basse, borne haute))`, l'IC étant + calculé par la méthode des percentiles sur la distribution des + rééchantillonnages. + """ + n = len(y_true) + if n != len(y_pred) or n != len(strata): + raise ValueError("y_true, y_pred et strata doivent avoir la même taille") + if weights is not None and len(weights) != n: + raise ValueError("weights doit avoir la même taille que y_true") + + y_true_arr = np.asarray(y_true, dtype=object) + y_pred_arr = np.asarray(y_pred, dtype=object) + weights_arr = np.asarray(weights, dtype=float) if weights is not None else None + + strata_indices: dict = {} + for i, s in enumerate(strata): + strata_indices.setdefault(s, []).append(i) + strata_index_arrays = [np.array(idx) for idx in strata_indices.values()] + + point_estimate = metric_fn(list(y_true), list(y_pred), weights) + + rng = np.random.default_rng(seed) + resample_values = np.empty(n_resamples) + for r in range(n_resamples): + resampled_idx = np.concatenate( + [rng.choice(idx, size=len(idx), replace=True) for idx in strata_index_arrays] + ) + resampled_y_true = y_true_arr[resampled_idx].tolist() + resampled_y_pred = y_pred_arr[resampled_idx].tolist() + resampled_weights = weights_arr[resampled_idx].tolist() if weights_arr is not None else None + resample_values[r] = metric_fn(resampled_y_true, resampled_y_pred, resampled_weights) + + lo, hi = np.quantile(resample_values, [alpha / 2, 1 - alpha / 2]) + return float(point_estimate), (float(lo), float(hi)) diff --git a/src/evaluation/build_eval_set.py b/src/evaluation/build_eval_set.py new file mode 100644 index 0000000..cefe1e8 --- /dev/null +++ b/src/evaluation/build_eval_set.py @@ -0,0 +1,142 @@ +"""Construction d'un jeu d'évaluation stratifié à partir d'un parquet labellisé. + +Stratification par préfixe de code (2 caractères = division NAF par défaut) +pour couvrir les classes rares comme les sections à fort volume, avec un +tirage reproductible (seed fixée). + +Usage : + uv run -m src.evaluation.build_eval_set \ + --input projet-ape/data/08112022_27102024/naf2025/split/df_test.parquet \ + --output data/eval/eval_set.parquet \ + --n-per-stratum 10 + +Le chemin d'entrée est lu en local s'il existe, sinon sur S3 (Datalab/Onyxia, +identifiants AWS_* dans l'environnement). +""" + +import argparse +import logging +import os + +import polars as pl + +from src.evaluation.config import PATH_EVAL_INPUT, PATH_EVAL_OUTPUT +from src.evaluation.metrics import normalize_code +from src.utils.logging import configure_logging + +configure_logging() +logger = logging.getLogger(__name__) + + +def load_dataframe(path: str) -> pl.DataFrame: + """Charge un parquet depuis le disque local ou S3 (Datalab).""" + if os.path.exists(path): + logger.info(f"Loading local parquet: {path}") + return pl.read_parquet(path) + + import s3fs + + logger.info(f"Loading parquet from S3: {path}") + fs = s3fs.S3FileSystem( + client_kwargs={"endpoint_url": os.environ["AWS_ENDPOINT_URL"]}, + key=os.environ["AWS_ACCESS_KEY_ID"], + secret=os.environ["AWS_SECRET_ACCESS_KEY"], + token=os.environ["AWS_SESSION_TOKEN"], + ) + with fs.open(path, "rb") as f: + return pl.read_parquet(f) + + +def stratified_sample( + df: pl.DataFrame, + code_column: str, + n_per_stratum: int, + stratum_depth: int = 5, + seed: int = 42, +) -> pl.DataFrame: + """Tire au plus `n_per_stratum` lignes par strate de code. + + Args: + df: Données labellisées. + code_column: Colonne contenant le code de nomenclature. + n_per_stratum: Nombre maximal de lignes par strate (les strates plus + petites sont prises en entier). + stratum_depth: Longueur du préfixe de code normalisé définissant la + strate (2 = division NAF). + seed: Graine du tirage, pour un jeu reproductible. + + Le sur-échantillonnage des strates rares casse la fréquence réelle des + codes : une exactitude moyennée sans pondération sur le jeu obtenu répond + à « quelle est la performance moyenne par code », pas « quelle serait la + performance sur le trafic réel ». `ipw_weight` (population de la strate / + lignes effectivement tirées) permet de reconstruire cette seconde lecture + a posteriori (cf. `metrics.evaluate(..., weights=...)`). `eval_stratum` + est conservée pour que le rééchantillonnage stratifié (bootstrap) n'ait + pas à redériver les strates depuis le code, avec un risque de dérive si + `stratum_depth` change. + """ + df = ( + df.with_columns( + pl.col(code_column) + .map_elements(normalize_code, return_dtype=pl.Utf8) + .alias("_norm_code") + ) + .drop_nulls("_norm_code") + .with_columns(pl.col("_norm_code").str.slice(0, stratum_depth).alias("_stratum")) + ) + + n_strata = df["_stratum"].n_unique() + logger.info(f"{n_strata} strata found at depth {stratum_depth}") + + df = df.with_columns(pl.len().over("_stratum").alias("_population_count")) + sampled = df.filter(pl.int_range(pl.len()).shuffle(seed=seed).over("_stratum") < n_per_stratum) + sampled = sampled.with_columns( + pl.len().over("_stratum").alias("_sampled_count"), + ).with_columns( + (pl.col("_population_count") / pl.col("_sampled_count")).alias("ipw_weight"), + ) + + logger.info(f"Sampled {len(sampled)} rows out of {len(df)}") + return sampled.rename({"_stratum": "eval_stratum"}).drop( + ["_norm_code", "_population_count", "_sampled_count"] + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build a stratified evaluation set") + parser.add_argument( + "--input", default=PATH_EVAL_INPUT, help="Input parquet (local path or S3 key)" + ) + parser.add_argument("--output", default=PATH_EVAL_OUTPUT, help="Output parquet (local path)") + parser.add_argument( + "--code-column", default="apet2025", help="Label column (default: apet2025)" + ) + parser.add_argument( + "--n-per-stratum", type=int, default=10, help="Max rows per stratum (default: 10)" + ) + parser.add_argument( + "--stratum-depth", + type=int, + default=5, + help="Code prefix length defining a stratum (default: 2 = NAF division)", + ) + parser.add_argument("--seed", type=int, default=42, help="Sampling seed (default: 42)") + args = parser.parse_args() + + df = load_dataframe(args.input) + sampled = stratified_sample( + df, + code_column=args.code_column, + n_per_stratum=args.n_per_stratum, + stratum_depth=args.stratum_depth, + seed=args.seed, + ) + + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + sampled.write_parquet(args.output) + logger.info(f"Evaluation set written to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/evaluation/compare.py b/src/evaluation/compare.py new file mode 100644 index 0000000..f92746f --- /dev/null +++ b/src/evaluation/compare.py @@ -0,0 +1,209 @@ +"""Comparaison appariée de deux méthodes exécutées sur le même jeu d'évaluation. + +cf. cadrage_2026-07.md §3.3-B. Deux méthodes évaluées sur le même jeu (mêmes +lignes, même vérité terrain) forment un plan **apparié** : comparer deux +intervalles de confiance indépendants et vérifier s'ils se chevauchent est un +test conservateur et statistiquement inadapté ici (il ignore la corrélation +ligne à ligne entre les deux méthodes). On utilise donc : + +- un bootstrap apparié en grappes (par strate) sur la différence d'exactitude + (`accuracy_b - accuracy_a`) ; +- le test de McNemar sur les paires discordantes, en complément rapide et + sans rééchantillonnage, standard pour comparer deux classifieurs sur les + mêmes observations. + +Usage : + uv run -m src.evaluation.compare \ + --a data/eval/results/predictions_navigator.parquet \ + --b data/eval/results/predictions_agentic-rag.parquet +""" + +import argparse +import json + +import numpy as np +import polars as pl +from scipy.stats import binomtest + +from src.evaluation.metrics import accuracy_at_depth, normalize_code + + +def _is_correct(true_code, pred_code, depth: int | None = None) -> bool | None: + """True/False si comparable, None si la vérité terrain est manquante (paire ignorée).""" + true_norm = normalize_code(true_code) + pred_norm = normalize_code(pred_code) + if true_norm is None: + return None + if pred_norm is None: + return False + if depth is None: + return true_norm == pred_norm + return true_norm[:depth] == pred_norm[:depth] + + +def paired_bootstrap_diff( + y_true: list, + y_pred_a: list, + y_pred_b: list, + strata: list, + depth: int | None = None, + n_resamples: int = 1000, + alpha: float = 0.05, + seed: int = 42, +) -> tuple[float, tuple[float, float]]: + """IC bootstrap apparié (grappes par strate) sur `accuracy(b) - accuracy(a)`. + + Chaque rééchantillonnage tire les mêmes indices de lignes pour les deux + méthodes (le rééchantillonnage porte sur les lignes, pas sur les + prédictions séparément), ce qui préserve l'appariement. + """ + n = len(y_true) + if n != len(y_pred_a) or n != len(y_pred_b) or n != len(strata): + raise ValueError("y_true, y_pred_a, y_pred_b et strata doivent avoir la même taille") + + y_true_arr = np.asarray(y_true, dtype=object) + y_pred_a_arr = np.asarray(y_pred_a, dtype=object) + y_pred_b_arr = np.asarray(y_pred_b, dtype=object) + + strata_indices: dict = {} + for i, s in enumerate(strata): + strata_indices.setdefault(s, []).append(i) + strata_index_arrays = [np.array(idx) for idx in strata_indices.values()] + + def diff_for(idx: np.ndarray) -> float: + t = y_true_arr[idx].tolist() + acc_a = accuracy_at_depth(t, y_pred_a_arr[idx].tolist(), depth=depth) + acc_b = accuracy_at_depth(t, y_pred_b_arr[idx].tolist(), depth=depth) + return acc_b - acc_a + + point_diff = diff_for(np.arange(n)) + + rng = np.random.default_rng(seed) + diffs = np.empty(n_resamples) + for r in range(n_resamples): + resampled_idx = np.concatenate( + [rng.choice(idx, size=len(idx), replace=True) for idx in strata_index_arrays] + ) + diffs[r] = diff_for(resampled_idx) + + lo, hi = np.quantile(diffs, [alpha / 2, 1 - alpha / 2]) + return float(point_diff), (float(lo), float(hi)) + + +def mcnemar_test(y_true: list, y_pred_a: list, y_pred_b: list, depth: int | None = None) -> float: + """P-valeur du test de McNemar exact sur les paires discordantes entre deux méthodes. + + Une paire est discordante quand une méthode a raison et l'autre a tort sur + la même ligne ; les lignes sans vérité terrain exploitable sont ignorées. + """ + n_a_only = 0 # a correct, b incorrect + n_b_only = 0 # a incorrect, b correct + for true_code, pred_a, pred_b in zip(y_true, y_pred_a, y_pred_b): + correct_a = _is_correct(true_code, pred_a, depth=depth) + correct_b = _is_correct(true_code, pred_b, depth=depth) + if correct_a is None or correct_b is None: + continue + if correct_a and not correct_b: + n_a_only += 1 + elif correct_b and not correct_a: + n_b_only += 1 + + discordant = n_a_only + n_b_only + if discordant == 0: + return 1.0 + return float(binomtest(min(n_a_only, n_b_only), discordant, p=0.5).pvalue) + + +def compare( + df_a: pl.DataFrame, + df_b: pl.DataFrame, + code_column: str = "apet2025", + prediction_column: str = "prediction", + stratum_column: str = "eval_stratum", + depth: int | None = None, + n_resamples: int = 1000, + alpha: float = 0.05, + seed: int = 42, +) -> dict: + """Compare deux campagnes exécutées sur le même jeu d'évaluation (même ordre de lignes).""" + if len(df_a) != len(df_b): + raise ValueError("Les deux jeux de prédictions doivent avoir le même nombre de lignes") + + y_true = df_a[code_column].to_list() + if y_true != df_b[code_column].to_list(): + raise ValueError( + "Les vérités terrain diffèrent entre les deux fichiers : lignes non alignées" + ) + + y_pred_a = df_a[prediction_column].to_list() + y_pred_b = df_b[prediction_column].to_list() + strata = ( + df_a[stratum_column].to_list() if stratum_column in df_a.columns else list(range(len(df_a))) + ) + + accuracy_a = accuracy_at_depth(y_true, y_pred_a, depth=depth) + accuracy_b = accuracy_at_depth(y_true, y_pred_b, depth=depth) + diff_point, diff_ci = paired_bootstrap_diff( + y_true, + y_pred_a, + y_pred_b, + strata, + depth=depth, + n_resamples=n_resamples, + alpha=alpha, + seed=seed, + ) + p_value = mcnemar_test(y_true, y_pred_a, y_pred_b, depth=depth) + + return { + "n": len(y_true), + "accuracy_a": accuracy_a, + "accuracy_b": accuracy_b, + "diff": diff_point, + "diff_ci": list(diff_ci), + "mcnemar_p_value": p_value, + "significant": p_value < alpha, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Paired statistical comparison of two evaluation campaigns" + ) + parser.add_argument("--a", required=True, help="predictions_.parquet for method A") + parser.add_argument("--b", required=True, help="predictions_.parquet for method B") + parser.add_argument("--code-column", default="apet2025") + parser.add_argument("--prediction-column", default="prediction") + parser.add_argument("--stratum-column", default="eval_stratum") + parser.add_argument( + "--depth", type=int, default=None, help="Prefix depth to compare (default: leaf)" + ) + parser.add_argument("--n-resamples", type=int, default=1000) + parser.add_argument("--alpha", type=float, default=0.05) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output", default=None, help="Optional path to write the JSON report") + args = parser.parse_args() + + df_a = pl.read_parquet(args.a) + df_b = pl.read_parquet(args.b) + report = compare( + df_a, + df_b, + code_column=args.code_column, + prediction_column=args.prediction_column, + stratum_column=args.stratum_column, + depth=args.depth, + n_resamples=args.n_resamples, + alpha=args.alpha, + seed=args.seed, + ) + + print(json.dumps(report, indent=2, ensure_ascii=False)) + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/evaluation/config.py b/src/evaluation/config.py new file mode 100644 index 0000000..487f091 --- /dev/null +++ b/src/evaluation/config.py @@ -0,0 +1,2 @@ +PATH_EVAL_INPUT = "projet-ape/data/25062026/naf2025/split/df_test.parquet" +PATH_EVAL_OUTPUT = "data/eval/eval_set.parquet" diff --git a/src/evaluation/drift.py b/src/evaluation/drift.py new file mode 100644 index 0000000..89b762e --- /dev/null +++ b/src/evaluation/drift.py @@ -0,0 +1,198 @@ +"""Détection de dérive de distribution, sans étiquettes (cf. cadrage §3.3-C). + +Sert le cas d'usage monitoring : surveiller la distribution des prédictions +d'un modèle en production (scores de confiance, fréquence des codes prédits) +et détecter un décalage par rapport à une période de référence, sans +nécessiter d'annotation humaine continue. + +Trois métriques usuelles en surveillance de modèles : +- **distance de Wasserstein** et **test de Kolmogorov-Smirnov** : pour des + signaux continus (ex. scores de confiance du *CodeChooser*/*MatchVerifier*). +- **Population Stability Index (PSI)** : pour un signal continu (bins par + quantiles de la référence) ou catégoriel (ex. fréquence des codes prédits). + +Les seuils d'alerte ne sont pas des constantes arbitraires : `calibrate_threshold` +les dérive empiriquement de la référence elle-même (rééchantillonnage sous +l'hypothèse « pas de dérive »), pour un taux de fausse alerte contrôlé. +""" + + +import numpy as np +from scipy.stats import ks_2samp, wasserstein_distance + + +def wasserstein_drift(reference: np.ndarray, current: np.ndarray) -> float: + """Distance de Wasserstein (terre déplacée) entre deux échantillons continus. + + 0 = distributions identiques ; croît avec l'ampleur du décalage. Sensible + à l'échelle du signal (ex. pas comparable entre un score de confiance + dans [0, 1] et un signal à plus grande échelle). + """ + return float(wasserstein_distance(reference, current)) + + +def ks_drift(reference: np.ndarray, current: np.ndarray) -> dict: + """Test de Kolmogorov-Smirnov à deux échantillons. + + Returns: + {"statistic": écart maximal entre fonctions de répartition empiriques, + "p_value": probabilité d'observer un tel écart sous H0 (même distribution)} + """ + statistic, p_value = ks_2samp(reference, current) + return {"statistic": float(statistic), "p_value": float(p_value)} + + +def psi(reference: np.ndarray, current: np.ndarray, n_bins: int = 10, eps: float = 1e-4) -> float: + """Population Stability Index entre deux échantillons continus. + + Les bins sont définis par les quantiles de `reference` (pas de `current`), + pour que le découpage ne dépende que de la période de référence. + + Repères usuels en gouvernance de modèle : PSI < 0.1 = stable, + 0.1-0.25 = dérive modérée à surveiller, > 0.25 = dérive significative. + """ + quantile_edges = np.quantile(reference, np.linspace(0, 1, n_bins + 1)) + quantile_edges[0], quantile_edges[-1] = -np.inf, np.inf + + ref_counts, _ = np.histogram(reference, bins=quantile_edges) + cur_counts, _ = np.histogram(current, bins=quantile_edges) + + return _psi_from_counts(ref_counts, cur_counts, eps=eps) + + +def psi_categorical(reference_labels: list, current_labels: list, eps: float = 1e-4) -> float: + """Population Stability Index entre deux échantillons catégoriels. + + Cas d'usage typique ici : comparer la distribution des codes prédits sur + deux fenêtres temporelles. Les catégories vues dans l'une des deux + fenêtres mais pas l'autre sont incluses (fréquence 0 lissée par `eps`). + """ + categories = sorted(set(reference_labels) | set(current_labels)) + ref_counts = np.array([reference_labels.count(c) for c in categories]) + cur_counts = np.array([current_labels.count(c) for c in categories]) + return _psi_from_counts(ref_counts, cur_counts, eps=eps) + + +def _psi_from_counts(ref_counts: np.ndarray, cur_counts: np.ndarray, eps: float) -> float: + ref_pct = ref_counts / ref_counts.sum() if ref_counts.sum() else ref_counts.astype(float) + cur_pct = cur_counts / cur_counts.sum() if cur_counts.sum() else cur_counts.astype(float) + ref_pct = np.clip(ref_pct, eps, None) + cur_pct = np.clip(cur_pct, eps, None) + return float(np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct))) + + +def calibrate_threshold( + reference: np.ndarray, + metric_fn, + alpha: float = 0.05, + n_resamples: int = 500, + seed: int = 42, +) -> float: + """Calibre un seuil d'alerte empirique à partir de la seule référence. + + Principe : sous l'hypothèse « pas de dérive », deux sous-échantillons + aléatoires de la référence devraient donner une valeur de métrique + proche de 0. On simule cette hypothèse par rééchantillonnage (bootstrap) + et on prend le quantile (1 - alpha) de la distribution obtenue comme + seuil : au-delà, la dérive observée est trop grande pour être due au + seul bruit d'échantillonnage, au taux de faux positifs `alpha` près. + + Args: + reference: Échantillon de la période de référence. + metric_fn: Fonction (échantillon_a, échantillon_b) -> métrique scalaire + (ex. `wasserstein_drift`, ou `lambda a, b: ks_drift(a, b)["statistic"]`). + alpha: Taux de fausse alerte visé (défaut : 5 %). + n_resamples: Nombre de tirages bootstrap. + seed: Graine, pour un seuil reproductible. + + Returns: + Seuil au-delà duquel une valeur de métrique est considérée comme une + dérive réelle plutôt qu'une fluctuation d'échantillonnage. + """ + rng = np.random.default_rng(seed) + reference = np.asarray(reference) + half = len(reference) // 2 + if half < 1: + raise ValueError("reference must have at least 2 elements to calibrate a threshold") + + null_values = [] + for _ in range(n_resamples): + shuffled = rng.permutation(reference) + null_values.append(metric_fn(shuffled[:half], shuffled[half:])) + + return float(np.quantile(null_values, 1 - alpha)) + + +def drift_report( + reference: np.ndarray, + current: np.ndarray, + alpha: float = 0.05, + n_resamples: int = 500, + seed: int = 42, +) -> dict: + """Rapport de dérive combinant les trois métriques sur un signal continu. + + Chaque métrique a son propre seuil calibré empiriquement sur `reference` + (cf. `calibrate_threshold`) plutôt qu'un seuil arbitraire partagé. + + Returns: + Dictionnaire avec, pour chaque métrique, la valeur observée, le seuil + calibré et un booléen `is_drift`, plus `any_drift` (majorité simple). + """ + reference = np.asarray(reference) + current = np.asarray(current) + + wasserstein_value = wasserstein_drift(reference, current) + wasserstein_threshold = calibrate_threshold( + reference, wasserstein_drift, alpha=alpha, n_resamples=n_resamples, seed=seed + ) + + ks_result = ks_drift(reference, current) + + psi_value = psi(reference, current) + psi_threshold = calibrate_threshold( + reference, psi, alpha=alpha, n_resamples=n_resamples, seed=seed + ) + + report = { + "wasserstein": { + "value": wasserstein_value, + "threshold": wasserstein_threshold, + "is_drift": wasserstein_value > wasserstein_threshold, + }, + "ks": { + "statistic": ks_result["statistic"], + "p_value": ks_result["p_value"], + "is_drift": ks_result["p_value"] < alpha, + }, + "psi": { + "value": psi_value, + "threshold": psi_threshold, + "is_drift": psi_value > psi_threshold, + }, + } + n_alerts = sum(m["is_drift"] for m in report.values()) + report["any_drift"] = n_alerts >= 2 # majorité sur les 3 métriques + return report + + +def rolling_drift_reports( + reference: np.ndarray, + windows: list, + alpha: float = 0.05, + n_resamples: int = 500, + seed: int = 42, +) -> list: + """Applique `drift_report` sur une suite de fenêtres temporelles glissantes. + + Args: + reference: Échantillon de la période de référence (fixe). + windows: Liste d'échantillons, un par fenêtre temporelle successive. + + Returns: + Liste de rapports (un par fenêtre), dans l'ordre fourni. + """ + return [ + drift_report(reference, window, alpha=alpha, n_resamples=n_resamples, seed=seed) + for window in windows + ] diff --git a/src/evaluation/human_review_app.py b/src/evaluation/human_review_app.py new file mode 100644 index 0000000..f6b4a4c --- /dev/null +++ b/src/evaluation/human_review_app.py @@ -0,0 +1,485 @@ +"""Petite appli web pour l'évaluation humaine du MatchVerifier. + +Pour un échantillon de labels du jeu d'entraînement déjà jugés par +MatchVerifier (cf. src.evaluation.verify_train_labels), affiche l'activité, +le code de la vérité terrain avec sa notice officielle NACE, et le verdict +du vérificateur (is_match, confiance, justification). L'humain indique si ce +verdict est correct ("right") ou non ("wrong") ; les jugements sont +journalisés en JSONL (append-only, ré-exécutable) et servent à calculer +l'accuracy, la précision et le rappel du vérificateur. + +Le rappel/précision sont dérivés en traitant le jugement humain comme la +vérité terrain sur le match réel : si l'humain dit "right", le label réel vaut +le verdict du vérificateur ; s'il dit "wrong", le label réel est l'inverse. + +Nécessite Neo4j configuré dans l'environnement pour afficher la notice du +code (comme src.main) ; sans connexion, la revue reste possible mais sans +notice. + +Usage : + uv run -m src.evaluation.human_review_app \ + --input data/eval/train_verification/train_label_verification.parquet \ + --output data/eval/human_review/match_verifier_review.jsonl \ + --port 5050 +""" + +import argparse +import hashlib +import json +import logging +import os +from datetime import datetime, timezone + +import polars as pl +from flask import Flask, redirect, render_template_string, url_for + +from src.config import neo4j_config +from src.neo4j_graph.graph import Graph +from src.utils.logging import configure_logging + +configure_logging() +logger = logging.getLogger(__name__) + +DEFAULT_INPUT = "data/eval/train_verification/train_label_verification.parquet" +DEFAULT_OUTPUT = "data/eval/human_review/match_verifier_review.jsonl" + + +def row_id_for(libelle: str, code: str) -> str: + return hashlib.sha1(f"{libelle}|{code}".encode("utf-8")).hexdigest()[:12] + + +def load_rows(input_path: str, n: int | None, seed: int) -> list[dict]: + df = pl.read_parquet(input_path) + if n is not None and n < len(df): + df = df.sample(n=n, seed=seed) + return [ + { + "row_id": row_id_for(r["libelle"], r["nace2025"]), + "libelle": r["libelle"], + "nace2025": r["nace2025"], + "llm_is_match": bool(r["llm_is_match"]), + "llm_confidence": r["llm_confidence"], + "llm_explanation": r["llm_explanation"], + } + for r in df.to_dicts() + ] + + +def load_judgments(output_path: str) -> dict[str, dict]: + judgments = {} + if not os.path.exists(output_path): + return judgments + with open(output_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + entry = json.loads(line) + judgments[entry["row_id"]] = entry + return judgments + + +def append_judgment(output_path: str, entry: dict) -> None: + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + with open(output_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +def compute_metrics(rows: list[dict], judgments: dict[str, dict]) -> dict: + """Confusion matrix of llm_is_match (predicted) vs. human-derived actual label. + + actual = predicted if the human marked the verdict "right", else `not predicted` + (the human is asserting the verifier got it backwards). + """ + tp = fp = fn = tn = 0 + n_right = 0 + n_reviewed = 0 + for row in rows: + judgment = judgments.get(row["row_id"]) + if judgment is None: + continue + n_reviewed += 1 + predicted = row["llm_is_match"] + is_right = judgment["human_verdict"] == "right" + n_right += int(is_right) + actual = predicted if is_right else not predicted + if predicted and actual: + tp += 1 + elif predicted and not actual: + fp += 1 + elif not predicted and actual: + fn += 1 + else: + tn += 1 + + accuracy = n_right / n_reviewed if n_reviewed else None + precision = tp / (tp + fp) if (tp + fp) else None + recall = tp / (tp + fn) if (tp + fn) else None + f1 = 2 * precision * recall / (precision + recall) if precision and recall else None + + return { + "n_total": len(rows), + "n_reviewed": n_reviewed, + "accuracy": accuracy, + "precision": precision, + "recall": recall, + "f1": f1, + "tp": tp, + "fp": fp, + "fn": fn, + "tn": tn, + } + + +_code_notice_cache: dict[str, dict | None] = {} +_graph: Graph | None | bool = None + + +def get_graph() -> Graph | None: + global _graph + if _graph is None: + try: + _graph = Graph(neo4j_config) + except Exception: + logger.exception("Could not connect to Neo4j, code notices will be unavailable") + _graph = False + return _graph or None + + +def get_code_notice(code: str) -> dict | None: + if code in _code_notice_cache: + return _code_notice_cache[code] + graph = get_graph() + notice = None + if graph is not None: + try: + info = graph.get_code_information(code) + if info and not info.get("error"): + notice = info + except Exception: + logger.exception(f"Failed to fetch code information for {code}") + _code_notice_cache[code] = notice + return notice + + +STYLE = """ + +""" + +NAV = """ + +""" + +REVIEW_TEMPLATE = ( + STYLE + + NAV + + """ +{% if existing_verdict %} +
+ Déjà jugé : verdict du vérificateur marqué {{ "correct" if existing_verdict == "right" else "incorrect" }} +
+{% endif %} + +
+
Activité ({{ idx }} / {{ total }})
+
{{ row.libelle }}
+ +
Code de la vérité terrain
+
+ {{ row.nace2025 }} + {% if notice %} — {{ notice.name }}{% endif %} +
+ + {% if notice %} +
Notice officielle NACE
+
{{ notice.description or "(pas de description)" }}
+ {% else %} +
Notice indisponible (pas de connexion Neo4j ou code introuvable).
+ {% endif %} +
+ +
+
+ {% if row.llm_is_match %} + MATCH + {% else %} + NO MATCH + {% endif %} + confiance : {{ "%.0f"|format(row.llm_confidence * 100) }}% +
+
Justification du MatchVerifier
+
{{ row.llm_explanation }}
+
+ + + + +""" +) + +METRICS_TEMPLATE = ( + STYLE + + NAV + + """ +

Métriques du MatchVerifier

+ +
+
{{ m.n_reviewed }}/{{ m.n_total }}Jugés
+
{{ "%.0f%%"|format(m.accuracy * 100) if m.accuracy is not none else "—" }}Accuracy
+
{{ "%.0f%%"|format(m.precision * 100) if m.precision is not none else "—" }}Précision
+
{{ "%.0f%%"|format(m.recall * 100) if m.recall is not none else "—" }}Rappel
+
{{ "%.0f%%"|format(m.f1 * 100) if m.f1 is not none else "—" }}F1
+
+ +

+ Accuracy = part des verdicts jugés corrects. Précision/rappel traitent + "is_match" comme prédiction positive, avec un label réel dérivé du jugement + humain (verdict inversé quand il est jugé incorrect). +

+ + + + + +
Réel : matchRéel : no match
Prédit : matchTP = {{ m.tp }}FP = {{ m.fp }}
Prédit : no matchFN = {{ m.fn }}TN = {{ m.tn }}
+ +

Lignes jugées

+ + + {% for r in rows %} + + + + + + + + {% endfor %} +
ActivitéCodeVerdict LLMJugement humain
{{ r.libelle[:60] }}{{ "…" if r.libelle|length > 60 else "" }}{{ r.nace2025 }}{{ "MATCH" if r.llm_is_match else "NO MATCH" }}{{ "correct" if r.human_verdict == "right" else "incorrect" }}revoir
+""" +) + + +class PrefixMiddleware: + """Make the app work whether or not the reverse proxy strips its own path prefix. + + Onyxia/code-server serves this app through a path-based reverse proxy + (`/proxy//...`). Whether that proxy strips the prefix before + forwarding to us, or forwards the full path unstripped, is not something + we can rely on: it 404s one way and 404s the other way if we hard-code + either assumption. So: if an incoming request's PATH_INFO still carries + the prefix, strip it ourselves before Werkzeug routes it (handles the + "forwarded unstripped" case; a no-op if the proxy already stripped it). + Either way, set SCRIPT_NAME so url_for()/redirect() re-add the prefix to + every generated link and Location header, since plain `` links in + the HTML body are never rewritten by the proxy itself. + """ + + def __init__(self, app, prefix: str): + self.app = app + self.prefix = prefix + + def __call__(self, environ, start_response): + if self.prefix: + path = environ.get("PATH_INFO", "") + if path.startswith(self.prefix): + environ["PATH_INFO"] = path[len(self.prefix) :] or "/" + environ["SCRIPT_NAME"] = self.prefix + return self.app(environ, start_response) + + +def create_app( + input_path: str, output_path: str, n: int | None, seed: int, url_prefix: str = "" +) -> Flask: + app = Flask(__name__) + if url_prefix: + app.wsgi_app = PrefixMiddleware(app.wsgi_app, url_prefix) + rows = load_rows(input_path, n, seed) + rows_by_id = {r["row_id"]: r for r in rows} + order = [r["row_id"] for r in rows] + logger.info(f"Loaded {len(rows)} rows from {input_path}") + + def first_unreviewed() -> str | None: + judgments = load_judgments(output_path) + for row_id in order: + if row_id not in judgments: + return row_id + return None + + @app.route("/") + def index(): + next_id = first_unreviewed() + if next_id is None: + return redirect(url_for("metrics")) + return redirect(url_for("review", row_id=next_id)) + + @app.route("/review/") + def review(row_id): + if row_id not in rows_by_id: + return redirect(url_for("index")) + row = rows_by_id[row_id] + judgments = load_judgments(output_path) + notice = get_code_notice(row["nace2025"]) + idx = order.index(row_id) + existing = judgments.get(row_id) + return render_template_string( + REVIEW_TEMPLATE, + row=row, + notice=notice, + idx=idx + 1, + total=len(order), + n_reviewed=len(judgments), + n_total=len(order), + prev_id=order[idx - 1] if idx > 0 else None, + next_id=order[idx + 1] if idx + 1 < len(order) else None, + existing_verdict=existing["human_verdict"] if existing else None, + ) + + @app.route("/judge//") + def judge(row_id, verdict): + if row_id not in rows_by_id or verdict not in ("right", "wrong"): + return redirect(url_for("index")) + + row = rows_by_id[row_id] + append_judgment( + output_path, + { + "row_id": row_id, + "libelle": row["libelle"], + "nace2025": row["nace2025"], + "llm_is_match": row["llm_is_match"], + "llm_confidence": row["llm_confidence"], + "llm_explanation": row["llm_explanation"], + "human_verdict": verdict, + "reviewed_at": datetime.now(timezone.utc).isoformat(), + }, + ) + + idx = order.index(row_id) + if idx + 1 < len(order): + return redirect(url_for("review", row_id=order[idx + 1])) + return redirect(url_for("index")) + + @app.route("/metrics") + def metrics(): + judgments = load_judgments(output_path) + m = compute_metrics(rows, judgments) + reviewed_rows = [ + {**row, **judgments[row["row_id"]]} for row in rows if row["row_id"] in judgments + ] + return render_template_string( + METRICS_TEMPLATE, + m=m, + rows=reviewed_rows, + n_reviewed=m["n_reviewed"], + n_total=m["n_total"], + ) + + return app + + +def main() -> int: + parser = argparse.ArgumentParser(description="Human review app for MatchVerifier") + parser.add_argument( + "--input", default=DEFAULT_INPUT, help="Parquet with libelle/nace2025/llm_* columns" + ) + parser.add_argument("--output", default=DEFAULT_OUTPUT, help="JSONL log of human judgments") + parser.add_argument("--n", type=int, default=None, help="Subsample size (default: all rows)") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=5050) + parser.add_argument("--debug", action="store_true") + parser.add_argument( + "--url-prefix", + default=None, + help="Path prefix to prepend to generated links (default: auto-detect Onyxia's " + "/proxy/ reverse proxy from VSCODE_PROXY_URI; pass '' to disable)", + ) + args = parser.parse_args() + + if args.url_prefix is not None: + url_prefix = args.url_prefix + elif os.environ.get("VSCODE_PROXY_URI"): + url_prefix = f"/proxy/{args.port}" + else: + url_prefix = "" + + app = create_app(args.input, args.output, args.n, args.seed, url_prefix=url_prefix) + if url_prefix: + logger.info(f"Prefixing generated links with {url_prefix!r} (Onyxia reverse proxy)") + app.run(host=args.host, port=args.port, debug=args.debug) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/evaluation/metrics.py b/src/evaluation/metrics.py new file mode 100644 index 0000000..4d17427 --- /dev/null +++ b/src/evaluation/metrics.py @@ -0,0 +1,152 @@ +"""Métriques d'évaluation pour la classification hiérarchique. + +Volontairement sans dépendance tierce : le module reste importable et testable +dans un environnement de CI minimal, sans Neo4j ni client LLM. + +Les codes sont normalisés (suppression des points/espaces, majuscules) avant +comparaison, si bien que "10.71C" et "1071c" sont considérés identiques. +Pour la NAF/NACE, les préfixes du code normalisé correspondent aux niveaux +de la hiérarchie : 2 caractères = division, 3 = groupe, 4 = classe, +code complet = sous-classe (feuille). +""" + + +def normalize_code(code) -> str | None: + """Normalise un code de nomenclature pour comparaison. + + Retourne None pour une prédiction manquante (None, chaîne vide), + ce qui matérialise un échec du classifieur (pas de code final atteint). + """ + if code is None: + return None + normalized = str(code).replace(".", "").replace(" ", "").upper() + return normalized or None + + +def accuracy_at_depth( + y_true: list, y_pred: list, depth: int | None = None, weights: list | None = None +) -> float: + """Part des prédictions égales à la vérité terrain sur les `depth` premiers caractères. + + Args: + y_true: Codes de référence. + y_pred: Codes prédits (None = échec du classifieur, compté comme erreur). + depth: Profondeur de comparaison ; None compare les codes complets + (exactitude à la feuille). + weights: Poids par ligne (ex. correction de sur-échantillonnage des + strates rares, cf. `build_eval_set.ipw_weight`). None = poids + uniformes (comportement historique : exactitude moyenne par + ligne du jeu d'évaluation, pas par la fréquence réelle des codes). + + Returns: + Exactitude entre 0 et 1 ; NaN si aucune paire exploitable. Les paires + dont la vérité terrain est manquante sont ignorées. + """ + if len(y_true) != len(y_pred): + raise ValueError( + f"y_true ({len(y_true)}) et y_pred ({len(y_pred)}) doivent avoir la même taille" + ) + if weights is not None and len(weights) != len(y_true): + raise ValueError( + f"weights ({len(weights)}) et y_true ({len(y_true)}) doivent avoir la même taille" + ) + + correct = 0.0 + total = 0.0 + for i, (true_code, pred_code) in enumerate(zip(y_true, y_pred)): + true_norm = normalize_code(true_code) + pred_norm = normalize_code(pred_code) + if true_norm is None: + continue + weight = weights[i] if weights is not None else 1.0 + total += weight + if pred_norm is None: + continue + if depth is None: + is_correct = true_norm == pred_norm + else: + is_correct = true_norm[:depth] == pred_norm[:depth] + correct += weight if is_correct else 0.0 + + return correct / total if total else float("nan") + + +def failure_rate(y_pred: list) -> float: + """Part des prédictions manquantes (le classifieur n'a pas atteint de code final).""" + if not y_pred: + return float("nan") + return sum(1 for pred in y_pred if normalize_code(pred) is None) / len(y_pred) + + +def low_confidence_rate(confidences: list, threshold: float = 0.0) -> float: + """Part des prédictions à confiance faible ou nulle. + + Distinct de `failure_rate` : un classifieur peut renvoyer un code réel + (pas None) avec une confiance nulle quand la finalisation a échoué et + qu'un repli sur la dernière position atteinte a été utilisé (cf. + `_fallback_output`). Ces cas ne sont pas des échecs au sens de + `failure_rate` (il y a bien un code) mais ne doivent pas être confondus + avec une prédiction confiante lors de l'analyse d'erreurs. + + Args: + confidences: Scores de confiance par prédiction (None = non renseigné, + ignoré du calcul). + threshold: Seuil (inclusif) en dessous duquel une confiance est + considérée comme faible (défaut : 0.0, le sentinel de repli). + + Returns: + Part des prédictions dont la confiance est renseignée et <= threshold, + rapportée à l'ensemble des prédictions (confiances manquantes comptées + au dénominateur, jamais au numérateur) ; NaN si la liste est vide. + """ + if not confidences: + return float("nan") + return sum(1 for c in confidences if c is not None and c <= threshold) / len(confidences) + + +def evaluate( + y_true: list, + y_pred: list, + depths: tuple = (2, 3, 4), + weights: list | None = None, + confidences: list | None = None, +) -> dict: + """Rapport d'évaluation complet d'un classifieur. + + Args: + y_true: Codes de référence. + y_pred: Codes prédits (None = échec). + depths: Profondeurs de préfixe pour l'exactitude par niveau + (par défaut : division, groupe, classe pour la NAF). + weights: Poids par ligne pour une lecture pondérée (représentative de + la fréquence réelle des codes) en complément de la lecture non + pondérée (moyenne égale par code) — les deux sont toujours + calculées quand `weights` est fourni, aucune n'écrase l'autre. + confidences: Scores de confiance par prédiction, pour reporter + `low_confidence_rate` en plus de `failure_rate` (optionnel). + + Returns: + Dictionnaire {n, leaf_accuracy, failure_rate, accuracy_depth_...}, + avec en plus {leaf_accuracy_weighted, accuracy_depth__weighted...} + si `weights` est fourni, et {low_confidence_rate} si `confidences` + est fourni. + """ + report = { + "n": len(y_true), + "leaf_accuracy": accuracy_at_depth(y_true, y_pred), + "failure_rate": failure_rate(y_pred), + } + for depth in depths: + report[f"accuracy_depth_{depth}"] = accuracy_at_depth(y_true, y_pred, depth=depth) + + if weights is not None: + report["leaf_accuracy_weighted"] = accuracy_at_depth(y_true, y_pred, weights=weights) + for depth in depths: + report[f"accuracy_depth_{depth}_weighted"] = accuracy_at_depth( + y_true, y_pred, depth=depth, weights=weights + ) + + if confidences is not None: + report["low_confidence_rate"] = low_confidence_rate(confidences) + + return report diff --git a/src/evaluation/run_eval.py b/src/evaluation/run_eval.py new file mode 100644 index 0000000..c4c1d61 --- /dev/null +++ b/src/evaluation/run_eval.py @@ -0,0 +1,149 @@ +"""Exécution d'une campagne d'évaluation d'un classifieur sur un jeu d'évaluation. + +Charge un jeu d'évaluation (cf. build_eval_set.py), classe chaque libellé avec +la méthode choisie, calcule les métriques (exactitude feuille et par niveau, +taux d'échec) et écrit les prédictions détaillées + le rapport de métriques. + +Nécessite à l'exécution : la base Neo4j et l'API LLM configurées dans +l'environnement (mêmes prérequis que src.main). + +Usage : + uv run -m src.evaluation.run_eval \ + --eval-set data/eval/eval_set.parquet \ + --method navigator \ + --output-dir data/eval/results +""" + +import argparse +import asyncio +import json +import logging +import os + +import polars as pl + +from src.evaluation.bootstrap import bootstrap_ci +from src.evaluation.config import PATH_EVAL_OUTPUT +from src.evaluation.metrics import accuracy_at_depth, evaluate +from src.main import classify_agentic_rag, classify_navigator, classify_supervised +from src.utils.logging import configure_logging + +configure_logging() +logger = logging.getLogger(__name__) + +METHODS = { + "navigator": classify_navigator, + "agentic-rag": classify_agentic_rag, + # Reference baseline: production model, for the Navigator/Agentic-RAG/supervised + # comparison called for in the cadrage's note de conception (§3.3-B). + "supervised": classify_supervised, +} + + +async def run(args) -> int: + df = pl.read_parquet(args.eval_set) + df = df.sample(fraction=args.fraction) + labels = df[args.text_column].to_list() + y_true = df[args.code_column].to_list() + logger.info(f"Evaluating method '{args.method}' on {len(labels)} labels") + + if "ipw_weight" in df.columns: + weights = df["ipw_weight"].to_list() + else: + logger.warning( + "No 'ipw_weight' column in eval set: weighted accuracy skipped " + "(eval set predates IPW weighting, rebuild it with build_eval_set.py)." + ) + weights = None + + method = METHODS[args.method] + predictions = await method(labels) + + # Les classifieurs renvoient des MatchVerificationInput ; un échec (pas de + # code final atteint) est représenté par une prédiction sans code. Une + # confiance nulle avec un code renseigné signale un repli de finalisation + # (`_fallback_output`), pas un échec au sens strict : cf. low_confidence_rate. + y_pred = [getattr(pred, "code", None) for pred in predictions] + explanations = [getattr(pred, "proposed_explanation", None) for pred in predictions] + confidences = [getattr(pred, "proposed_confidence", None) for pred in predictions] + + report = evaluate(y_true, y_pred, weights=weights, confidences=confidences) + + if args.bootstrap > 0: + if "eval_stratum" in df.columns: + strata = df["eval_stratum"].to_list() + else: + logger.warning( + "No 'eval_stratum' column in eval set: bootstrap falls back to a single " + "stratum (unstratified), understating the effect of the sampling design." + ) + strata = [0] * len(y_true) + + def accuracy_metric(t, p, w): + return accuracy_at_depth(t, p, weights=w) + + _, ci = bootstrap_ci( + y_true, y_pred, strata, accuracy_metric, n_resamples=args.bootstrap, seed=args.seed + ) + report["leaf_accuracy_ci"] = list(ci) + + if weights is not None: + _, weighted_ci = bootstrap_ci( + y_true, + y_pred, + strata, + accuracy_metric, + n_resamples=args.bootstrap, + seed=args.seed, + weights=weights, + ) + report["leaf_accuracy_weighted_ci"] = list(weighted_ci) + + logger.info(f"Metrics: {report}") + + os.makedirs(args.output_dir, exist_ok=True) + + details = df.with_columns( + pl.Series("prediction", y_pred), + pl.Series("explanation", explanations), + pl.Series("confidence", confidences), + ) + details_path = os.path.join(args.output_dir, f"predictions_{args.method}.parquet") + details.write_parquet(details_path) + + report_path = os.path.join(args.output_dir, f"metrics_{args.method}.json") + with open(report_path, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + print(json.dumps(report, indent=2, ensure_ascii=False)) + logger.info(f"Predictions written to {details_path}, metrics to {report_path}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run an evaluation campaign") + parser.add_argument( + "--eval-set", default=PATH_EVAL_OUTPUT, help="Evaluation parquet (cf. build_eval_set)" + ) + parser.add_argument( + "--fraction", default="1.0", help="Dataset's fraction to evaluate (default: 1.0)" + ) + parser.add_argument("--method", choices=sorted(METHODS), default="navigator") + parser.add_argument("--text-column", default="libelle", help="Text column (default: libelle)") + parser.add_argument( + "--code-column", default="apet2025", help="Label column (default: apet2025)" + ) + parser.add_argument("--output-dir", default="data/eval/results", help="Output directory") + parser.add_argument( + "--bootstrap", + type=int, + default=0, + help="Number of bootstrap resamples for accuracy CIs (0 = off, default)", + ) + parser.add_argument("--seed", type=int, default=42, help="Bootstrap seed (default: 42)") + args = parser.parse_args() + return asyncio.run(run(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/evaluation/verify_train_labels.py b/src/evaluation/verify_train_labels.py new file mode 100644 index 0000000..b6ff134 --- /dev/null +++ b/src/evaluation/verify_train_labels.py @@ -0,0 +1,122 @@ +"""Audit de la qualité des labels du jeu d'entraînement via un LLM vérificateur. + +Tire un échantillon aléatoire du jeu d'entraînement et demande au MatchVerifier +s'il pense que le code associé à chaque libellé est correct. Ne compare pas à +une prédiction de modèle : c'est un contrôle qualité du label de référence +lui-même (cf. src.evaluation.run_eval pour l'évaluation d'un classifieur). + +Nécessite à l'exécution : la base Neo4j et l'API LLM configurées dans +l'environnement (mêmes prérequis que src.main). + +Usage : + uv run -m src.evaluation.verify_train_labels \ + --train-set projet-ape/data/08112022_27102024/naf2025/split/df_train.parquet \ + --n-samples 200 \ + --output data/eval/train_verification +""" + +import argparse +import asyncio +import json +import logging +import os + +import polars as pl + +from src.agents.closers.match_verifier import MatchVerificationInput, MatchVerifier +from src.config import neo4j_config +from src.evaluation.build_eval_set import load_dataframe +from src.neo4j_graph.graph import Graph +from src.utils.logging import configure_logging + +configure_logging() +logger = logging.getLogger(__name__) + +TRAIN_SET_PATH = "projet-ape/data/08112022_27102024/naf2025/split/df_train.parquet" + + +async def verify_rows( + verifier: MatchVerifier, rows: list[dict], text_column: str, code_column: str +) -> list[dict]: + """Verify each row's existing (text, code) label with MatchVerifier. + + No `proposed_explanation`/`proposed_confidence` is passed, so the verifier + treats each code as a raw ground-truth label rather than a model's guess + (cf. the "no explanation provided" prompt branch in MatchVerifier). + """ + results = [] + for i, row in enumerate(rows, 1): + text = row[text_column] + code = row[code_column] + logger.info(f"{i}/{len(rows)}: {text!r} -> {code}") + + verification = await verifier(MatchVerificationInput(activity=text, code=str(code))) + results.append( + { + text_column: text, + code_column: code, + "llm_is_match": verification.is_match, + "llm_confidence": verification.confidence, + "llm_explanation": verification.explanation, + } + ) + return results + + +async def run(args) -> int: + df = load_dataframe(args.train_set) + sample = df.sample(n=min(args.n_samples, len(df)), seed=args.seed) + logger.info(f"Verifying {len(sample)} train labels with MatchVerifier") + + verifier = MatchVerifier(Graph(neo4j_config)) + results = await verify_rows(verifier, sample.to_dicts(), args.text_column, args.code_column) + + details = pl.DataFrame(results) + n = len(details) + n_flagged = int((~details["llm_is_match"]).sum()) if n else 0 + summary = { + "n": n, + "n_flagged": n_flagged, + "agreement_rate": (1 - n_flagged / n) if n else None, + "mean_llm_confidence": float(details["llm_confidence"].mean()) if n else None, + } + + os.makedirs(args.output, exist_ok=True) + details_path = os.path.join(args.output, "train_label_verification.parquet") + details.write_parquet(details_path) + summary_path = os.path.join(args.output, "train_label_verification_summary.json") + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, indent=2, ensure_ascii=False) + + print(json.dumps(summary, indent=2, ensure_ascii=False)) + + flagged = details.filter(~pl.col("llm_is_match")) + if len(flagged): + print("\nFlagged rows (LLM thinks the label may be wrong):") + for r in flagged.to_dicts(): + print(f" {r[args.text_column]!r} -> {r[args.code_column]}: {r['llm_explanation']}") + + logger.info(f"Details written to {details_path}, summary to {summary_path}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify a sample of train-set labels with an LLM") + parser.add_argument( + "--train-set", default=TRAIN_SET_PATH, help="Train parquet (local path or S3 key)" + ) + parser.add_argument("--text-column", default="libelle", help="Text column (default: libelle)") + parser.add_argument( + "--code-column", default="nace2025", help="Label column (default: nace2025)" + ) + parser.add_argument( + "--n-samples", type=int, default=200, help="Number of rows to sample (default: 200)" + ) + parser.add_argument("--seed", type=int, default=42, help="Sampling seed (default: 42)") + parser.add_argument("--output", default="data/eval/train_verification", help="Output directory") + args = parser.parse_args() + return asyncio.run(run(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/main.py b/src/main.py index a7d45c5..e142620 100644 --- a/src/main.py +++ b/src/main.py @@ -1,12 +1,17 @@ import asyncio import logging import sys -from langfuse import get_client, propagate_attributes, observe from datetime import datetime +from langfuse import get_client, observe, propagate_attributes + +from src.agents.closers.match_verifier import MatchVerificationInput, MatchVerifier +from src.agents.Text2Code.classifiers.agentic_rag import AgenticRAGClassifier from src.agents.Text2Code.classifiers.navigator_classifier import NavigatorAgenticClassifier +from src.agents.Text2Code.classifiers.supervised_classifier import SupervisedClassifier from src.config import neo4j_config from src.navigator.navigator import Navigator +from src.neo4j_graph.graph import Graph from src.utils.logging import configure_logging from src.utils.parser import parse_args @@ -14,54 +19,150 @@ logger = logging.getLogger(__name__) -# @observe +@observe async def classify_navigator( - query: str | list[str], - experiment_name: str = "Navigator Classification" + query: str | list[str], experiment_name: str = "Navigator Classification" ): """Classify using agentic method - + Args: query: A single query string or a list of query strings experiment_name: Name of the experiment - + Returns: Single result dict if query is str, list of result dicts if query is list """ + get_client().update_current_trace( + name=experiment_name, tags=[experiment_name], metadata={"experiment_name": experiment_name} + ) + # Normalize input to always work with a list queries = [query] if isinstance(query, str) else query is_single = isinstance(query, str) - + logger.info(f"Navigator classification: {len(queries)} query/queries") - - # TODO: add the management for exp_name + navigator = Navigator(neo4j_config) - + # Built once: tools/Agent are closures over `navigator`, so state resets + # (below) are enough between queries — no need to rebuild them each time. + classifier = NavigatorAgenticClassifier(navigator) + results = [] for q in queries: logger.info(f"Classifying: {q}") - logger.info(f'Current position of the navigator: {navigator.current_code}') - classifier = NavigatorAgenticClassifier(navigator) + logger.info(f"Current position of the navigator: {navigator.current_code}") result = await classifier(q) results.append(result) logger.info(f"Le résultat de la classification est : {result}") navigator.reset_to_root() - + # Return single result or list based on input type return results[0] if is_single else results @observe -async def classify_agentic_rag(query: str, experiment_name: str): - """Classify using flat embeddings""" - logger.info(f"Flat embeddings classification: {query}") +async def classify_agentic_rag( + query: str | list[str], + experiment_name: str = "Agentic RAG Classification", +): + """Classify using embedding retrieval as a Navigator warm-start + + Finds the closest code by vector similarity, then lets the Navigator agent verify + and refine that starting position instead of walking down from the root + (cf. classify_navigator). + + Args: + query: A single query string or a list of query strings + experiment_name: Name of the experiment + + Returns: + Single MatchVerificationInput if query is str, list of them if query is list + """ + get_client().update_current_trace( + name=experiment_name, tags=[experiment_name], metadata={"experiment_name": experiment_name} + ) + + queries = [query] if isinstance(query, str) else query + is_single = isinstance(query, str) + + logger.info(f"Agentic RAG classification: {len(queries)} query/queries") + + navigator = Navigator(neo4j_config) + classifier = AgenticRAGClassifier(navigator) + + results = [] + for q in queries: + logger.info(f"Classifying: {q}") + result = await classifier(q) + results.append(result) + logger.info(f"Le résultat de la classification est : {result}") + + return results[0] if is_single else results + + +@observe +async def classify_supervised( + query: str | list[str], + experiment_name: str = "Supervised Model Classification", +): + """Classify using the production supervised model, via the deployed codif-ape-API + + Serves as the reference baseline for comparing the agentic methods + (cf. cadrage §3.3-B, note de conception). Requires CODIF_APE_API_USERNAME + and CODIF_APE_API_PASSWORD to be set (CODIF_APE_API_URL is optional). + + Args: + query: A single query string or a list of query strings + experiment_name: Name of the experiment + + Returns: + Single MatchVerificationInput if query is str, list of them if query is list + """ + get_client().update_current_trace( + name=experiment_name, tags=[experiment_name], metadata={"experiment_name": experiment_name} + ) - return "10.71C" + queries = [query] if isinstance(query, str) else query + is_single = isinstance(query, str) + + logger.info(f"Supervised model classification: {len(queries)} query/queries") + + classifier = SupervisedClassifier() + + results = [] + for q in queries: + logger.info(f"Classifying: {q}") + result = await classifier(q) + results.append(result) + logger.info(f"Le résultat de la classification est : {result}") + + return results[0] if is_single else results + + +async def verify_classification(prediction: MatchVerificationInput, verifier: MatchVerifier): + """Chain a classifier prediction into the MatchVerifier for double-checking + + Args: + prediction: Output of a classifier (activity, proposed code, explanation, confidence) + verifier: MatchVerifier agent instance + + Returns: + MatchVerificationResult (is_match, confidence, explanation) + """ + verification = await verifier(prediction) + logger.info(f"Le résultat de la vérification est : {verification}") + return verification @observe -async def process_batch_file(filepath: str, method_func, experiment_name: str): +async def process_batch_file( + filepath: str, method_func, experiment_name: str, verifier: MatchVerifier | None = None +): """Process a batch file with queries""" + get_client().update_current_trace( + name=experiment_name, tags=[experiment_name], metadata={"experiment_name": experiment_name} + ) + logger.info(f"Processing batch file: {filepath}") with open(filepath, "r", encoding="utf-8") as f: @@ -73,7 +174,10 @@ async def process_batch_file(filepath: str, method_func, experiment_name: str): for i, query in enumerate(queries, 1): logger.info(f"Processing {i}/{len(queries)}: {query}") result = await method_func(query, experiment_name) - results.append({"query": query, "code": result}) + verification = None + if verifier is not None and isinstance(result, MatchVerificationInput): + verification = await verify_classification(result, verifier) + results.append({"query": query, "result": result, "verification": verification}) return results @@ -95,27 +199,38 @@ async def main(): if args.agentic_rag: methods_to_run.append(("agentic-rag", args.agentic_rag, classify_agentic_rag)) + if args.supervised: + methods_to_run.append(("supervised", args.supervised, classify_supervised)) + # No method specified if not methods_to_run: logger.info("Use --help to see available options") return 1 + verifier = MatchVerifier(Graph(neo4j_config)) if args.verify else None + # Batch file mode if args.batch_file: - logger.error("No classification method specified!") if len(methods_to_run) > 1: logger.warning("Multiple methods specified, using first one for batch") method_name, _, method_func = methods_to_run[0] logger.info(f"Batch mode with method: {method_name}") - results = await process_batch_file(args.batch_file, method_func, args.experiment_name) + results = await process_batch_file( + args.batch_file, method_func, args.experiment_name, verifier=verifier + ) print("\n" + "=" * 80) print("BATCH RESULTS") print("=" * 80) for result in results: - print(f" {result['query']:40s} → {result['code']}") + code = getattr(result["result"], "code", result["result"]) + line = f" {result['query']:40s} → {code}" + if result["verification"] is not None: + status = "✅" if result["verification"].is_match else "❌" + line += f" | verifier: {status} ({result['verification'].confidence:.2f})" + print(line) print("=" * 80) return 0 @@ -131,6 +246,12 @@ async def main(): print(f"\n✅ Result: {result}") + if verifier is not None and isinstance(result, MatchVerificationInput): + verification = await verify_classification(result, verifier) + status = "✅ validé" if verification.is_match else "❌ rejeté" + print(f"🔍 Verification: {status} ({verification.confidence:.2f})") + print(f" {verification.explanation}") + return 0 except KeyboardInterrupt: diff --git a/src/navigator/navigator.py b/src/navigator/navigator.py index cf1c556..467f211 100644 --- a/src/navigator/navigator.py +++ b/src/navigator/navigator.py @@ -1,15 +1,18 @@ import logging from typing import Any, Dict, List, Optional -import json from agents import function_tool -from src.neo4j_graph.graph import Graph, Neo4JConfig, _unfreeze_dict, _unfreeze_list_of_dicts -from src.agents.closers.match_verifier import MatchVerificationInput +from src.neo4j_graph.graph import Graph, Neo4JConfig logger = logging.getLogger(__name__) def make_tools(navigator): + def _children_summary(code: str) -> List[Dict[str, Any]]: + children_found = navigator.get_children(code) + keys_to_keep = ["code", "name", "is_final"] + return [{k: d[k] for k in keys_to_keep} for d in children_found] + # ------------------------------------------------------------------ # Information methods # ------------------------------------------------------------------ @@ -17,18 +20,21 @@ def make_tools(navigator): @function_tool def get_current_information() -> Dict[str, Any]: """ - Retourne les informations du noeud actuel. - Fournis les codes et les noms des noeuds enfants. - Pour avoir l'information détaillée sur un enfant, utilise get_code_information(code) - + Retourne les informations du noeud actuel, y compris les codes et noms de ses + enfants directs. Rappeler cet outil sans avoir changé de position renvoie + exactement le même résultat. Si un enfant correspond déjà à l'activité à + classifier, appelez go_to_child(child_code) directement plutôt que de + ré-appeler cet outil. + Returns: Informations complètes du noeud courant avec historique de navigation """ - logger.info(f"Navigator: get_current_information called at the position: {navigator.current_code}") - + logger.info( + f"Navigator: get_current_information called at the position: {navigator.current_code}" + ) + # Cas spécial pour root if navigator.current_code == "root": - children = get_current_children() result = { "success": True, "code": "root", @@ -39,20 +45,20 @@ def get_current_information() -> Dict[str, Any]: } logger.info(f"get_current_information (root): Data sent to the llm: {result}") return result - + # Cas normal (pas root) - data = _unfreeze_dict(navigator._cached_get_code_information(navigator.current_code)) + data = navigator.get_code_information(navigator.current_code) if not data: logger.warning(f"No data for code: {navigator.current_code}") return { "success": False, "error": f"Code {navigator.current_code} not found", - "current_position": navigator.current_code + "current_position": navigator.current_code, } - + result = { - "success": True, - **data, + "success": True, + **data, "current_position": navigator.current_code, } logger.info(f"get_current_information: Data sent to the llm: {result}") @@ -61,7 +67,11 @@ def get_current_information() -> Dict[str, Any]: @function_tool def get_code_information(code: str) -> Dict[str, Any]: """ - Retourne les informations d'un code spécifique sans changer la position. + Retourne les informations détaillées d'un code spécifique, sans changer la + position. À utiliser en cas d'hésitation entre plusieurs enfants candidats — + ce n'est pas une étape obligatoire : si un enfant correspond déjà clairement + à l'activité d'après get_current_children, appelez directement + go_to_child(child_code) sans consulter cet outil au préalable. Args: code: Code NACE à consulter @@ -71,7 +81,7 @@ def get_code_information(code: str) -> Dict[str, Any]: """ logger.info(f"Navigator: get_code_information called with code {code}") - data = navigator._cached_get_code_information(code) + data = navigator.get_code_information(code) if not data: logger.info(f"No data for the code: {code}") @@ -81,8 +91,6 @@ def get_code_information(code: str) -> Dict[str, Any]: "current_position": navigator.current_code, } - data = _unfreeze_dict(data) - filtered_data = { "success": True, "code": data.get("code"), @@ -95,24 +103,22 @@ def get_code_information(code: str) -> Dict[str, Any]: logger.info(f"get_code_information with code {code}: Data sent to the llm: {filtered_data}") return filtered_data - @function_tool def get_current_children() -> List[Dict[str, Any]]: """ - Retourne les codes et les noms des enfants directs du noeud actuel. - Pour avoir l'information détaillée sur un enfant, utilise get_code_information(code) + Retourne les codes, noms et is_final des enfants directs du noeud actuel. + Dès qu'un enfant correspond à l'activité à classifier, appelez immédiatement + go_to_child(child_code) pour vous y déplacer — ne rappelez pas cet outil sans + avoir changé de position, son résultat sera identique. Returns: Liste des codes enfants du noeud courant, contient le code et son nom """ - logger.info(f"Navigator: get_current_children called at the position {navigator.current_code}") - children_found = _unfreeze_list_of_dicts(navigator._cached_get_children(navigator.current_code)) - keys_to_keep = ["code", "name", "is_final"] - filtered_children_found = [ - {k: d[k] for k in keys_to_keep} - for d in children_found - ] + logger.info( + f"Navigator: get_current_children called at the position {navigator.current_code}" + ) + filtered_children_found = _children_summary(navigator.current_code) logger.info(f"get_current_children: Data sent to the llm {filtered_children_found}") return filtered_children_found @@ -125,7 +131,7 @@ def get_current_siblings() -> List[Dict[str, Any]]: Liste des siblings du noeud courant """ logger.info("Navigator: get_current_siblings called") - result = _unfreeze_list_of_dicts(navigator._cached_get_siblings(navigator.current_code)) + result = navigator.get_siblings(navigator.current_code) logger.info(f"get_current_siblings: Data sent to the llm: {result}") return result @@ -138,8 +144,7 @@ def get_current_parent() -> Optional[Dict[str, Any]]: Dictionnaire du parent ou None si pas de parent """ logger.info("Navigator: get_current_parent called") - data = navigator._cached_get_parent(navigator.current_code) - result = _unfreeze_dict(data) if data else None + result = navigator.get_parent(navigator.current_code) logger.info(f"get_current_parent: Data sent to the llm: {result}") return result @@ -172,7 +177,7 @@ def navigate_to(code: str) -> Dict[str, Any]: navigator.current_code = code navigator.history.append(code) logger.info(f"Navigated to: {code}") - + result = { "success": True, "node": info, @@ -184,13 +189,15 @@ def navigate_to(code: str) -> Dict[str, Any]: @function_tool def go_to_parent() -> Dict[str, Any]: """ - Remonte au parent du noeud actuel. + Remonte au parent du noeud actuel et retourne directement ses enfants + (les frères du noeud de départ) — inutile d'appeler get_current_children + juste après. Returns: - Résultat de la navigation avec informations du parent + Résultat de la navigation avec informations du parent et ses enfants """ logger.info("Navigator: go_to_parent called") - parent_info = _unfreeze_dict(navigator._cached_get_parent(navigator.current_code)) + parent_info = navigator.get_parent(navigator.current_code) if parent_info is None: logger.warning("parent_info is None, go_to_parent failed") return { @@ -203,36 +210,43 @@ def go_to_parent() -> Dict[str, Any]: navigator.current_code = parent_code navigator.history.append(parent_code) logger.info(f"Move up to: {parent_code}") - + parent_info_filtered = { - 'code': parent_info['code'], - 'level': parent_info['level'], - 'name': parent_info['name'], + "code": parent_info["code"], + "level": parent_info["level"], + "name": parent_info["name"], } result = { "success": True, "parent": parent_info_filtered, + "children": _children_summary(parent_code), "current_position": navigator.current_code, } logger.info(f"go_to_parent: data sent to the llm: {result}") return result - + @function_tool def go_to_child(child_code: str) -> Dict[str, Any]: """ - Descend vers un enfant spécifique du noeud actuel. + Descend vers un enfant spécifique du noeud actuel et retourne directement les + enfants de cette nouvelle position — inutile d'appeler get_current_children + juste après. C'est l'action à utiliser dès qu'un enfant pertinent a été + identifié via get_current_children ou get_current_information — appelez-la + directement, sans consultation systématique de get_code_information au + préalable. Si le noeud atteint a is_final = 1 (ou une liste d'enfants vide), + c'est une position terminale : arrêtez la navigation et concluez. Args: child_code: Code de l'enfant vers lequel naviguer Returns: - Résultat de la navigation avec validation + Résultat de la navigation avec validation et les enfants du nouveau noeud """ logger.info(f"Navigator: go_to_child called with child_code: {child_code}") - children = _unfreeze_list_of_dicts(navigator._cached_get_children(navigator.current_code)) + children = navigator.get_children(navigator.current_code) child_codes = [child["code"] for child in children] if child_code not in child_codes: @@ -248,17 +262,20 @@ def go_to_child(child_code: str) -> Dict[str, Any]: new_node_information = { "code": target_info["code"], "level": target_info["level"], - "is_final": target_info['is_final'], - "name": target_info["name"], - } + "is_final": target_info["is_final"], + "name": target_info["name"], + } navigator.current_code = child_code navigator.history.append(child_code) logger.info(f"Move down to: {child_code}") - logger.info(f"Navigator.current_code is {navigator.current_code} and navigator.history is {navigator.history}") + logger.info( + f"Navigator.current_code is {navigator.current_code} and navigator.history is {navigator.history}" + ) result = { "success": True, "node": new_node_information, + "children": _children_summary(child_code), "current_position": navigator.current_code, } logger.info(f"go_to_child: Data sent to the llm: {result}") @@ -296,6 +313,7 @@ def reset_to_root() -> Dict[str, Any]: go_to_child, ] + class Navigator(Graph): """ Classe de navigation dans la hiérarchie NACE avec état persistant. @@ -319,4 +337,12 @@ def get_tools(self): def reset_to_root(self): self.current_code = "root" self.history = ["root"] - logger.info("Navigator: Reset to root") \ No newline at end of file + logger.info("Navigator: Reset to root") + + def is_current_final(self) -> bool: + """Ground-truth check on the current position, read straight from the graph + (never from the model's self-reported completion).""" + if self.current_code == "root": + return False + data = self.get_code_information(self.current_code) + return bool(data.get("is_final")) if data else False diff --git a/src/neo4j_graph/graph.py b/src/neo4j_graph/graph.py index 97b6f46..a34dc31 100644 --- a/src/neo4j_graph/graph.py +++ b/src/neo4j_graph/graph.py @@ -45,8 +45,8 @@ def get_code_information(code: str) -> Dict[str, Any]: Dictionnaire avec code, level, name, description, includes, includes_also, excludes, implementation_rule, parent_code, children_codes, children_count """ - data = graph._cached_get_code_information(code) - return _unfreeze_dict(data) if data else {"error": f"Code {code} not found"} + data = graph.get_code_information(code) + return data if data else {"error": f"Code {code} not found"} @function_tool def get_children(code: str) -> List[Dict[str, Any]]: @@ -59,7 +59,7 @@ def get_children(code: str) -> List[Dict[str, Any]]: Returns: Liste des codes enfants avec code, level, name, description, includes, excludes """ - return _unfreeze_list_of_dicts(graph._cached_get_children(code)) + return graph.get_children(code) @function_tool def get_descendants(code: str, levels: int = 2) -> List[Dict[str, Any]]: @@ -73,7 +73,7 @@ def get_descendants(code: str, levels: int = 2) -> List[Dict[str, Any]]: Returns: Liste de tous les descendants jusqu'au niveau spécifié """ - return _unfreeze_list_of_dicts(graph._cached_get_descendants(code, levels)) + return graph.get_descendants(code, levels) @function_tool def get_siblings(code: str) -> List[Dict[str, Any]]: @@ -86,7 +86,7 @@ def get_siblings(code: str) -> List[Dict[str, Any]]: Returns: Liste des codes siblings (excluant le code d'origine) """ - return _unfreeze_list_of_dicts(graph._cached_get_siblings(code)) + return graph.get_siblings(code) @function_tool def get_parent(code: str) -> Optional[Dict[str, Any]]: @@ -99,8 +99,7 @@ def get_parent(code: str) -> Optional[Dict[str, Any]]: Returns: Dictionnaire du parent ou None si pas de parent """ - data = graph._cached_get_parent(code) - return _unfreeze_dict(data) if data else None + return graph.get_parent(code) return [get_code_information, get_children, get_descendants, get_siblings, get_parent] @@ -120,26 +119,23 @@ def __init__(self, neo4j_config: Neo4JConfig) -> None: enhanced_schema=True, ) - try: - self.emb_model = OpenAIEmbeddings( - model=os.environ["EMBEDDING_MODEL"], - openai_api_base=os.environ["URL_EMBEDDING_API"], - openai_api_key=os.environ["OPENAI_API_KEY"], - ) - - self.db = Neo4jVector.from_existing_graph( - graph=self.graph, - embedding=self.emb_model, - index_name="id", - node_label="Chunk", - text_node_properties=["text"], - keyword_index_name="text", - embedding_node_property="embedding", - search_type="vector", - ) - - except Exception as e: - logger.info(f"Not able to connect to the embedder: {e}") + self.emb_model = OpenAIEmbeddings( + model=os.environ["EMBEDDING_MODEL"], + openai_api_base=os.environ["URL_EMBEDDING_API"], + openai_api_key=os.environ["OPENAI_API_KEY"], + check_embedding_ctx_length=False, + ) + + self.db = Neo4jVector.from_existing_graph( + graph=self.graph, + embedding=self.emb_model, + index_name="id", + node_label="Chunk", + text_node_properties=["text"], + keyword_index_name="text", + embedding_node_property="embedding", + search_type="vector", + ) # ------------------------------------------------------------------ # Get tools @@ -154,6 +150,46 @@ def get_tools(self): """ return make_tools(self) + def _with_dotted_retry(self, code: str, fetch): + """Call `fetch(code)`, retrying once on a dotted variant if it comes back empty. + + Les noeuds du graphe utilisent un code pointé (ex: "85.51Y"), alors que les + jeux de données bruts type NAF et l'API codif-ape en donnent la forme non + pointée (ex: "8551Y"). Si le code tel quel ne matche aucun noeud, on retente + avec un point inséré après les 2 premiers caractères. Partagé par tous les + lookups par code (get_code_information, get_children, get_descendants, + get_siblings, get_parent) plutôt que dupliqué dans chacun. + """ + data = fetch(code) + if not data and "." not in code and len(code) > 2: + data = fetch(f"{code[:2]}.{code[2:]}") + return data + + def get_code_information(self, code: str) -> Dict[str, Any]: + """Retourne les informations d'un code (nom, description, parent, enfants).""" + data = self._with_dotted_retry(code, self._cached_get_code_information) + return _unfreeze_dict(data) if data else {} + + def get_children(self, code: str) -> List[Dict[str, Any]]: + """Retourne les enfants directs d'un code (niveau N+1).""" + data = self._with_dotted_retry(code, self._cached_get_children) + return _unfreeze_list_of_dicts(data) if data else [] + + def get_descendants(self, code: str, levels: int = 2) -> List[Dict[str, Any]]: + """Retourne les descendants d'un code jusqu'à N niveaux de profondeur.""" + data = self._with_dotted_retry(code, lambda c: self._cached_get_descendants(c, levels)) + return _unfreeze_list_of_dicts(data) if data else [] + + def get_siblings(self, code: str) -> List[Dict[str, Any]]: + """Retourne les codes au même niveau hiérarchique (même parent).""" + data = self._with_dotted_retry(code, self._cached_get_siblings) + return _unfreeze_list_of_dicts(data) if data else [] + + def get_parent(self, code: str) -> Optional[Dict[str, Any]]: + """Retourne le parent direct d'un code (niveau N-1), ou None si pas de parent.""" + data = self._with_dotted_retry(code, self._cached_get_parent) + return _unfreeze_dict(data) if data else None + async def get_closest_codes(self, activity: str, top_k: int = 5) -> List[str]: retrieval = await self.db.asimilarity_search( f"query : {activity}", k=top_k, filter={"FINAL": 1} @@ -285,7 +321,7 @@ def _cached_get_parent(self, code: str) -> Tuple[Tuple[str, Any], ...]: return _freeze_dict(result[0]) # ------------------------------------------------------------------ - # search_codes + # search_codes # ------------------------------------------------------------------ @lru_cache(maxsize=0) @@ -296,7 +332,7 @@ def _cached_search_codes(self, search_term: str) -> Tuple[Tuple[Tuple[str, Any], OR toLower(node.text) CONTAINS toLower($search_term) RETURN node.CODE as code, node.LEVEL as level, - node.FINAL as is_final, + node.FINAL as is_final, node.NAME as name, node.text as description ORDER BY node.LEVEL, node.CODE diff --git a/src/utils/parser.py b/src/utils/parser.py index d2c4000..28c1987 100644 --- a/src/utils/parser.py +++ b/src/utils/parser.py @@ -22,11 +22,32 @@ def parse_args() -> argparse.Namespace: const="Boulangerie", default=None, metavar="QUERY", - help="Classify with flat embeddings method. Default query: 'Boulangerie'", + help=( + "Classify with embedding retrieval as a Navigator warm-start. " + "Default query: 'Boulangerie'" + ), + ) + methods.add_argument( + "--supervised", + type=str, + nargs="?", + const="Boulangerie", + default=None, + metavar="QUERY", + help=( + "Classify with the production supervised model (torchTextClassifiers via MLflow). " + "Default query: 'Boulangerie'" + ), ) options = parser.add_argument_group("Options") + options.add_argument( + "--verify", + action="store_true", + help="Chain the classification into the MatchVerifier agent for double-checking", + ) + options.add_argument( "--experiment-name", type=str, diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..d7a7fe5 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,73 @@ +from src.evaluation.bootstrap import bootstrap_ci +from src.evaluation.metrics import accuracy_at_depth + + +def _accuracy_metric(y_true, y_pred, weights): + return accuracy_at_depth(y_true, y_pred, weights=weights) + + +def _synthetic_dataset(): + # Two strata of 4 rows each: stratum "A" is 3/4 correct, stratum "B" is 1/4 correct. + y_true = ["10.71C", "10.71C", "10.71C", "10.71C", "62.01Z", "62.01Z", "62.01Z", "62.01Z"] + y_pred = ["10.71C", "10.71C", "10.71C", "99.99Z", "62.01Z", "99.99Z", "99.99Z", "99.99Z"] + strata = ["A", "A", "A", "A", "B", "B", "B", "B"] + return y_true, y_pred, strata + + +def test_point_estimate_matches_accuracy_at_depth(): + y_true, y_pred, strata = _synthetic_dataset() + point, _ = bootstrap_ci(y_true, y_pred, strata, _accuracy_metric, n_resamples=200) + assert point == accuracy_at_depth(y_true, y_pred) + + +def test_ci_bounds_are_in_unit_interval_and_contain_point_estimate(): + y_true, y_pred, strata = _synthetic_dataset() + point, (lo, hi) = bootstrap_ci(y_true, y_pred, strata, _accuracy_metric, n_resamples=500) + assert 0.0 <= lo <= hi <= 1.0 + # The point estimate need not sit exactly inside a percentile CI in general, + # but for a symmetric, well-behaved statistic like accuracy here it should. + assert lo <= point <= hi + + +def test_reproducible_for_fixed_seed(): + y_true, y_pred, strata = _synthetic_dataset() + result_a = bootstrap_ci(y_true, y_pred, strata, _accuracy_metric, n_resamples=300, seed=7) + result_b = bootstrap_ci(y_true, y_pred, strata, _accuracy_metric, n_resamples=300, seed=7) + assert result_a == result_b + + +def test_resampling_never_mixes_rows_across_strata(): + # Unequal stratum sizes (4 vs 6) with per-row unique, stratum-tagged codes: + # a resample that leaked rows across strata would break the exact 4/6 count. + y_true = [f"A{i}" for i in range(4)] + [f"B{i}" for i in range(6)] + y_pred = list(y_true) + strata = ["A"] * 4 + ["B"] * 6 + + def recording_metric(t, p, w): + n_a = sum(1 for code in t if code.startswith("A")) + n_b = sum(1 for code in t if code.startswith("B")) + assert (n_a, n_b) == (4, 6) + return accuracy_at_depth(t, p, weights=w) + + bootstrap_ci(y_true, y_pred, strata, recording_metric, n_resamples=50, seed=1) + + +def test_weights_are_resampled_alongside_rows(): + y_true, y_pred, strata = _synthetic_dataset() + weights = [1, 1, 1, 1, 5, 5, 5, 5] + point_unweighted, _ = bootstrap_ci(y_true, y_pred, strata, _accuracy_metric, n_resamples=200) + point_weighted, _ = bootstrap_ci( + y_true, y_pred, strata, _accuracy_metric, n_resamples=200, weights=weights + ) + # Stratum B (weight 5, only 1/4 correct) dominates the weighted estimate, + # so the weighted accuracy should be markedly lower than the unweighted one. + assert point_weighted < point_unweighted + + +def test_raises_on_length_mismatch(): + try: + bootstrap_ci(["a", "b"], ["a"], ["s", "s"], _accuracy_metric) + except ValueError: + pass + else: + raise AssertionError("expected ValueError") diff --git a/tests/test_build_eval_set.py b/tests/test_build_eval_set.py new file mode 100644 index 0000000..cf1a9e7 --- /dev/null +++ b/tests/test_build_eval_set.py @@ -0,0 +1,44 @@ +import polars as pl + +from src.evaluation.build_eval_set import stratified_sample + + +def test_stratum_smaller_than_cap_is_kept_whole_with_weight_one(): + # Stratum "1071C" has only 2 rows, well under n_per_stratum=10. + df = pl.DataFrame({"code": ["10.71C", "10.71C", "62.01Z", "62.01Z", "62.01Z"]}) + sampled = stratified_sample(df, code_column="code", n_per_stratum=10, stratum_depth=5) + + small_stratum = sampled.filter(pl.col("eval_stratum") == "1071C") + assert len(small_stratum) == 2 + assert small_stratum["ipw_weight"].to_list() == [1.0, 1.0] + + +def test_capped_stratum_gets_inverse_probability_weight(): + # Stratum "6201Z" has 8 rows, capped to 4: weight should be 8/4 = 2. + df = pl.DataFrame({"code": ["62.01Z"] * 8 + ["10.71C"] * 2}) + sampled = stratified_sample(df, code_column="code", n_per_stratum=4, stratum_depth=5) + + capped_stratum = sampled.filter(pl.col("eval_stratum") == "6201Z") + assert len(capped_stratum) == 4 + assert capped_stratum["ipw_weight"].to_list() == [2.0, 2.0, 2.0, 2.0] + + whole_stratum = sampled.filter(pl.col("eval_stratum") == "1071C") + assert len(whole_stratum) == 2 + assert whole_stratum["ipw_weight"].to_list() == [1.0, 1.0] + + +def test_helper_columns_are_not_leaked(): + df = pl.DataFrame({"code": ["10.71C", "62.01Z"]}) + sampled = stratified_sample(df, code_column="code", n_per_stratum=10, stratum_depth=5) + assert "_norm_code" not in sampled.columns + assert "_population_count" not in sampled.columns + assert "_sampled_count" not in sampled.columns + assert "eval_stratum" in sampled.columns + assert "ipw_weight" in sampled.columns + + +def test_reproducible_for_fixed_seed(): + df = pl.DataFrame({"code": ["62.01Z"] * 10, "id": list(range(10))}) + sampled_a = stratified_sample(df, code_column="code", n_per_stratum=4, stratum_depth=5, seed=1) + sampled_b = stratified_sample(df, code_column="code", n_per_stratum=4, stratum_depth=5, seed=1) + assert sampled_a["id"].to_list() == sampled_b["id"].to_list() diff --git a/tests/test_compare.py b/tests/test_compare.py new file mode 100644 index 0000000..e2c0599 --- /dev/null +++ b/tests/test_compare.py @@ -0,0 +1,77 @@ +import polars as pl + +from src.evaluation.compare import compare, mcnemar_test, paired_bootstrap_diff + + +def _make_df(y_true, y_pred, strata): + return pl.DataFrame({"apet2025": y_true, "prediction": y_pred, "eval_stratum": strata}) + + +def test_mcnemar_no_difference_when_predictions_identical(): + y_true = ["10.71C", "62.01Z", "01.11Z", "43.99C"] * 5 + y_pred = ["10.71C", "62.02A", "01.11Z", "99.99Z"] * 5 + p_value = mcnemar_test(y_true, y_pred, y_pred) + assert p_value == 1.0 + + +def test_mcnemar_detects_clear_difference(): + # Method A always wrong, method B always right on 20 discordant rows. + y_true = [f"{i:02d}.00Z" for i in range(20)] + y_pred_a = ["99.99Z"] * 20 + y_pred_b = list(y_true) + p_value = mcnemar_test(y_true, y_pred_a, y_pred_b) + assert p_value < 0.05 + + +def test_paired_bootstrap_diff_matches_manual_point_estimate(): + y_true = ["10.71C", "10.71C", "62.01Z", "62.01Z"] + y_pred_a = ["10.71C", "99.99Z", "62.01Z", "99.99Z"] # 2/4 correct + y_pred_b = ["10.71C", "10.71C", "62.01Z", "62.01Z"] # 4/4 correct + strata = ["A", "A", "B", "B"] + point_diff, (lo, hi) = paired_bootstrap_diff( + y_true, y_pred_a, y_pred_b, strata, n_resamples=300, seed=1 + ) + assert point_diff == 4 / 4 - 2 / 4 + assert lo <= point_diff <= hi + + +def test_compare_flags_significant_difference(): + n = 20 + y_true = [f"{i:02d}.00Z" for i in range(n)] + y_pred_a = ["99.99Z"] * n # always wrong + y_pred_b = list(y_true) # always right + strata = ["S"] * n + df_a = _make_df(y_true, y_pred_a, strata) + df_b = _make_df(y_true, y_pred_b, strata) + + report = compare(df_a, df_b, n_resamples=300, seed=1) + assert report["n"] == n + assert report["accuracy_a"] == 0.0 + assert report["accuracy_b"] == 1.0 + assert report["diff"] == 1.0 + assert report["diff_ci"][0] > 0 + assert report["mcnemar_p_value"] < 0.05 + assert report["significant"] is True + + +def test_compare_no_significant_difference_when_predictions_identical(): + n = 20 + y_true = [f"{i:02d}.00Z" for i in range(n)] + strata = ["S"] * n + df_a = _make_df(y_true, y_true, strata) + df_b = _make_df(y_true, y_true, strata) + + report = compare(df_a, df_b, n_resamples=300, seed=1) + assert report["diff"] == 0.0 + assert report["significant"] is False + + +def test_compare_raises_on_misaligned_ground_truth(): + df_a = _make_df(["10.71C", "62.01Z"], ["10.71C", "62.01Z"], ["A", "B"]) + df_b = _make_df(["10.71C", "01.11Z"], ["10.71C", "01.11Z"], ["A", "B"]) + try: + compare(df_a, df_b) + except ValueError: + pass + else: + raise AssertionError("expected ValueError") diff --git a/tests/test_connections.py b/tests/test_connections.py new file mode 100644 index 0000000..acd33ed --- /dev/null +++ b/tests/test_connections.py @@ -0,0 +1,116 @@ +"""Connectivity smoke tests for the external services the project depends on. + +Each test performs a minimal, real network call against a service (Neo4j, +the generation LLM API, the embedding API, S3, Langfuse) using the environment +variables documented in docs/framework.md. A test is skipped automatically +when its required variables aren't set, so the suite stays green on a +fresh clone or in CI without Datalab/Onyxia secrets, while still catching +misconfigured endpoints/credentials when they are. + +Run explicitly with: + uv run pytest tests/test_connections.py -v +""" + +import os + +import pytest +from dotenv import load_dotenv + +load_dotenv(override=True) + + +def _require_env(*names): + missing = [name for name in names if not os.environ.get(name)] + return pytest.mark.skipif( + bool(missing), + reason=f"missing env var(s): {', '.join(missing)}", + ) + + +@_require_env("NEO4J_URL", "NEO4J_USERNAME", "NEO4J_PWD") +def test_neo4j_connection(): + from neo4j import GraphDatabase + + driver = GraphDatabase.driver( + os.environ["NEO4J_URL"], + auth=(os.environ["NEO4J_USERNAME"], os.environ["NEO4J_PWD"]), + ) + try: + driver.verify_connectivity() + finally: + driver.close() + + +@_require_env("OPENAI_BASE_URL", "OPENAI_API_KEY") +def test_generation_model_api_connection(): + from openai import OpenAI + + client = OpenAI(base_url=os.environ["OPENAI_BASE_URL"], api_key=os.environ["OPENAI_API_KEY"]) + assert list(client.models.list()) + + +@_require_env("URL_EMBEDDING_API", "EMBEDDING_MODEL") +def test_embedding_api_connection(): + from openai import OpenAI + + client = OpenAI( + base_url=os.environ.get("URL_EMBEDDING_API"), + api_key=os.environ.get("OPENAI_API_KEY", "EMPTY"), + ) + assert list(client.models.list()) + + +_S3_PROBE_PATH = "projet-ape/notices/Notices-NAF2025-FR.parquet" + + +@_require_env("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY") +def test_s3_connection(): + import s3fs + + fs = s3fs.S3FileSystem( + client_kwargs={"endpoint_url": os.environ.get("AWS_ENDPOINT_URL")}, + key=os.environ["AWS_ACCESS_KEY_ID"], + secret=os.environ["AWS_SECRET_ACCESS_KEY"], + token=os.environ.get("AWS_SESSION_TOKEN"), + # Fail fast rather than hang: this endpoint has been observed to time + # out (instead of erroring) on requests it won't serve. + config_kwargs={"connect_timeout": 5, "read_timeout": 8, "retries": {"max_attempts": 1}}, + ) + assert fs.exists(_S3_PROBE_PATH) + + +@_require_env("LLM_URL", "LLM_API_KEY") +def test_naive_code2text_llm_connection(): + """NaiveCode2Text uses its own LLM_URL/LLM_API_KEY pair (see naive_code2text.py).""" + from openai import OpenAI + + client = OpenAI(base_url=os.environ["LLM_URL"], api_key=os.environ["LLM_API_KEY"]) + assert list(client.models.list()) + + +@_require_env("LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY") +def test_langfuse_connection(): + """Langfuse backs the application tracing used in src/main.py.""" + from langfuse import Langfuse + + client = Langfuse( + public_key=os.environ["LANGFUSE_PUBLIC_KEY"], + secret_key=os.environ["LANGFUSE_SECRET_KEY"], + base_url=os.environ.get("LANGFUSE_BASE_URL"), + ) + assert client.auth_check() + + +@_require_env("MLFLOW_TRACKING_URI", "MLFLOW_MODEL_URI") +def test_mlflow_supervised_model_connection(): + """Backs SupervisedClassifier (src/agents/Text2Code/classifiers/supervised_classifier.py). + + Only checks that the model can be *loaded*, not that a real prediction + matches the expected output shape — `_parse_prediction` there is + unverified against the real model and may need adjusting after this + passes. + """ + import mlflow + + mlflow.set_tracking_uri(os.environ["MLFLOW_TRACKING_URI"]) + assert mlflow.pyfunc.load_model(os.environ["MLFLOW_MODEL_URI"]) is not None diff --git a/tests/test_drift.py b/tests/test_drift.py new file mode 100644 index 0000000..b7fe1dc --- /dev/null +++ b/tests/test_drift.py @@ -0,0 +1,114 @@ +import numpy as np + +from src.evaluation.drift import ( + calibrate_threshold, + drift_report, + ks_drift, + psi, + psi_categorical, + rolling_drift_reports, + wasserstein_drift, +) + + +def _confidence_scores(n, low, high, seed): + rng = np.random.default_rng(seed) + return rng.uniform(low, high, size=n) + + +def test_wasserstein_zero_for_identical_distributions(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + current = _confidence_scores(500, 0.6, 1.0, seed=2) + # Same distribution, different draws: should be small, not exactly 0. + assert wasserstein_drift(reference, reference) == 0.0 + assert wasserstein_drift(reference, current) < 0.05 + + +def test_wasserstein_detects_shift(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + shifted = _confidence_scores(500, 0.1, 0.5, seed=2) + assert wasserstein_drift(reference, shifted) > 0.3 + + +def test_ks_drift_no_shift_high_pvalue(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + current = _confidence_scores(500, 0.6, 1.0, seed=2) + result = ks_drift(reference, current) + assert result["p_value"] > 0.05 + + +def test_ks_drift_detects_shift(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + shifted = _confidence_scores(500, 0.1, 0.5, seed=2) + result = ks_drift(reference, shifted) + assert result["p_value"] < 0.01 + + +def test_psi_no_shift_is_small(): + reference = _confidence_scores(1000, 0.6, 1.0, seed=1) + current = _confidence_scores(1000, 0.6, 1.0, seed=2) + assert psi(reference, current) < 0.1 # seuil usuel de stabilité + + +def test_psi_detects_shift(): + reference = _confidence_scores(1000, 0.6, 1.0, seed=1) + shifted = _confidence_scores(1000, 0.1, 0.5, seed=2) + assert psi(reference, shifted) > 0.25 # seuil usuel de dérive significative + + +def test_psi_categorical_identical_is_zero(): + labels = ["10.71C"] * 50 + ["62.01Z"] * 30 + ["43.99C"] * 20 + assert psi_categorical(labels, labels) == 0.0 + + +def test_psi_categorical_detects_shift(): + reference = ["10.71C"] * 50 + ["62.01Z"] * 30 + ["43.99C"] * 20 + # Same categories, frequencies reversed + shifted = ["10.71C"] * 20 + ["62.01Z"] * 30 + ["43.99C"] * 50 + assert psi_categorical(reference, shifted) > 0.25 + + +def test_psi_categorical_handles_unseen_category(): + reference = ["10.71C"] * 50 + ["62.01Z"] * 50 + current = ["10.71C"] * 50 + ["01.11Z"] * 50 # 62.01Z disappeared, 01.11Z appeared + assert psi_categorical(reference, current) > 0 + + +def test_calibrate_threshold_is_positive_and_reproducible(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + threshold_a = calibrate_threshold(reference, wasserstein_drift, seed=42, n_resamples=100) + threshold_b = calibrate_threshold(reference, wasserstein_drift, seed=42, n_resamples=100) + assert threshold_a == threshold_b + assert threshold_a > 0 + + +def test_drift_report_no_drift(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + current = _confidence_scores(500, 0.6, 1.0, seed=2) + report = drift_report(reference, current, n_resamples=100) + assert report["any_drift"] is False + assert report["ks"]["is_drift"] is False + + +def test_drift_report_flags_injected_drift(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + shifted = _confidence_scores(500, 0.1, 0.5, seed=2) + report = drift_report(reference, shifted, n_resamples=100) + assert report["any_drift"] is True + assert report["wasserstein"]["is_drift"] is True + assert report["ks"]["is_drift"] is True + assert report["psi"]["is_drift"] is True + + +def test_rolling_drift_reports_tracks_progressive_shift(): + reference = _confidence_scores(500, 0.6, 1.0, seed=1) + windows = [ + _confidence_scores(200, 0.6, 1.0, seed=10), # pas de dérive + _confidence_scores(200, 0.6, 1.0, seed=11), # pas de dérive + _confidence_scores(200, 0.1, 0.5, seed=12), # dérive injectée + ] + reports = rolling_drift_reports(reference, windows, n_resamples=100) + assert len(reports) == 3 + assert reports[0]["any_drift"] is False + assert reports[1]["any_drift"] is False + assert reports[2]["any_drift"] is True diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..f49702e --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,120 @@ +import math + +from src.evaluation.metrics import ( + accuracy_at_depth, + evaluate, + failure_rate, + low_confidence_rate, + normalize_code, +) + + +def test_normalize_code(): + assert normalize_code("10.71C") == "1071C" + assert normalize_code(" 10 71 c ") == "1071C" + assert normalize_code(None) is None + assert normalize_code("") is None + + +def test_leaf_accuracy(): + y_true = ["10.71C", "62.01Z", "01.11Z"] + y_pred = ["1071C", "62.02A", "01.11Z"] + assert accuracy_at_depth(y_true, y_pred) == 2 / 3 + + +def test_depth_accuracy(): + y_true = ["10.71C", "62.01Z"] + y_pred = ["10.72B", "43.99C"] + # Division (depth 2): 10 == 10 mais 62 != 43 + assert accuracy_at_depth(y_true, y_pred, depth=2) == 1 / 2 + # Classe (depth 4): 1071 != 1072 + assert accuracy_at_depth(y_true, y_pred, depth=4) == 0.0 + + +def test_missing_prediction_counts_as_error(): + assert accuracy_at_depth(["10.71C"], [None]) == 0.0 + assert accuracy_at_depth(["10.71C"], [None], depth=2) == 0.0 + + +def test_missing_ground_truth_is_skipped(): + assert accuracy_at_depth([None, "10.71C"], ["10.71C", "10.71C"]) == 1.0 + + +def test_length_mismatch_raises(): + try: + accuracy_at_depth(["a"], []) + except ValueError: + pass + else: + raise AssertionError("expected ValueError") + + +def test_failure_rate(): + assert failure_rate(["10.71C", None, "", "62.01Z"]) == 2 / 4 + assert math.isnan(failure_rate([])) + + +def test_evaluate_report(): + y_true = ["10.71C", "62.01Z", "01.11Z", "43.99C"] + y_pred = ["10.71C", "62.02A", None, "43.99C"] + report = evaluate(y_true, y_pred) + assert report["n"] == 4 + assert report["leaf_accuracy"] == 2 / 4 + assert report["failure_rate"] == 1 / 4 + assert report["accuracy_depth_2"] == 3 / 4 + assert set(report) == { + "n", + "leaf_accuracy", + "failure_rate", + "accuracy_depth_2", + "accuracy_depth_3", + "accuracy_depth_4", + } + + +def test_weighted_accuracy_matches_manual_computation(): + y_true = ["10.71C", "62.01Z", "01.11Z"] + y_pred = ["1071C", "62.02A", "01.11Z"] # correct, wrong, correct + weights = [1, 2, 1] + # correct weight = 1 (row 0) + 1 (row 2) = 2 ; total weight = 1 + 2 + 1 = 4 + assert accuracy_at_depth(y_true, y_pred, weights=weights) == 2 / 4 + # unweighted accuracy on the same data is unaffected (2/3, not 2/4) + assert accuracy_at_depth(y_true, y_pred) == 2 / 3 + + +def test_weighted_accuracy_length_mismatch_raises(): + try: + accuracy_at_depth(["a"], ["a"], weights=[1, 2]) + except ValueError: + pass + else: + raise AssertionError("expected ValueError") + + +def test_evaluate_includes_weighted_keys_when_weights_given(): + y_true = ["10.71C", "62.01Z", "01.11Z"] + y_pred = ["1071C", "62.02A", "01.11Z"] + weights = [1, 2, 1] + report = evaluate(y_true, y_pred, weights=weights) + assert report["leaf_accuracy"] == 2 / 3 + assert report["leaf_accuracy_weighted"] == 2 / 4 + assert "accuracy_depth_2_weighted" in report + + +def test_evaluate_omits_weighted_and_confidence_keys_by_default(): + report = evaluate(["10.71C"], ["10.71C"]) + assert "leaf_accuracy_weighted" not in report + assert "low_confidence_rate" not in report + + +def test_low_confidence_rate(): + assert low_confidence_rate([0.0, 0.9, None, 0.0]) == 2 / 4 + assert math.isnan(low_confidence_rate([])) + + +def test_evaluate_includes_low_confidence_rate_when_given(): + y_true = ["10.71C", "62.01Z"] + y_pred = ["10.71C", "62.01Z"] + confidences = [0.0, 0.9] + report = evaluate(y_true, y_pred, confidences=confidences) + assert report["low_confidence_rate"] == 0.5 diff --git a/uv.lock b/uv.lock index 90c7266..acb4b81 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/4c/d4/6585f3b6fdb75648bca294664af4becc8aa2fb3fb08f4e4e9fd27e10d773/adjusttext-1.3.0.tar.gz", hash = "sha256:4ab75cd4453af4828876ac3e964f2c49be642ea834f0c1f7449558d5f12cbca1", size = 15724, upload-time = "2024-10-31T16:45:36.101Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/53/1c/8feedd607cc14c5df9aef74fe3af9a99bf660743b842a9b5b1865326b4aa/adjustText-1.3.0-py3-none-any.whl", hash = "sha256:da23d7b24b6db5ffa039bb136bfa556207365e32f48ac74b07ad26dd485bc691", size = 13154, upload-time = "2024-10-31T16:45:35.227Z" }, + { url = "https://files.pythonhosted.org/packages/2d/80/7ad35ee5321a86b842f9e8516c8ae4c86f58db7b40e82ce9759f94517a50/adjusttext-1.3.0-py3-none-any.whl", hash = "sha256:bc6c118cd9d7caf6ae37f9355e51d840a2d7f64b4fb2956b8401de27c5af803b", size = 13264, upload-time = "2026-06-08T16:40:05.041Z" }, ] [[package]] @@ -132,6 +133,29 @@ 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.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -195,6 +219,15 @@ 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 = "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.41.5" @@ -209,6 +242,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/4e/21cd0b8f365449f1576f93de1ec8718ed18a7a3bc086dfbdeb79437bba7a/botocore-1.41.5-py3-none-any.whl", hash = "sha256:3fef7fcda30c82c27202d232cfdbd6782cb27f20f8e7e21b20606483e66ee73a", size = 14337008, upload-time = "2025-11-26T20:27:35.208Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -325,6 +367,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -530,6 +581,21 @@ 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.120.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "protobuf" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/49/d6c19f037d8543831fbcfdac48c62bd741b25bca2fd36e9d03aa21e6b9e8/databricks_sdk-0.120.0.tar.gz", hash = "sha256:12c41492e5d6e7105cb06e6d1a0f1bdf11b76b0630f3a7da85aa8db4fe91dee8", size = 1009418, upload-time = "2026-07-02T14:55:50.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/54/83fae982cdf334cb06919ada810ca98c5012f23d1e36c4574a01c0bb963e/databricks_sdk-0.120.0-py3-none-any.whl", hash = "sha256:2d8e19444df30f714123b330fb616d7775f96631f6e36b4dd252adb4f2e010e2", size = 954576, upload-time = "2026-07-02T14:55:48.546Z" }, +] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -601,6 +667,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[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 = "duckdb" version = "1.4.4" @@ -632,6 +712,22 @@ 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" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + [[package]] name = "fastjsonschema" version = "2.21.2" @@ -689,6 +785,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, ] +[[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.61.1" @@ -804,6 +930,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/86/5486b0188d08aa643e127774a99bac51ffa6cf343e3deb0583956dca5b22/fsspec-2024.12.0-py3-none-any.whl", hash = "sha256:b520aed47ad9804237ff878b504267a3b0b441e97508bd6d2d8774e3db85cee2", size = 183862, upload-time = "2024-12-19T19:57:28.258Z" }, ] +[[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.55.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.72.0" @@ -823,6 +986,7 @@ source = { virtual = "." } dependencies = [ { name = "duckdb" }, { name = "fastparquet" }, + { name = "flask" }, { name = "ipykernel" }, { name = "ipywidgets" }, { name = "jupyter-cache" }, @@ -833,6 +997,7 @@ dependencies = [ { name = "langchain-text-splitters" }, { name = "langfuse" }, { name = "matplotlib" }, + { name = "mlflow" }, { name = "nbclient" }, { name = "nbformat" }, { name = "networkx" }, @@ -843,13 +1008,16 @@ dependencies = [ { name = "plotly" }, { name = "polars" }, { name = "pyarrow" }, + { name = "pytest" }, { name = "s3fs" }, + { name = "scipy" }, { name = "transformers" }, { name = "umap-learn" }, ] [package.dev-dependencies] dev = [ + { name = "jupytext" }, { name = "pre-commit" }, { name = "ruff" }, ] @@ -858,6 +1026,7 @@ dev = [ requires-dist = [ { name = "duckdb", specifier = ">=1.4.4" }, { name = "fastparquet", specifier = ">=2025.12.0" }, + { name = "flask", specifier = ">=3.1.3" }, { name = "ipykernel", specifier = ">=7.1.0" }, { name = "ipywidgets", specifier = ">=8.1.8" }, { name = "jupyter-cache", specifier = ">=1.0.1" }, @@ -868,6 +1037,7 @@ requires-dist = [ { name = "langchain-text-splitters", specifier = ">=1.1.0" }, { name = "langfuse", specifier = ">=3.11.2" }, { name = "matplotlib", specifier = ">=3.10.8" }, + { name = "mlflow", specifier = ">=2.19.0" }, { name = "nbclient", specifier = ">=0.10.4" }, { name = "nbformat", specifier = ">=5.10.4" }, { name = "networkx", specifier = ">=3.6.1" }, @@ -878,17 +1048,56 @@ requires-dist = [ { name = "plotly", specifier = ">=6.5.1" }, { name = "polars", specifier = ">=1.38.1" }, { name = "pyarrow", specifier = ">=23.0.1" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "s3fs", specifier = ">=2024.12.0" }, + { name = "scipy", specifier = ">=1.14.0" }, { name = "transformers", specifier = ">=4.57.3" }, { name = "umap-learn", specifier = ">=0.5.11" }, ] [package.metadata.requires-dev] dev = [ + { name = "jupytext", specifier = ">=1.19.4" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "ruff", specifier = ">=0.14.9" }, ] +[[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.3.0" @@ -929,6 +1138,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, ] +[[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" @@ -1004,6 +1225,15 @@ 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.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/77/6ede51ab59bc7b35110e5817eeb47db03d0e5b4b716d6cff81a8cb379a3b/huey-3.1.1.tar.gz", hash = "sha256:156f30e90f0fae81ae2e004f2062e1b18ec9dcbc684564df63159ef546b13502", size = 281531, upload-time = "2026-07-02T22:10:46.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/3a/ee216ca4e7e4fa5bf1dbe7dd502f23981d02252d277d8ee74ea0afa8937f/huey-3.1.1-py3-none-any.whl", hash = "sha256:8773231c7bc7a40b9b9191d72cf1c6580bbf6742a4118c70dde6b5c2e7ccb7ce", size = 99335, upload-time = "2026-07-02T22:10:44.805Z" }, +] + [[package]] name = "huggingface-hub" version = "0.36.0" @@ -1053,6 +1283,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "ipykernel" version = "7.1.0" @@ -1126,6 +1365,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, ] +[[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 = "jedi" version = "0.19.2" @@ -1138,6 +1386,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.12.0" @@ -1321,6 +1581,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, ] +[[package]] +name = "jupytext" +version = "1.19.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/52/e014296ac8f40ca783aeb73dae52e65edbb0eaae0dcdc1ea41bfaa8aebf7/jupytext-1.19.4.tar.gz", hash = "sha256:739bcd4bc12aa4fe298a38017cdb5ae27b08a6ba3a5470728d2fe9e04b155db1", size = 4581977, upload-time = "2026-06-21T21:48:58.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-py3-none-any.whl", hash = "sha256:032d4ef4bd2e96addcac780b9b1d6b5a266ca39beceaaca95bfb4f06e0b77029", size = 170889, upload-time = "2026-06-21T21:48:56.352Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -1607,6 +1883,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" }, ] +[[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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { 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 = "marshmallow" version = "3.26.2" @@ -1703,6 +2055,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/0d/5cf14e177c8ae655a2fd9324a6ef657ca4cafd3fc2201c87716055e29641/mcp-1.24.0-py3-none-any.whl", hash = "sha256:db130e103cc50ddc3dffc928382f33ba3eaef0b711f7a87c05e7ded65b1ca062", size = 232896, upload-time = "2025-12-12T14:19:36.14Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mlflow" +version = "3.14.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" }, + { 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/d6/0b/3404a057daceffe9ce18cd08868648a1e9b817270177bdf8a764576b988b/mlflow-3.14.0.tar.gz", hash = "sha256:5a1f818fa003035c724162096ce3ded7bc7bc47a1cae595df6173961983f4718", size = 11792369, upload-time = "2026-06-17T07:57:44.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/b9/76dcdef7f7f856b36f18cfcd752c2717d9847812a0aaa36d50a7baed569d/mlflow-3.14.0-py3-none-any.whl", hash = "sha256:dbf77f7cdb5b5c0ec59b4671c61730b1b914b4dff7a2892e267a547cb5454f56", size = 12564161, upload-time = "2026-06-17T07:57:42.348Z" }, +] + +[[package]] +name = "mlflow-skinny" +version = "3.14.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/e8/4f/a054cd8860590e4e942aee1aab3c94307878159f945fa844acc9ea787721/mlflow_skinny-3.14.0.tar.gz", hash = "sha256:e50f4506422c7737157ae6643c165122af7898345f2e828fa93c4f10128653cf", size = 2901772, upload-time = "2026-06-17T07:57:44.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/e7/b80f76ce689b9d6f21cdb84abb2b02148a1149e63a6428dd2c629cefd061/mlflow_skinny-3.14.0-py3-none-any.whl", hash = "sha256:a4880e086365871ef9d78e727a34ea5fb1ce615689579998d48e8c65ee1665a9", size = 3462788, upload-time = "2026-06-17T07:57:42.583Z" }, +] + +[[package]] +name = "mlflow-tracing" +version = "3.14.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/04/34/ff5e72919b4eec8fe65e6fc843978a1a512194e6fafbd1761deca48269ad/mlflow_tracing-3.14.0.tar.gz", hash = "sha256:c2f701e001d35964f23fbbdfdda36c818a76c157b912ae83781199fd714be09a", size = 1429017, upload-time = "2026-06-17T07:58:00.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/4a/4658a9e514c8f079e40b608661844b9beb21c7530e6ee1e7f830cf81541e/mlflow_tracing-3.14.0-py3-none-any.whl", hash = "sha256:854488dd18068f15e2a56f1cc7b8868c611d09ea39068d0a691a3f07e0048cae", size = 1703863, upload-time = "2026-06-17T07:57:58.687Z" }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -2348,6 +2802,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/8e/24e0bb90b2d75af84820693260c5534e9ed351afdda67ed6f393a141a0e2/plotly-6.5.1-py3-none-any.whl", hash = "sha256:5adad4f58c360612b6c5ce11a308cdbc4fd38ceb1d40594a614f0062e227abe1", size = 9894981, upload-time = "2026-01-07T20:11:38.124Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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 = "polars" version = "1.38.1" @@ -2392,6 +2855,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "prettytable" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/74/ba08d81e668ccfe8658d7520a307e63c19862c08eb4ccb26f356c5239a7a/prettytable-3.18.0.tar.gz", hash = "sha256:439217116152244369caf3d9f1caf2f9fe29b03bd79e88d2928c8e718c95d680", size = 76373, upload-time = "2026-06-22T16:07:50.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl", hash = "sha256:b3346e0e6f79180833aebaac088ae926340586cf6d7d991b9eb125b65f72313a", size = 37357, upload-time = "2026-06-22T16:07:48.595Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2570,6 +3045,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] +[[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 = "pycparser" version = "2.23" @@ -2718,6 +3214,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/99/3147435e15ccd97c0451efc3d13495dc22602e9887f81e64f1b135bae821/pypdf-6.4.2-py3-none-any.whl", hash = "sha256:014dcff867fd99fc0b6fc90ed1f7e1347ef2317ae038a489c2caa64106d268f4", size = 328212, upload-time = "2025-12-14T14:30:56.701Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3209,6 +3721,31 @@ 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" }, + { 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" @@ -3247,6 +3784,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] +[[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.0.4" @@ -3605,13 +4151,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] +[[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 = "wcwidth" -version = "0.2.14" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +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 = "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/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, + { 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]]