Files
t2a_v2/patch_0+1.md
2026-03-05 00:37:41 +01:00

13 KiB

Plan : Patch 0 + Patch 1 — DP shortlist & scoring déterministe

Contexte

Le pipeline T2A extrait le Diagnostic Principal (DP) de deux types de documents :

  • Trackare : DP pré-codé CIM-10 → aucun scoring nécessaire (priorité absolue)
  • CRH : fallback fragile — cherche dans "Au total" via CIM10_MAP, puis premier edsnlp

Problème : le CRH parser ne capture que 7 sections (dont conclusion). Les sections à fort signal DP ("Diagnostic de sortie", "Diagnostics retenus", "Diagnostic principal") sont ignorées. Pas de scoring multi-candidats, pas de détection d'ambiguïté.

Objectif : DP plus juste sur les CRH sans ajouter de dépendance LLM obligatoire.


Patch 0 — CRH Parser : capter les sections utiles au DP

0.1 Ajouter 3 patterns de section dans crh_parser.py

Fichier : src/extraction/crh_parser.py (ligne 116-124, bloc section_patterns)

Ajouter avant le pattern conclusion (pour que les terminaisons soient correctes) :

Clé Patterns capturés
diag_sortie "Diagnostic(s) de sortie", "Diagnostic(s) retenu(s) (à la sortie)"
diag_principal "Diagnostic principal", "Problème principal"
synthese "Synthèse", "En résumé", "En synthèse"

Pas besoin d'un alias motif séparé : motif_hospitalisation existe déjà (ligne 117) et sejour.motif est extrait par _extract_sejour_info() (ligne 74-80).

0.2 Ajuster les terminaisons des patterns existants

Le pattern conclusion (ligne 121) se termine sur (?=\n\s*(?:Devenir|TTT|Traitement)|$). Il faut ajouter les nouveaux en-têtes comme terminaisons possibles pour éviter la capture excessive :

  • Ajouter Diagnostic(?:s)?\s+de\s+sortie|Diagnostic(?:s)?\s+retenu|Synthèse|En résumé dans les groupes de terminaison de conclusion, histoire_maladie, examen_clinique.

0.3 Tests

Fichier : tests/test_extraction.py

8 tests à ajouter :

  1. test_parse_diag_sortie — "Diagnostic de sortie :" capturé
  2. test_parse_diagnostics_retenus — "Diagnostics retenus :" capturé
  3. test_parse_diag_principal — "Diagnostic principal :" capturé
  4. test_parse_probleme_principal — "Problème principal :" capturé
  5. test_parse_synthese — "Synthèse :" capturé
  6. test_existing_sections_preserved — les 7 sections existantes inchangées
  7. test_diag_sortie_multiline — section multi-lignes avec codes CIM-10
  8. test_conclusion_does_not_overflow_into_diag_sortie — terminaisons correctes

Patch 1 — DP shortlist + scoring déterministe + REVIEW

1.1 Modèles de données

Fichier : src/config.py

Nouveau DPCandidate(BaseModel) (après CodeDecision, avant Diagnostic) :

code: Optional[str]           # Code CIM-10 (peut être None si non résolu)
label: str                    # Texte du diagnostic
source_section: str           # "diag_sortie" | "diag_principal" | "conclusion" | "synthese" | "motif_hospitalisation" | "edsnlp" | "regex"
source_excerpt: Optional[str] # ~200 chars du texte source
source_page: Optional[int]    # Page 1-indexed
confidence_raw: Optional[str] # "high" | "medium" | "low"
score: int = 0                # Score final
score_details: dict[str, int] # Détail : {"section": +4, "negation": -4, ...}
is_negated: bool = False
is_conditional: bool = False

Nouveau DPSelection(BaseModel) :

verdict: str = "confirmed"         # "confirmed" | "review"
candidates: list[DPCandidate]      # Triés par score décroissant
winner_reason: Optional[str]       # Ex: "score 8 vs 4" ou "candidat unique"
llm_tiebreak: Optional[dict]       # {"winner": "A"|"B", "reason": "..."}

Ajout sur DossierMedical (après diagnostic_principal, ligne 658) :

dp_selection: Optional[DPSelection] = None

1.2 Constantes de scoring

Fichier : src/config.py (section constantes, après les chemins)

