feat: mode hybride Ollama — gemma3:27b pour CPAM, 12b pour codage

Le pipeline utilise désormais gemma3:12b (rapide) pour le codage CIM-10
et gemma3:27b (meilleur raisonnement) pour la contre-argumentation CPAM.
Configurable via OLLAMA_MODEL_CPAM et OLLAMA_TIMEOUT_CPAM.

Inclut aussi : traçabilité source/page DAS, niveaux CMA ATIH, sévérité,
page tracker PDF, améliorations fusion et filtres DAS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-17 17:53:53 +01:00
parent 4ef42dd3d3
commit 01d47f3c4b
20 changed files with 1025 additions and 98 deletions

View File

@@ -98,11 +98,21 @@ def extract_medical_info(
anonymized_text: str,
edsnlp_result: Optional[EdsnlpResult] = None,
use_rag: bool = False,
page_tracker=None,
raw_text: str | None = None,
) -> DossierMedical:
"""Extrait les informations médicales structurées depuis les données parsées et le texte."""
"""Extrait les informations médicales structurées depuis les données parsées et le texte.
Args:
page_tracker: PageTracker pour la traçabilité page/extrait (optionnel).
raw_text: Texte brut avant anonymisation (pour recherche page source).
"""
dossier = DossierMedical()
dossier.document_type = parsed_data.get("type", "")
# Texte de référence pour la recherche de pages (raw_text préféré, sinon anonymized)
search_text = raw_text or anonymized_text
_extract_sejour(parsed_data, dossier)
_extract_diagnostics(parsed_data, anonymized_text, dossier, edsnlp_result)
_extract_actes(anonymized_text, dossier)
@@ -140,6 +150,10 @@ def extract_medical_info(
# Post-processing : retirer DAS dont le code est identique au DP
_remove_das_equal_dp(dossier)
# Post-processing : traçabilité source (page + extrait)
if page_tracker:
_apply_source_tracking(dossier, page_tracker, search_text)
return dossier
@@ -331,10 +345,12 @@ def _extract_diagnostics(
elif edsnlp_codes:
# Utiliser la première entité CIM-10 edsnlp comme DP
code, texte = next(iter(edsnlp_codes.items()))
dossier.diagnostic_principal = Diagnostic(
texte=texte.capitalize(), cim10_suggestion=code,
source="edsnlp",
)
texte_clean = texte.capitalize()
if is_valid_diagnostic_text(texte_clean):
dossier.diagnostic_principal = Diagnostic(
texte=texte_clean, cim10_suggestion=code,
source="edsnlp",
)
# Diagnostics associés depuis le texte (regex)
das = _find_diagnostics_associes(text_lower, conclusion, dossier)
@@ -881,18 +897,46 @@ def _apply_code_corrections(dossier: DossierMedical) -> None:
diag.cim10_suggestion = corrected
def _is_dp_family_redundant(das_code: str, dp_code: str) -> bool:
"""True si le DAS est redondant avec le DP (même code, parent/enfant, ou même famille)."""
if das_code == dp_code:
return True
# Relation parent/enfant → toujours redondant
das_norm = das_code.replace(".", "")
dp_norm = dp_code.replace(".", "")
if das_norm.startswith(dp_norm) or dp_norm.startswith(das_norm):
return True
# Même famille 3 chars, sauf exceptions
dp_family = dp_code[:3]
if das_code[:3] == dp_family:
# S/T (trauma) : sites différents → garder
if dp_family[0] in ("S", "T"):
return False
# E10-E14 (diabète) : complications différentes → garder
if dp_family[0] == "E" and dp_family[1:].isdigit() and 10 <= int(dp_family[1:]) <= 14:
return False
return True
return False
def _remove_das_equal_dp(dossier: DossierMedical) -> None:
"""Retire les DAS dont le code CIM-10 est identique au DP (violation règle PMSI)."""
"""Retire les DAS redondants avec le DP (même code, famille, ou sémantique)."""
from .das_filter import apply_semantic_dedup
dp_code = dossier.diagnostic_principal.cim10_suggestion if dossier.diagnostic_principal else None
if not dp_code:
return
before = len(dossier.diagnostics_associes)
dossier.diagnostics_associes = [
d for d in dossier.diagnostics_associes if d.cim10_suggestion != dp_code
d for d in dossier.diagnostics_associes
if not d.cim10_suggestion or not _is_dp_family_redundant(d.cim10_suggestion, dp_code)
]
removed = before - len(dossier.diagnostics_associes)
if removed:
logger.info(" DAS=DP : %d DAS retiré(s) (code %s identique au DP)", removed, dp_code)
logger.info(" DASDP : %d DAS retiré(s) (famille %s du DP)", removed, dp_code[:3])
# Redondances sémantiques entre DAS
dossier.diagnostics_associes = apply_semantic_dedup(dossier.diagnostics_associes)
def _apply_noncumul_rules(dossier: DossierMedical) -> None:
@@ -945,3 +989,33 @@ def _is_abnormal(test: str, value: str) -> bool | None:
lo, hi = BIO_NORMALS[test]
return val > hi or val < lo
return None
def _apply_source_tracking(dossier: DossierMedical, page_tracker, search_text: str) -> None:
"""Ajoute la traçabilité source (page + extrait) à chaque diagnostic.
Cherche le texte du diagnostic dans le texte source pour retrouver
la page d'origine et extraire un passage contextualisé.
"""
all_diags: list[Diagnostic] = []
if dossier.diagnostic_principal:
all_diags.append(dossier.diagnostic_principal)
all_diags.extend(dossier.diagnostics_associes)
tracked = 0
for diag in all_diags:
if diag.source_page is not None:
continue # déjà renseigné
texte = diag.texte
if not texte:
continue
page = page_tracker.find_page_for_text(texte, search_text)
if page:
diag.source_page = page
diag.source_excerpt = page_tracker.extract_excerpt(texte, search_text)
tracked += 1
if tracked:
logger.info(" Traçabilité source : %d/%d diagnostics localisés", tracked, len(all_diags))