8. Decide — arbitrage LLM-as-judge

Rôle de l’étape

decide-coicop est l’arbitre final. Pour chaque produit non codé par regex, elle réunit les prédictions candidates — LCS, RAG, TTC et, optionnellement, RAG-Annotations — puis choisit le code COICOP final, soit automatiquement (consensus), soit en interrogeant un LLM qui pèse les indices.

Entrées

Source Chemin S3 Argument
LCS …/{run}/codif-lcs/raw_test_LCS.parquet --lcs-file
RAG …/{run}/run-rag/predictions.parquet --rag-file
TTC …/{run}/run-ttc/predictions.parquet --ttc-file
RAG-Annotations (optionnel) …/{run}/rag-annotation/predictions.parquet --rag-annotations-file
Table de mapping prune (optionnel) …/{run}/prune/mapping_lvl4.parquet --mapping-file
Nomenclature CSV des codes COICOP + codes techniques (Libelle;Code) — défaut : decide-coicop/data/coicop_et_codes_techniques.csv, fichier statique du dépôt (~700 codes, tous niveaux) --nomenclature
Colonnes utilisateur …/{run}/preprocessing/observations.parquet (prod) ou raw_test.parquet (éval) --extra-columns-file

Le pipeline Argo passe systématiquement --mapping-file et --rag-annotations-file (la step dépend de prune et de run-rag-annotations).

Les prédictions sont fusionnées sur la colonne id (load_all_observations()). Le bloc RAG-Annotations (colonnes ragann_code, ragann_confidence, ragann_codable) n’est joint que si --rag-annotations-file est fourni : le 4ᵉ classifieur est donc optionnel au niveau du module/CLI (default=None), et tout le traitement aval s’adapte au nombre de modèles réellement disponibles. En revanche, le pipeline Argo le passe systématiquement (la step decide-coicop dépend de run-rag-annotations et fournit toujours --rag-annotations-file) : les 4 classifieurs sont donc la norme en production, l’optionalité servant surtout aux lancements manuels ou au débogage.

Traitement

0. Normalisation des codes (troncature + élagage)

Avant toute décision, quand --mapping-file est fourni, les codes entrants sont ramenés dans l’espace de codes pruné défini par l’étape prune (troncature niveau 4 + repli des hiérarchies linéaires, via trunc_and_prune_lvl4) :

  • lcs_code (le suggester LCS contient des codes de niveau 5) ;
  • ttc_code_1..3 (le TTC est entraîné sur des codes tronqués mais non élagués) ;
  • ragann_code (le RAG annotations ne garantit le pruning que par son prompt — seule la branche RAG notices re-prune structurellement ses prédictions) ;
  • et, en fin de batch, la décision llm_code elle-même.

Consensus, arbitrage et scoring raisonnent ainsi sur des codes homogènes : 11.2 et 11.2.0 ne sont plus comptés comme un désaccord.

La vérité terrain suit un traitement différent : elle n’est pas écrasée. La sortie porte les deux versions, et c’est la version canonique que le report score.

Colonne Contenu Usage
code code annoté brut, tel que saisi (souvent niveau 5) traçabilité, inspection d’un cas, détection du mode par report
code_lvl4 sa forme canonique : tronquée niveau 4 puis élaguée cible du scoring
Note

Conserver code brut évite un effet de bord : la détection automatique du mode par report repose sur le test « tous les code sont nuls », qui reste donc valable. Et un contrôle méthodologique peut comparer les deux colonnes pour mesurer combien d’annotations ont effectivement été ramenées à un code plus agrégé.

1. Court-circuit consensus (sans LLM)

Pour économiser temps et tokens sur les cas faciles, try_consensus_decision() tranche sans appeler le LLM quand :

  • la confiance TTC top-1 est suffisante (ttc_conf_1 ≥ 0,90), et
  • tous les autres codes disponibles (lcs_code, rag_code, ragann_code) sont égaux à ttc_code_1.
if float(ttc_conf) < threshold:          # threshold = 0.90
    return None
