Fonctionnement, sorties et pistes de consolidation — pour les mainteneurs
2026-06-10
Le problème en bref
L’enquête Budget de Famille collecte les tickets de caisse des ménages ; chaque ligne est un libellé court et bruité à rattacher à la nomenclature COICOP :
Bagu. Tradition U Blé Bretagne · Nescafé DLC Gust ESP 30 cap 165g · Ticket CB · Illisible
Codes hiérarchiques 01 → 01.1.1.1 (le pipeline s’arrête au niveau 4)
Codes techniques 98.x / 99.x pour les lignes non-produit
Codage manuel coûteux → le pipeline automatise et trace chaque décision
Argo Workflows ; chaque étape = un conteneur qui git clone (paramètre git-branch, défaut main) + uv sync --locked
Un dossier S3 par run : …/workflow_runs/{run_date}/{run_id}/<étape>/ ; run_id/run_date passés en CLI partout
Trois workflows : codif-pipeline.yaml (codification ; un seul mode, input_file obligatoire), index-notices-pipeline.yaml et index-annotations-pipeline.yaml (construction des collections Qdrant)
Les nœuds en pointillé ne sont pas des étapes : ce sont des collections bâties à l’avance, désignées par les paramètres obligatoiresclassify-rag-*-collection
Pourquoi l’indexation est sortie du pipeline
Avant : index-notices / index-annotations étaient deux étapes du DAG, sautables par skip-index=true, écrivant dans des collections au nom fixe (coicop_lineage, coicop_annotations_without_copain_2017).
Deux problèmes :
coût — l’embedding de toute la nomenclature (et de toute la base d’exemples) repayé à chaque codification, d’où le skip-index : un contournement, pas une solution ;
corruption silencieuse — une réindexation détruisait la base que lisait un run concurrent, et rien n’indiquait quelle version était embarquée.
Après : deux workflows autonomes, une collection au nom unique par indexation ({base}__{run_date}__{run_id}[__sampleN]), un manifeste JSON à côté (modèle d’embedding, dimension, stratégie, points, SHA git) que classify-rag-* relit et valide au démarrage. skip-index n’existe plus : il n’y a plus rien à sauter.
C’est le patron déjà en place pour classify-ttc-model-uri et reconcile-sirus-model-uri : artefact coûteux produit hors du pipeline, identifiant recopié dans argo/params.yaml.
La logique de conception
Aucune méthode ne couvre tout — le pipeline en croise quatre, puis arbitre :
Brique
Forte sur…
Aveugle sur…
Règles regex
libellés standardisés
tout le reste (voulu)
LCS
quasi-doublons du stock manuel
produits jamais vus
RAG
sens, libellés inédits
coût LLM, retrieval raté
TTC
volume grande distribution
hors supermarché
L’arbitre LLM-as-judge ne travaille que sur les désaccords : le consensus court-circuite l’appel LLM. C’est le compromis coût / qualité central du pipeline.
build-datasets + classify-regex
build-datasets (build-datasets/main.py) :
mappage de colonnes configurable (NAT_DEP → raw_product…) ; id UUID par ligne
deux normalisations : l_pr_product (légère → RAG/TTC) et s_pr_product (forte → regex/LCS)
enseigne → shop_type_name (jointure liste_magasins.csv) ; retrait des non-codables
écrit observations.parquet (à coder) + annotations_full.parquet (la KB) + le pool suggester.parquet
classify-regex (classify-regex/, 43 règles dans config/rules.yaml) :
s’applique sur s_pr_product ; règles ancrées (^…$), périmètre volontairement étroit
match → REGEX_pred.parquet, la ligne sort du circuit ; sinon → raw_test_without_regex.parquet
2 règles renvoient le littéral Reprise manuelle — il traverse tout le pipeline jusqu’au livrable
classify-lcs (R + C++)
Plus longue sous-chaîne commune entre le libellé et chaque entrée du pool suggester :
égalités strictes appariées d’abord (distance 0) ; ensuite distance minimale, départage par proportion couverte
les paires distance > 0,8 sont écartées dès le noyau C++ (classify-lcs/C/distance_gcd_batch_cpp.cpp) — jamais candidates
produit cartésien jeu × pool : calcul par lots parallélisés (furrr + Rcpp)
transmet à l’arbitre : lcs_code, lcs_distance, lcs_substring, lcs_prop
⚠ La couverture dépend du pool : un libellé loin de tout COPAIN n’a pas de bon candidat.
Branches RAG
prune-codes (module dédié, dans les trois workflows, drapeau --only {all,nomenclature,kb}) — supprime le niveau 5, replie les hiérarchies linéaires sur un code canonique (+ table de mapping) ; produit tous les artefacts prunés, l’aval ne fait que lire.
Indexation (hors pipeline de codification) — qwen3-embedding-8b (dim 4096) → Qdrant, cosinus, collection au nom unique + manifeste JSON :
index-annotations-pipeline.yaml → produits déjà annotés + suggester (coicop_annotations__…) ; aucun input_file : la KB, ce sont les produits annotés, pas les produits à classer — d’où l’absence de classify-regex dans la chaîne
tokenisation n-grammes de caractères (type fastText) — robuste aux abréviations
entraîné sur des données de caisses (grande distribution), fine-tuné sur le test BDF 2024 — sous-commandes train-basic / fine-tune-basic, lancées à la main, hors Argo (⚠ argo/ttc-pipeline.yaml est un workflow de prédiction, et il ne passe pas argo lint)
prédiction sur l_pr_product, renvoie ttc_code_1..10 + ttc_conf_1..10
modèle chargé depuis MLflow : paramètre de workflow classify-ttc-model-uri (URI d’artefact, propagé au rapport pour traçabilité)
⚠ Biais connu : très fiable en supermarché, moins ailleurs — compensé par l’arbitrage.
reconcile-llm — 1. le consensus
try_consensus_decision() (reconcile-llm/src/reconcile_llm.py) tranche sans appel LLM quand :
ttc_conf_1 ≥ 0,90 (seuil threshold codé en dur), et
tous les codes disponibles (lcs_code, rag_code) égalentttc_code_1
Tout est inspectable en place (DuckDB read_parquet('s3://…')) — premier réflexe de debug.
Le livrable et le rapport
export-results : fichier d’entrée d’origine + 3 colonnes — la décision LLM prime, sinon le code regex, sinon vide.
prediction_source
Lecture
consensus
les 3 modèles d’accord — le plus sûr
regex
règle déterministe (inclut Reprise manuelle)
llm
arbitrage — croiser avec llm_confiance et llm_comment
(vide)
non codé : échec LLM, raison dans llm_error (fichier reconcile-llm)
report (rapport de production, sans vérité terrain) : distribution par niveau, sources, meilleur prédicteur par code, désaccords totaux.
evaluate (facultative, déclenchée par label-column) : accuracy par niveau/groupe/source, confusion, calibration, recall de retrieval, coût de l’arbitrage.
Traçabilité : MLflow (paramètres dont ttc_model_uri, métriques, HTML en artefact) + traces Langfuse (classify-rag-notices).
Opérations courantes
# 1. Construire les collections Qdrant (une fois, ou quand les sources changent).# Chacune imprime la ligne à recopier dans argo/params.yaml.argo submit argo/index-notices-pipeline.yamlargo submit argo/index-annotations-pipeline.yaml # la KB = toute la base annotée# 2. Pipeline complet (test rapide : -p sample-observations=100)argo submit argo/codif-pipeline.yaml --parameter-file argo/params.yaml# Relancer UNE étape : --entrypoint + run_id/run_date du run d'origine (sinon dossier S3 vide !)argo submit argo/codif-pipeline.yaml --entrypoint reconcile-llm \-p run_id=codif-abc12 -p run_date=2026-03-20 \-p reconcile-llm-model=gemma4-26b-moe -p reconcile-llm-concurrency=5# Reprendre le DAG là où il a échouéargo retry codif-abc12
Pièges connus :
classify-rag-*-collection sont obligatoires (défaut vide) : oubliés, l’étape échoue en quelques secondes — voulu, plutôt qu’un repli silencieux sur l’index d’un autre run
en --entrypoint, l’input du template RAG s’appelle collection-name ; report réclame en plus step-timings — valider avec --dry-run
-p skip-index=... et -p sample-annotations=...n’existent plus. Argo n’en dit rien : un -p inconnu est accepté et ignoré. Le run part donc sans échantillonnage, ou en réindexant… rien. Utiliser sample-observations et les workflows d’indexation
chaque étape est aussi exécutable en local (uv / Rscript) avec les bonnes variables d’env
Constats vérifiés dans le code, à garder en tête :
llm_code n’est jamais validé contre la nomenclature : la validation Pydantic est purement formelle → hallucination (code inexistant) possible dans le livrable
Pas de retry/backoff sur les erreurs LLM transitoires : un RateLimitError laisse la ligne en échec ; la « relance manuelle + reprise checkpoint » fait office de retry
Prompt RAG hors dépôt (Langfuse v12) : une modif de prompt n’est ni versionnée ni revue avec le code
MLflow éclaté : classify-rag-notices loggue dans l’expérience test, classify-rag-annotations dans rag-annotation, report dans codif-coicop-eval
argo/ttc-pipeline.yaml invalide et mal nommé : workflow de prédiction, échoue à argo lint
Réglés depuis : collection Qdrant unique écrasée à chaque run (→ noms uniques + manifestes) ; branches git incohérentes (→ paramètre git-branch partout) ; vector_index.py dupliqué (→ module common/) ; la dualité prod/éval — un mode dérivé du seul fait qu’input_file soit vide ou non, testé à treize endroits, avec des métriques dans trois étapes de classification (→ un seul mode + l’étape facultative evaluate).
Pistes — fiabilité
Valider predicted_code contre la nomenclature en sortie de reconcile-llm : flag code_invalide (a minima) ou re-prompt automatique du LLM avec l’erreur
Retry avec backoff exponentiel sur RateLimitError / APIConnectionError dans call_llm_async — le checkpoint existant rend ça peu risqué
Boucle vertueuse : réinjecter les codifications validées par les gestionnaires dans le pool suggester (LCS s’améliore) et dans le fine-tuning TTC périodique
Suivi des non-codés : seuil d’alerte sur la part de llm_error / lignes vides par run (métrique MLflow déjà disponible)
Pistes — industrialisation
CI GitHub Actions : smoke tests uv run … --help par module, argo lint, rendu du site docs (et publication GitHub Pages)
Factoriser les manifests Argo : envFrom: secretRef au lieu de ~20 secretKeyRef copiés-collés par étape ; ou un WorkflowTemplate partagé
MLflow Model Registry pour le TTC : alias (@champion) au lieu de l’URI d’artefact brut mlflow-artifacts:/10/cacf26… codée dans le YAML
Unifier les branches clonées (tout sur main, ou tag/commit épinglé par release) et mettre le README à jour (ou pointer vers ce site)
Harmoniser MLflow : une seule expérience (ou convention claire classify-rag-notices vs report)
Pistes — méthode
Jeu d’évaluation permanent : un échantillon annoté stable + un run de codif-pipeline.yaml avec -p label-column=codeà chaque changement de modèle, prompt ou règle — comparaison directe dans MLflow (l’infra existe déjà)
Vendoriser une copie du prompt Langfuse dans le dépôt (fallback + revue de code)
Remplacer le littéral Reprise manuelle par un code technique dédié (ex. 97.x) pour garder predicted_code typé « code »
Purge / cycle de vie des dossiers workflow_runs/ — et des collections Qdrant, dont le nombre croît maintenant d’une par indexation (les manifestes sur S3 donnent l’inventaire)
Pour contribuer
Site de documentation : une page par étape (Rôle · Entrées · Traitement · Fil rouge · Sorties), construit depuis docs/ (quarto render docs/)