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

281 lines
13 KiB
Markdown

# 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