...
# codes non nuls parmi lcs_code, rag_code, ragann_code
if not all(c == ttc_code for c in other_codes):
    return None
# → décision par consensus, confiance = 5, llm_model = "consensus"

Seuls les codes réellement présents (non NA) participent au vote : si RAG-Annotations n’a pas été fourni, le consensus se joue sur les modèles restants. La décision porte alors la mention « Consensus automatique : les N modèles s’accordent… » (où N est le nombre de codes concordants).

2. Arbitrage par LLM

Sinon, le LLM est sollicité :

  • Filtrage de la nomenclature (filter_nomenclature()) : au lieu d’envoyer les ~700 codes COICOP, on ne soumet au LLM que les codes « autour » des prédictions des modèles (voir le détail ci-dessous) — réduction typique de ~700 à ~50-150 lignes (gain ×4 à ×10 sur les tokens). L’option --full-nomenclature désactive ce filtrage et envoie la nomenclature complète.

  • Construction du prompt (build_prompt()) : un prompt structuré présentant le produit, les prédictions des modèles, la nomenclature filtrée et les consignes :

    ═══ PRODUIT ═══
    Libellé brut       : Max garden flowers balle surprise
    Libellé normalisé  : max garden flowers balle surprise
    Enseigne           : Action
    Type de magasin    : …
    Montant (€)        : 3.99
    ═══ PRÉDICTIONS DES MODÈLES ═══
    [LCS] Code prédit : … (sous-chaîne …, proportion …, distance …)
    [RAG] Code prédit : … (confiance …, codable …)
    [RAG-Annotations] Code prédit : … (confiance …, codable …)   ← seulement si fourni
    [TTC] Top 1 : … (confiance …) / Top 2 : … / Top 3 : …
    ═══ NOMENCLATURE COICOP (codes valides) ═══
    …
    ═══ INSTRUCTIONS ═══
    1. Choisis UN code COICOP parmi ceux listés…
    4. Attribue un score de confiance de 1 (très faible) à 5 (très élevé).

    Le bloc [RAG-Annotations] n’apparaît dans le prompt que si une prédiction ragann_code non nulle existe pour l’observation.

Comment est construite la liste de codes soumise au LLM ?

filter_nomenclature() part de tous les codes prédits pour l’observation (lcs_code, rag_code, ragann_code, ttc_code_1, ttc_code_2, ttc_code_3, après suppression des NA) et en extrait deux ensembles de préfixes :

  • L1 = la section, soit le segment avant le premier point — "01.1.2.3""01" ;
  • L2 = la sous-section, soit les deux premiers segments — "01.1.2.3""01.1".

Puis, pour chaque code de la nomenclature complète, la décision de le garder dépend de son propre niveau de profondeur :

Code de la nomenclature Exemple Conservé si…
1 segment — en-tête de section 01 sa section appartient à L1
2 segments — sous-section 01.1, 01.2 sa section appartient à L1 (filtré sur L1, pas sur L2)
3 segments et plus 01.1.2, 01.1.2.3 il commence par l’un des préfixes L2

L’asymétrie est volontaire :

  • Aux niveaux 1 et 2, le filtre est large : on garde l’en-tête de section et toutes ses sous-sections (01.1, 01.2, 01.3…), même celles dont aucun code fin ne sera listé. Cela donne au LLM une vue d’ensemble de la section pour réorienter si les modèles se sont trompés de sous-section.
  • À partir du niveau 3, le filtre est étroit : on ne garde que la descendance des L2 réellement prédits, pour ne pas noyer le prompt sous les feuilles non pertinentes.

Exemple — si les modèles prédisent 09.3.1 et 05.1.1, alors L1 = {09, 05} et L2 = {09.3, 05.1}. La liste soumise contient : les en-têtes 05 et 09 ; toutes les sous-sections de 05 et 09 (05.1, 05.2, …, 09.1, …, 09.3, …) ; mais, au niveau fin, uniquement les codes sous 05.1.* et 09.3.*. Le LLM reste donc libre de choisir un code précis dans ces deux sous-sections, de basculer vers une autre sous-section des sections 05/09, ou de remonter à un niveau plus agrégé.