DP_SCORING_WEIGHTS = {
    "section_diag_sortie": 4,
    "section_diag_principal": 4,
    "section_diagnostics_retenus": 4,   # alias diag_sortie
    "section_motif_hospitalisation": 3,
    "section_conclusion": 2,
    "section_synthese": 2,
    "section_edsnlp": 1,
    "section_regex": 1,
    "proof_excerpt": 2,    # excerpt non-vide + page
    "negation": -4,         # "pas de", "absence de", "éliminé"
    "conditional": -3,      # "suspect", "probable", "?"
    "z_code_dp": -2,        # sauf whitelist
    "r_code_dp": -2,        # symptôme en DP
    "ccam_coherence": 1,    # futur
    "bio_coherence": 1,     # futur
}
DP_REVIEW_THRESHOLD = 2     # delta minimum top1-top2 pour éviter REVIEW

1.3 Nouveau module src/medical/dp_scoring.py

4 fonctions publiques :

build_dp_shortlist(parsed, text, edsnlp_result, dossier) -> list[DPCandidate]

Collecte les candidats depuis :

  1. Sections CRH (parsed["sections"]) : pour chaque section à poids fort (diag_sortie, diag_principal, conclusion, synthese, motif_hospitalisation), extraire les diagnostics via :
    • CIM10_MAP (itération substring normalisé) — réutiliser diagnostic_extraction.CIM10_MAP
    • Regex codes CIM-10 explicites r"([A-Z]\d{2}(?:\.\d{1,2})?)" dans le texte de section
    • Valider via cim10_dict.validate_code()
  2. edsnlp : chaque entité CIM-10 non-niée, non-hypothétique → candidat source_section="edsnlp"
  3. Regex patterns : réutiliser _find_diagnostic_principal() et CIM10_MAP sur texte complet → source_section="regex"
  4. Dédup par code CIM-10 : si même code depuis 2 sections, garder la section la plus forte

score_candidates(candidates, dossier) -> list[DPCandidate]

Pour chaque candidat :

  1. Bonus section : DP_SCORING_WEIGHTS["section_" + source_section]
  2. Bonus preuve : +2 si source_excerpt non vide ET source_page non None
  3. Pénalité négation : chercher dans fenêtre ~200 chars autour du diagnostic. Réutiliser les patterns de veto_engine.py (lignes 31-41) : "pas de", "absence de", "non retenu", "exclu", "éliminé", "négatif"
  4. Pénalité conditionnel : réutiliser patterns veto_engine.py (lignes 43-53) : "suspect", "probable", "hypothèse", "?", "à confirmer", "éventuel"
  5. Pénalité Z/R code : -2, sauf whitelist Z-codes admis en DP (Z51.1, Z51.0, Z38, Z50.1, Z43, Z45, Z09, Z54, Z75, Z03, Z04, Z08) — même whitelist que VETO-20 dans veto_engine.py (ligne 376-386)
  6. Stocker détail dans candidate.score_details
  7. Trier par score décroissant

select_dp(candidates, dossier, use_llm=False) -> DPSelection

  1. 0 candidat → verdict="review", candidates vide
  2. 1 candidat → verdict="confirmed", winner_reason="candidat unique"
  3. Delta top1-top2 >= DP_REVIEW_THRESHOLDverdict="confirmed"
  4. Delta < seuil → verdict="review", retourner top 3 avec preuves
  5. Si use_llm=True ET scores identiques → appeler _llm_tiebreak()

_llm_tiebreak(candidate_a, candidate_b, dossier) -> dict | None

  • Appel LLM local (ollama_client.call_ollama, role="coding", temperature=0.0)
  • Prompt dans src/prompts/templates.py (nouveau template DP_TIEBREAK)
  • Input : motif + sections fortes + 2 candidats + preuves
  • Output attendu : {"winner": "A"|"B", "reason": "..."}
  • Si erreur ou réponse invalide → retourner None → verdict reste "review"

1.4 Template LLM tiebreaker

Fichier : src/prompts/templates.py

Nouveau template DP_TIEBREAK — prompt DIM expert, choix entre 2 candidats, sortie JSON stricte. Critères : motif principal de prise en charge, ressources mobilisées, spécificité du code.

1.5 Intégration dans _extract_diagnostics()

Fichier : src/medical/diagnostic_extraction.py (ligne 168-181)

Remplacer le bloc actuel (lignes 168-181) :

if not dossier.diagnostic_principal:
    dp = _find_diagnostic_principal(text_lower, conclusion)
    if dp:
        dossier.diagnostic_principal = dp
    elif edsnlp_codes:
        ...

Par :

if not dossier.diagnostic_principal:
    candidates = build_dp_shortlist(parsed, text, edsnlp_result, dossier)
    candidates = score_candidates(candidates, dossier)
    selection = select_dp(candidates, dossier, use_llm=use_rag)
    dossier.dp_selection = selection
    if selection.candidates:
        winner = selection.candidates[0]
        dossier.diagnostic_principal = Diagnostic(
            texte=winner.label,
            cim10_suggestion=winner.code,
            source=winner.source_section,
            source_page=winner.source_page,
            source_excerpt=winner.source_excerpt,
        )

