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 deconclusion,histoire_maladie,examen_clinique.
0.3 Tests
Fichier : tests/test_extraction.py
8 tests à ajouter :
test_parse_diag_sortie— "Diagnostic de sortie :" capturétest_parse_diagnostics_retenus— "Diagnostics retenus :" capturétest_parse_diag_principal— "Diagnostic principal :" capturétest_parse_probleme_principal— "Problème principal :" capturétest_parse_synthese— "Synthèse :" capturétest_existing_sections_preserved— les 7 sections existantes inchangéestest_diag_sortie_multiline— section multi-lignes avec codes CIM-10test_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 :
- 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éutiliserdiagnostic_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()
- edsnlp : chaque entité CIM-10 non-niée, non-hypothétique → candidat
source_section="edsnlp" - Regex patterns : réutiliser
_find_diagnostic_principal()etCIM10_MAPsur texte complet →source_section="regex" - 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 :
- Bonus section :
DP_SCORING_WEIGHTS["section_" + source_section] - Bonus preuve : +2 si
source_excerptnon vide ETsource_pagenon None - 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" - Pénalité conditionnel : réutiliser patterns
veto_engine.py(lignes 43-53) : "suspect", "probable", "hypothèse", "?", "à confirmer", "éventuel" - 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-20dansveto_engine.py(ligne 376-386) - Stocker détail dans
candidate.score_details - Trier par score décroissant
select_dp(candidates, dossier, use_llm=False) -> DPSelection
- 0 candidat →
verdict="review", candidates vide - 1 candidat →
verdict="confirmed",winner_reason="candidat unique" - Delta top1-top2 >=
DP_REVIEW_THRESHOLD→verdict="confirmed" - Delta < seuil →
verdict="review", retourner top 3 avec preuves - Si
use_llm=TrueET 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 templateDP_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 BaseModel → model_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
-
Patch 0 (prérequis de Patch 1)
crh_parser.py: 3 nouveaux patterns + ajustement terminaisonstests/test_extraction.py: 8 tests- Validation :
pytest tests/test_extraction.py -v
-
Patch 1a — Modèles (étapes 1.1, 1.2)
config.py:DPCandidate,DPSelection,DP_SCORING_WEIGHTS, champdp_selection
-
Patch 1b — Module scoring (étapes 1.3, 1.4)
- Créer
src/medical/dp_scoring.py - Ajouter
DP_TIEBREAKdansprompts/templates.py
- Créer
-
Patch 1c — Intégration (étapes 1.5, 1.6)
- Modifier
diagnostic_extraction.py(remplacer fallback naïf) - Modifier
fusion.py(propager dp_selection)
- Modifier
-
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
- Créer
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
pytest tests/test_extraction.py -v— Patch 0 (sections CRH)pytest tests/test_dp_scoring.py -v— Patch 1 (scoring)pytest tests/ -v --ignore=tests/test_integration.py— non-régression complète- Run sur 5 dossiers CRH connus : vérifier que
dp_selectionapparaît dans le JSON et que le verdict est cohérent