Cas limites : si aucune prédiction n’est disponible (tous les codes NA), ou si full=True (--full-nomenclature), la nomenclature complète est renvoyée.

  • Sortie structurée : le LLM répond en JSON validé par le modèle Pydantic DecisionCoicop :

    class DecisionCoicop(BaseModel):
        coicop_code: str   # code retenu
        libelle: str       # libellé copié de la nomenclature
        explication: str   # 1 phrase max
        confiance: int     # 1 (très faible) à 5 (très élevé)

Le LLM par défaut est gemma4-26b-moe (decide-model), avec decide-concurrency=5 appels parallèles dans le pipeline (20 par défaut en CLI). Chaque appel est borné (temperature=0, max_tokens=512, réponse forcée en JSON).

Exemple complet prompt → réponse

Le message system impose le format de sortie :

Tu es un expert COICOP. Réponds UNIQUEMENT avec un objet JSON valide, sans texte
avant ni après, respectant exactement ce schéma :
{"coicop_code": "<code exact de la nomenclature>",
 "libelle": "<libellé copié de la nomenclature>",
 "explication": "<1 phrase max>", "confiance": <entier 1-5>}

Le message user est produit par build_prompt() (valeurs des modèles illustratives) :

Tu es un expert en comptabilité nationale et en statistiques de consommation.
Ta mission est de déterminer le code COICOP le plus approprié pour un produit acheté,
en t'appuyant sur le contexte d'achat et les prédictions de plusieurs modèles automatiques.

═══════════════════════════════════════
PRODUIT
═══════════════════════════════════════
Libellé brut       : Max garden flowers balle surprise
Libellé normalisé  : max garden flowers balle surprise
Enseigne           : action
Type de magasin    : magasin discount
Montant (€)        : 3.99

═══════════════════════════════════════
PRÉDICTIONS DES MODÈLES
═══════════════════════════════════════

[LCS — Longest Common Substring]
  Code prédit  : 09.3.1
  Sous-chaîne  : balle (proportion dans référence : 36%)
  Distance     : 0.853

[RAG — Retrieval-Augmented Generation]
  Code prédit  : 09.3.1.1
  Confiance    : 60%
  Codable      : True

[RAG-Annotations — RAG sur exemples déjà codifiés]   ← présent seulement si --rag-annotations-file fourni
  Code prédit  : 09.3.1
  Confiance    : 72%
  Codable      : True

[TTC — Classificateur par apprentissage profond]
  Top 1 : 09.3.1 (confiance 41%)
  Top 2 : 05.1.1 (confiance 22%)
  Top 3 : 09.5.4 (confiance 8%)

═══════════════════════════════════════
NOMENCLATURE COICOP (codes valides)
═══════════════════════════════════════
05 — Meubles, articles de ménage et entretien courant du foyer
05.1 — Meubles, articles d'ameublement…
…   (nomenclature filtrée aux sections 05 et 09 : ~60 lignes au lieu de ~700)
09.3.1 — Jeux, jouets et passe-temps
…

═══════════════════════════════════════
INSTRUCTIONS
═══════════════════════════════════════
1. Choisis UN code COICOP parmi ceux listés dans la nomenclature ci-dessus.
2. Le code doit correspondre au niveau le plus précis qui te semble justifié.
3. Explique en 1 phrase maximum comment tu as pesé les prédictions des différents modèles
   et tout autre indice (enseigne, type de magasin, montant).
4. Attribue un score de confiance de 1 (très faible) à 5 (très élevé).

Réponse du LLM (validée par le schéma Pydantic DecisionCoicop) :

{
  "coicop_code": "09.3.1",
  "libelle": "Jeux, jouets et passe-temps",
  "explication": "Deux modèles sur trois convergent vers les jouets ; le libellé (balle surprise), l'enseigne discount et le faible montant confirment un jouet plutôt qu'un article de jardin.",
  "confiance": 4
}