Note : _find_diagnostic_principal() est conservée comme utilitaire interne (appelée par build_dp_shortlist() pour les candidats regex). edsnlp_codes first-entity fallback est absorbé dans le shortlist (source="edsnlp").

Paramètre use_rag : déjà passé à _extract_diagnostics() via extract_medical_info() dans cim10_extractor.py. Il contrôle le tiebreaker LLM.

1.6 Propagation dans la fusion

Fichier : src/medical/fusion.py (fonction merge_dossiers())

Après sélection du DP fusionné par _prefer_most_specific_dp(), propager dp_selection depuis le dossier source du DP retenu.

1.7 Sérialisation

DPCandidate et DPSelection héritent de BaseModelmodel_dump() natif. Le champ dp_selection apparaît dans le JSON de sortie uniquement si non-None (Pydantic exclude_none). Les dossiers Trackare auront dp_selection=None (pas de scoring).


Ordre d'implémentation

  1. Patch 0 (prérequis de Patch 1)

    • crh_parser.py : 3 nouveaux patterns + ajustement terminaisons
    • tests/test_extraction.py : 8 tests
    • Validation : pytest tests/test_extraction.py -v
  2. Patch 1a — Modèles (étapes 1.1, 1.2)

    • config.py : DPCandidate, DPSelection, DP_SCORING_WEIGHTS, champ dp_selection
  3. Patch 1b — Module scoring (étapes 1.3, 1.4)

    • Créer src/medical/dp_scoring.py
    • Ajouter DP_TIEBREAK dans prompts/templates.py
  4. Patch 1c — Intégration (étapes 1.5, 1.6)

    • Modifier diagnostic_extraction.py (remplacer fallback naïf)
    • Modifier fusion.py (propager dp_selection)
  5. Patch 1d — Tests

    • Créer tests/test_dp_scoring.py (~20 tests)
    • Enrichir tests/test_medical.py (2 tests intégration)
    • Validation : pytest tests/ -v --ignore=tests/test_integration.py

Fichiers impactés

Fichier Action Patch
src/extraction/crh_parser.py Modifier : 3 patterns + terminaisons 0
tests/test_extraction.py Modifier : 8 tests 0
src/config.py Modifier : 2 modèles + constantes + champ DossierMedical 1a
src/medical/dp_scoring.py Créer : 4 fonctions 1b
src/prompts/templates.py Modifier : template DP_TIEBREAK 1b
src/medical/diagnostic_extraction.py Modifier : remplacer lignes 168-181 1c
src/medical/fusion.py Modifier : propager dp_selection 1c
tests/test_dp_scoring.py Créer : ~20 tests 1d
tests/test_medical.py Modifier : 2 tests intégration 1d

Fonctions existantes réutilisées

Fonction Fichier Usage dans Patch 1
CIM10_MAP diagnostic_extraction.py:22 Lookup candidats par section
normalize_text() cim10_dict.py Normalisation texte avant matching
validate_code() cim10_dict.py Validation candidats
lookup() cim10_dict.py Résolution label → code
is_valid_diagnostic_text() das_filter.py Filtrage bruit candidats
clean_diagnostic_text() das_filter.py Nettoyage texte
call_ollama() ollama_client.py Tiebreaker LLM
Patterns négation (lignes 31-41) veto_engine.py Scoring pénalité négation
Patterns conditionnel (lignes 43-53) veto_engine.py Scoring pénalité conditionnel
Whitelist Z-codes (lignes 376-386) veto_engine.py Exception pénalité Z-code

Risques et mitigations

Risque Mitigation
Régression 250 dossiers Trackare DP inchangé (priorité absolue). Seuls CRH sans DP Trackare affectés. L'ancien fallback (conclusion) est inclus comme candidat score +2.
Bruit sections CRH is_valid_diagnostic_text() filtre les artefacts. Scoring pénalise codes invalides.
Performance build_dp_shortlist() = regex/dict, < 1ms. LLM tiebreaker optionnel (flag use_rag).
Taille JSON dp_selection uniquement si non-None (CRH). ~500 bytes par dossier.

Vérification

  1. pytest tests/test_extraction.py -v — Patch 0 (sections CRH)
  2. pytest tests/test_dp_scoring.py -v — Patch 1 (scoring)
  3. pytest tests/ -v --ignore=tests/test_integration.py — non-régression complète
  4. Run sur 5 dossiers CRH connus : vérifier que dp_selection apparaît dans le JSON et que le verdict est cohérent