# 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) ```python 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_THRESHOLD` → `verdict="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) : ```python if not dossier.diagnostic_principal: dp = _find_diagnostic_principal(text_lower, conclusion) if dp: dossier.diagnostic_principal = dp elif edsnlp_codes: ... ``` Par : ```python 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 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