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:
@@ -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.
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -312,6 +312,35 @@ def _iron_evidence_blob(dossier: DossierMedical, diag: Diagnostic) -> str:
|
||||
return _norm("\n".join(parts))
|
||||
|
||||
|
||||
def _das_promotion_score(das: Diagnostic) -> tuple[int, int, int]:
|
||||
"""Score de pertinence pour la promotion DAS→DP.
|
||||
|
||||
Retourne (pertinence_clinique, confiance, spécificité) :
|
||||
- Pertinence : pathologie (2) > symptôme R (1) > Z-code (0)
|
||||
- Confiance : high (3) > medium (2) > low (1)
|
||||
- Spécificité : longueur du code (sans point) — plus long = plus spécifique
|
||||
"""
|
||||
code = das.cim10_final or ""
|
||||
letter = code[0] if code else ""
|
||||
|
||||
# Pertinence clinique
|
||||
if letter == "Z":
|
||||
pertinence = 0
|
||||
elif letter == "R":
|
||||
pertinence = 1
|
||||
else:
|
||||
pertinence = 2
|
||||
|
||||
# Confiance
|
||||
conf = (das.cim10_confidence or "").lower()
|
||||
confiance = {"high": 3, "medium": 2, "low": 1}.get(conf, 1)
|
||||
|
||||
# Spécificité (longueur du code)
|
||||
specificite = len(code.replace(".", ""))
|
||||
|
||||
return (pertinence, confiance, specificite)
|
||||
|
||||
|
||||
def apply_decisions(dossier: DossierMedical) -> None:
|
||||
"""Applique des décisions finales sur DP/DAS.
|
||||
|
||||
@@ -579,6 +608,40 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
||||
applied_rules=["RULE-E87.6-K-NORMAL"],
|
||||
)
|
||||
|
||||
# --- Règle: promotion DAS→DP quand aucun DP n'a été extrait ---
|
||||
if rule_enabled("RULE-DAS-TO-DP"):
|
||||
if dossier.diagnostic_principal is None and dossier.diagnostics_associes:
|
||||
candidates = [
|
||||
das for das in dossier.diagnostics_associes
|
||||
if das.cim10_final
|
||||
and das.status not in ("ruled_out", "needs_info")
|
||||
]
|
||||
if candidates:
|
||||
best = max(candidates, key=_das_promotion_score)
|
||||
dossier.diagnostic_principal = Diagnostic(
|
||||
texte=best.texte,
|
||||
cim10_suggestion=best.cim10_suggestion,
|
||||
cim10_confidence=best.cim10_confidence,
|
||||
cim10_final=best.cim10_final,
|
||||
justification=best.justification,
|
||||
raisonnement=best.raisonnement,
|
||||
source=best.source,
|
||||
source_page=best.source_page,
|
||||
source_excerpt=best.source_excerpt,
|
||||
preuves_cliniques=best.preuves_cliniques,
|
||||
sources_rag=best.sources_rag,
|
||||
cim10_decision=CodeDecision(
|
||||
action="PROMOTE_DP",
|
||||
final_code=best.cim10_final,
|
||||
applied_rules=["RULE-DAS-TO-DP"],
|
||||
reason=f"DAS promu en DP (score {_das_promotion_score(best)})",
|
||||
),
|
||||
)
|
||||
dossier.diagnostics_associes.remove(best)
|
||||
logger.warning(
|
||||
"PROMOTE_DP: DAS %s (%s) promu en DP — aucun DP extrait",
|
||||
best.cim10_final, best.texte,
|
||||
)
|
||||
|
||||
|
||||
def decision_summaries(dossier: DossierMedical) -> list[str]:
|
||||
@@ -612,6 +675,8 @@ def decision_summaries(dossier: DossierMedical) -> list[str]:
|
||||
if dec.needs_info:
|
||||
for q in dec.needs_info:
|
||||
lines.append(f"DECISION: besoin_info: {q}")
|
||||
elif dec.action == "PROMOTE_DP":
|
||||
lines.append(f"DECISION: {where} {dec.final_code} promu en DP ({', '.join(dec.applied_rules)})")
|
||||
|
||||
if dossier.diagnostic_principal:
|
||||
_summ("diagnostic_principal", dossier.diagnostic_principal)
|
||||
|
||||
Reference in New Issue
Block a user