feat: qualité DP Phase 2 — filtre OCR étendu, abréviations médicales, promotion DAS→DP

- Filtre OCR : regex étendu (opérateurs +-*/), artefacts temporels (années),
  seuil digits abaissé 0.50→0.48
- Dictionnaire 41 abréviations médicales françaises (BMR, BPCO, SDRA, OAP,
  IDM, SCA, AVC, ACFA, SIDA, TDAH, etc.) avec expand_medical_abbreviations()
  appelé sur diagnostics Trackare et DAS LLM
- Promotion DAS→DP : si aucun DP extrait, le meilleur DAS (scoring
  pertinence/confiance/spécificité) est promu avec traçabilité RULE-DAS-TO-DP
- 95 nouveaux tests (OCR, abréviations, promotion, scoring, non-régression)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-20 08:37:10 +01:00
parent 6c036ed7f1
commit 1b680e9592
6 changed files with 360 additions and 5 deletions

View File

@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
from .cim10_dict import lookup as dict_lookup, normalize_text, normalize_code, validate_code as cim10_validate
from .ccam_dict import lookup as ccam_lookup, validate_code as ccam_validate
from .das_filter import clean_diagnostic_text, is_valid_diagnostic_text, correct_known_miscodes
from .das_filter import clean_diagnostic_text, is_valid_diagnostic_text, correct_known_miscodes, expand_medical_abbreviations
from ..config import (
ActeCCAM,
Antecedent,
@@ -209,6 +209,7 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
added = 0
for das in das_results:
texte = clean_diagnostic_text(das.get("texte", ""))
texte = expand_medical_abbreviations(texte)
if not texte or not is_valid_diagnostic_text(texte):
continue
@@ -315,6 +316,7 @@ def _extract_diagnostics(
# Diagnostics codés depuis Trackare (prioritaires)
for diag in parsed.get("diagnostics", []):
texte = clean_diagnostic_text(diag.get("libelle", ""))
texte = expand_medical_abbreviations(texte)
is_principal = diag.get("type", "").lower() == "principal"
# Le DP Trackare est toujours accepté (pré-codé avec CIM-10 validé).
# Seuls les DAS passent le filtre anti-bruit.

View File

@@ -22,6 +22,65 @@ def clean_diagnostic_text(text: str) -> str:
return text
# Abréviations médicales françaises courantes → forme expansée
MEDICAL_ABBREVIATIONS: dict[str, str] = {
"bmr": "Bactérie multi-résistante",
"bhre": "Bactérie hautement résistante émergente",
"sdra": "Syndrome de détresse respiratoire aiguë",
"oap": "Œdème aigu du poumon",
"bpco": "Bronchopneumopathie chronique obstructive",
"ep": "Embolie pulmonaire",
"saos": "Syndrome d'apnées obstructives du sommeil",
"idm": "Infarctus du myocarde",
"sca": "Syndrome coronarien aigu",
"avc": "Accident vasculaire cérébral",
"ait": "Accident ischémique transitoire",
"aomi": "Artériopathie oblitérante des membres inférieurs",
"fa": "Fibrillation auriculaire",
"acfa": "Arythmie complète par fibrillation auriculaire",
"bav": "Bloc auriculo-ventriculaire",
"hta": "Hypertension artérielle",
"tvp": "Thrombose veineuse profonde",
"irc": "Insuffisance rénale chronique",
"ira": "Insuffisance rénale aiguë",
"sep": "Sclérose en plaques",
"rgo": "Reflux gastro-œsophagien",
"dt1": "Diabète de type 1",
"dt2": "Diabète de type 2",
"dnid": "Diabète non insulino-dépendant",
"did": "Diabète insulino-dépendant",
# Ajouts depuis référentiel QuillBot (abréviations diagnostiques fréquentes)
"aag": "Asthme aigu grave",
"acr": "Arrêt cardio-respiratoire",
"aeg": "Altération de l'état général",
"db1": "Diabète de type 1",
"db2": "Diabète de type 2",
"edm": "État dépressif majeur",
"espt": "État de stress post-traumatique",
"ica": "Insuffisance cardiaque aiguë",
"pno": "Pneumothorax",
"sgb": "Syndrome de Guillain-Barré",
"sida": "Syndrome d'immunodéficience acquise",
"sii": "Syndrome de l'intestin irritable",
"tag": "Trouble anxieux généralisé",
"tc": "Traumatisme crânien",
"tdah": "Trouble du déficit de l'attention avec ou sans hyperactivité",
"tspt": "Trouble de stress post-traumatique",
}
def expand_medical_abbreviations(text: str) -> str:
"""Expanse une abréviation médicale si le texte entier est une abréviation connue.
Ne modifie pas les textes composés (ex: "FA paroxystique" reste inchangé).
"""
stripped = text.strip()
key = stripped.lower()
if key in MEDICAL_ABBREVIATIONS:
return MEDICAL_ABBREVIATIONS[key]
return text
def is_valid_diagnostic_text(text: str) -> bool:
"""Retourne True si le texte ressemble à un diagnostic médical légitime."""
t = text.strip()
@@ -30,13 +89,17 @@ def is_valid_diagnostic_text(text: str) -> bool:
if len(t) < 3:
return False
# 2. Chiffres purs (>= 50% de chiffres)
# 2. Chiffres purs (>= 48% de chiffres)
digits = sum(c.isdigit() for c in t)
if digits >= len(t) * 0.5:
if digits >= len(t) * 0.48:
return False
# 3. Lettre + chiffres OCR : "H 51", "À 08", "H\n10", "K 3.6", "B 12,5"
if re.match(r"^[A-ZÀ-Ú]\s*\d{1,3}([.,]\d+)?$", t):
# 3. Lettre + chiffres OCR : "H 51", "D - 200", "W + 400", "X-2"
if re.match(r"^[A-ZÀ-Ú]\s*[-–—+*/]?\s*\d{1,4}([.,]\d+)?$", t):
return False
# 3b. Texte court avec année calendaire (artefact temporel) : "X 2 en 2013"
if re.match(r"^[A-ZÀ-Ú].{0,15}\b(19|20)\d{2}\b", t) and len(t) < 25:
return False
# 4. Mots concaténés et/ou répétés avec espaces : "VentilationVentilation Ventilation..."