On peut reproduire ce prompt pour n’importe quelle ligne avec le mode mono-observation : uv run main.py decide-coicop --id <uuid> --output json … (voir Lancer une étape).

En mode évaluation, le code annoté (code) n’est pas injecté dans le prompt : le juge décide à l’aveugle, comme en production (pas de fuite de la vérité terrain dans les métriques).

Reprise et coût

  • Checkpoints : la sortie est sauvegardée toutes les 50 observations (CHECKPOINT_EVERY). Si --output-file existe déjà, l’étape reprend automatiquement les observations non traitées — relancer la même commande (même run_id/run_date) suffit après une interruption ou un quota épuisé.
  • Coût LLM maîtrisé : seules les lignes hors consensus déclenchent un appel ; le filtrage de nomenclature divise le prompt par 4 à 10 ; les compteurs de tokens (llm_prompt_tokens, llm_completion_tokens) et la latence (llm_latency_s) sont conservés dans la sortie pour suivre la consommation réelle d’un run.

Exemple sur le fil rouge

Produit Consensus ? Décision
Bagu. Tradition U Blé Bretagne si tous les codes disponibles = ttc_code_1 et ttc_conf_1 ≥ 0,90oui code pain repris sans LLM, confiance = 5, llm_model = "consensus"
Max garden flowers balle surprise les modèles divergent (jouet vs jardin) → non le LLM arbitre à partir de l’enseigne (Action), du prix et des candidats

Que peut renvoyer la décision ?

La sortie de l’étape n’est pas garantie d’être un « beau » code de niveau 4. Les cas possibles :

  • Cas nominal — un code repris de la nomenclature, à un niveau quelconque (de 01 à 01.1.1.1 ; l’instruction demande « le niveau le plus précis justifié »), avec un score llm_confiance de 1 (très faible) à 5 (très élevé). Ce code est ensuite tronqué au niveau 4 et élagué (normalisation --mapping-file), même si le LLM a répondu au niveau 5 (le fichier --nomenclature qui lui est soumis contient des codes de niveau 5, voir Limites et points de vigilance).

  • Décision par consensusllm_code = ttc_code_1, llm_model = "consensus", llm_confiance = 5, llm_error = None (aucun appel LLM).

  • Aucun code (llm_code à NA) — quand l’appel LLM échoue. La raison est alors consignée dans la colonne llm_error. Causes capturées par call_llm_async :

    Cause llm_error (extrait)
    Quota dépassé RateLimitError: …
    Erreur HTTP de l’API APIStatusError(<code>): …
    Connexion impossible APIConnectionError: …
    Réponse coupée (limite de tokens) Response truncated before JSON was complete…
    JSON illisible JSON parsing failed: …
    Toute autre erreur (auth, etc.) <TypeErreur>: … (catch-all)
AvertissementLe code n’est pas validé contre la nomenclature

La réponse du LLM est validée uniquement sur sa forme par le schéma Pydantic DecisionCoicop (coicop_code est une chaîne, confiance ∈ 1–5, longueurs de libelle / explication bornées). Le code n’est pas vérifié comme appartenant réellement à la nomenclature. Les garde-fous sont uniquement incitatifs : le prompt impose de choisir parmi la liste de candidats filtrée (filter_nomenclature) et de recopier le code exactement, et la sortie est en JSON structuré. Un code inexistant (hallucination) reste donc possible en principe — la normalisation finale le tronque et l’élague, mais ne vérifie pas son appartenance à la nomenclature. C’est rare, mais à garder en tête lors des contrôles qualité.

Sorties

Fichier Colonnes clés
…/{run}/decide-coicop/predictions.parquet id, les codes normalisés des 4 classifieurs (lcs_code, rag_code, ragann_code, ttc_code_1..3 + confiances), code (vérité terrain brute) et code_lvl4 (vérité canonique, éval), llm_code (normalisé), llm_libelle, llm_explication, llm_confiance, llm_model ("consensus" si court-circuit), llm_error (raison si llm_code est NA)

➡️ Étape suivante : 9. Final output