feat(t2a): build_dpi_enriched - extraction déterministe horaires + classifications cliniques

Préprocesseur Python qui injecte un bloc FAITS_CALCULÉS en tête du DPI
avant l'appel LLM, pour neutraliser l'hallucination de durée (bug "23h"
sur cas MOREL, confusion avec "depuis 23h" de l'Observ. IDE Urg).

Extrait depuis le bandeau Easily Assure et la Synthèse Urgences :
- âge (dateutil.relativedelta)
- date admission / sortie + durée passage (format humain + décimal)
- CCMU / GEMSA libellé complet (parser multi-ligne)
- priorité IAO, mode de venue / médicalisation / mode d'entrée
- diagnostic principal
- decision_terrain + orientation_terrain (metadata only, jamais injectés
  dans le prompt pour ne pas biaiser le LLM)

Retour tuple (dpi_enriched, metadata) pour permettre les garde-fous
serveur Python ↔ LLM au commit 2.

Robustesse :
- re.search 1re occurrence + WARNING si bandeau divergent multi-occurrences
- Synthèse Urgences priorité sur bandeau pour dates
- Valeur exigée sur même ligne que label (évite capture de section title)
- Cas négatif (horaires absents) → "NON CALCULABLE" + parsing_warnings
- Jamais de crash, retour tuple toujours valide

Tests : 4/4 verts (golden MOREL string + metadata, négatif sortie absente,
DPI vide). Pas de régression sur tests/integration/test_t2a_extract.py.

Brief complet : docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-12 18:49:49 +02:00
parent 2eeaa806bb
commit 9872f4510c
4 changed files with 937 additions and 9 deletions

View File

@@ -4,12 +4,15 @@ from .t2a_decision import (
PROMPT_TEMPLATE,
DEFAULT_MODEL,
analyze_dpi,
build_dpi_enriched,
)
from .ocr_extractor import extract_text_from_image
from .ocr_extractor import extract_table_from_image, extract_text_from_image
__all__ = [
"PROMPT_TEMPLATE",
"DEFAULT_MODEL",
"analyze_dpi",
"build_dpi_enriched",
"extract_text_from_image",
"extract_table_from_image",
]

View File

@@ -17,10 +17,14 @@ from __future__ import annotations
import json
import logging
import os
import re
import time
import urllib.error
import urllib.request
from typing import Any, Dict
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
from dateutil.relativedelta import relativedelta
logger = logging.getLogger(__name__)
@@ -166,3 +170,287 @@ def analyze_dpi(
parsed["_model"] = model
parsed["_eval_count"] = body.get("eval_count")
return parsed
# ---------------------------------------------------------------------------
# build_dpi_enriched — extraction déterministe horaires + classifications
# ---------------------------------------------------------------------------
#
# Voir docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md pour le contexte
# complet (bug hallucination durée "23h" sur cas MOREL, plan P0 [S1]/[S2]).
# Libellés connus de la section "Synthèse Urgences" (Easily Assure).
# Servent d'ancres pour le parsing : la valeur d'un champ s'étend depuis son
# libellé jusqu'au prochain libellé connu (gère le wrap multi-ligne CCMU/GEMSA).
# Trier par longueur décroissante côté regex pour éviter qu'un préfixe partiel
# (ex: "Décision médicale" matche "Médecin de la décision médicale").
_LIBELLES_SYNTHESE = [
"Episode - Date",
"Mode de transport à l'arrivée",
"Médicalisation du transport",
"Mode d'entrée",
"Origine du transfert",
"Date d'orientation",
"IAO",
"Priorité",
"Episode - Sous-type",
"Circonstances",
"Motif de prise en charge",
"Observ. IDE Urg",
"Médecin de la prise en charge médicale",
"Date de la prise en charge médicale",
"CCMU",
"GEMSA",
"Diagnostics",
"Médecin de la décision médicale",
"Date de décision médicale",
"Décision médicale",
"Orientation du patient",
"US de destination",
]
def _extract_synthese_field(text: str, label: str) -> Optional[str]:
"""Extrait la valeur du champ `label` dans la Synthèse Urgences.
Stratégie : trouve la 1re ligne qui commence par `label`, capture la suite
jusqu'au prochain libellé connu (en début de ligne) ou fin de texte.
Gère le wrap multi-ligne (CCMU 3 + libellé long sur plusieurs lignes).
Normalise les espaces multiples internes en un seul espace.
"""
other_labels = sorted(
(l for l in _LIBELLES_SYNTHESE if l != label),
key=len,
reverse=True,
)
other_alt = "|".join(re.escape(l) for l in other_labels)
# Exigence : la valeur doit commencer sur la même ligne que le label
# ([^\n]). Cela évite qu'une ligne titre de section ("Décision médicale\n"
# seule) capture la ligne suivante (qui serait un autre champ).
# Le wrap multi-ligne CCMU/GEMSA reste supporté : 1er char sur la même
# ligne que le label, suite via DOTALL jusqu'au prochain libellé connu.
pattern = (
rf"^[\s\|]*{re.escape(label)}[ \t]*[:\|]?[ \t]+"
rf"([^\n].*?)(?=\n[\s\|]*(?:{other_alt})\b|\Z)"
)
m = re.search(pattern, text, re.DOTALL | re.MULTILINE)
if not m:
return None
value = re.sub(r"\s+", " ", m.group(1)).strip()
return value or None
def _parse_dt(date_str: str, time_str: str) -> Optional[datetime]:
"""Parse 'DD/MM/YYYY' + 'HH:MM' → datetime. Retourne None si échec."""
try:
return datetime.strptime(f"{date_str} {time_str}", "%d/%m/%Y %H:%M")
except ValueError:
return None
def _parse_synthese_datetime(value: Optional[str]) -> Optional[datetime]:
"""Parse une valeur de type 'JJ/MM/AAAA à HH:MM' (format Synthèse Urgences).
Tolère absence du séparateur ' à ' (OCR peut linéariser autrement).
"""
if not value:
return None
m = re.search(r"(\d{2}/\d{2}/\d{4})\s*(?:à\s*)?(\d{2}:\d{2})", value)
if not m:
return None
return _parse_dt(m.group(1), m.group(2))
def build_dpi_enriched(dpi_raw: str) -> Tuple[str, Dict[str, Any]]:
"""Enrichit le DPI brut avec un bloc FAITS_CALCULÉS en tête.
Extrait de manière déterministe (Python, pas LLM) :
- âge du patient (depuis date de naissance bandeau + date d'admission)
- durée totale du passage (depuis horaires admission/sortie)
- CCMU, GEMSA, priorité IAO, mode de venue, diagnostic principal
- décision médicale terrain + orientation (metadata uniquement, NON injectés
dans le bloc FAITS_CALCULÉS pour ne pas biaiser le LLM)
Le bloc FAITS_CALCULÉS est concaténé en tête du DPI retourné.
Le metadata dict permet au handler serveur d'effectuer les garde-fous
Python ↔ LLM au commit 2.
Args:
dpi_raw: DPI brut concaténé (5 onglets OCR scroll auto + bandeau répété).
Returns:
Tuple (dpi_enriched, metadata).
dpi_enriched: str — FAITS_CALCULÉS + "\\n\\n" + dpi_raw.
metadata: dict — toutes les valeurs Python extraites (None si parsing
échoué pour un champ), + parsing_warnings: list[str].
"""
metadata: Dict[str, Any] = {
"age_ans": None,
"date_admission": None,
"date_sortie": None,
"duree_heures_decimales": None,
"ccmu": None,
"gemsa": None,
"priorite_iao": None,
"mode_venue": None,
"mode_medicalisation": None,
"mode_entree": None,
"diagnostic_principal": None,
"decision_terrain": None,
"orientation_terrain": None,
"parsing_warnings": [],
}
# ── 1. Bandeau Easily Assure (re.search = 1re occurrence) ────────────────
# Le bandeau est répété ~5 fois dans dpi_raw (un par extract_text_scroll
# de chaque onglet). On prend la 1re occurrence et on signale une éventuelle
# divergence inter-occurrences (symptôme d'OCR instable).
date_naissance: Optional[datetime] = None
date_adm_bandeau: Optional[datetime] = None
date_sortie_bandeau: Optional[datetime] = None
m_naissance = re.search(r"\(e\)\s+le\s+(\d{2}/\d{2}/\d{4})", dpi_raw)
if m_naissance:
try:
date_naissance = datetime.strptime(m_naissance.group(1), "%d/%m/%Y")
except ValueError:
metadata["parsing_warnings"].append(
f"date_naissance non parsable : {m_naissance.group(1)!r}"
)
arrivees = re.findall(
r"Arriv[ée]e\s*:\s*(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", dpi_raw
)
if arrivees:
date_adm_bandeau = _parse_dt(*arrivees[0])
if len({a for a in arrivees}) > 1:
logger.warning(
"[build_dpi_enriched] Bandeau détecté %d fois avec divergences "
"sur Arrivée — prise de la 1re occurrence : %s",
len(arrivees), arrivees[0],
)
sorties = re.findall(
r"Sortie\s*:\s*(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", dpi_raw
)
if sorties:
date_sortie_bandeau = _parse_dt(*sorties[0])
if len({s for s in sorties}) > 1:
logger.warning(
"[build_dpi_enriched] Bandeau détecté %d fois avec divergences "
"sur Sortie — prise de la 1re occurrence : %s",
len(sorties), sorties[0],
)
# ── 2. Synthèse Urgences (priorité sur le bandeau) ───────────────────────
syn_episode_date = _parse_synthese_datetime(
_extract_synthese_field(dpi_raw, "Episode - Date")
)
syn_pec_medicale = _parse_synthese_datetime(
_extract_synthese_field(dpi_raw, "Date de la prise en charge médicale")
)
syn_decision_medicale = _parse_synthese_datetime(
_extract_synthese_field(dpi_raw, "Date de décision médicale")
)
# Priorité d'admission : Episode-Date > PEC médicale > bandeau Arrivée
metadata["date_admission"] = (
syn_episode_date or syn_pec_medicale or date_adm_bandeau
)
# Priorité de sortie : Date décision médicale > bandeau Sortie
metadata["date_sortie"] = syn_decision_medicale or date_sortie_bandeau
# Champs structurés Synthèse Urgences (libellé complet, multi-ligne ok)
metadata["ccmu"] = _extract_synthese_field(dpi_raw, "CCMU")
metadata["gemsa"] = _extract_synthese_field(dpi_raw, "GEMSA")
metadata["priorite_iao"] = _extract_synthese_field(dpi_raw, "Priorité")
metadata["mode_venue"] = _extract_synthese_field(
dpi_raw, "Mode de transport à l'arrivée"
)
metadata["mode_medicalisation"] = _extract_synthese_field(
dpi_raw, "Médicalisation du transport"
)
metadata["mode_entree"] = _extract_synthese_field(dpi_raw, "Mode d'entrée")
metadata["diagnostic_principal"] = _extract_synthese_field(dpi_raw, "Diagnostics")
metadata["decision_terrain"] = _extract_synthese_field(dpi_raw, "Décision médicale")
metadata["orientation_terrain"] = _extract_synthese_field(
dpi_raw, "US de destination"
)
# ── 3. Calculs dérivés ───────────────────────────────────────────────────
if date_naissance and metadata["date_admission"]:
metadata["age_ans"] = relativedelta(
metadata["date_admission"], date_naissance
).years
elif date_naissance is None:
metadata["parsing_warnings"].append("date_naissance non détectée dans bandeau")
duree_format_humain: Optional[str] = None
if metadata["date_admission"] and metadata["date_sortie"]:
delta = metadata["date_sortie"] - metadata["date_admission"]
total_seconds = delta.total_seconds()
if total_seconds <= 0:
metadata["parsing_warnings"].append(
f"durée invalide : sortie {metadata['date_sortie']} <= "
f"admission {metadata['date_admission']}"
)
else:
metadata["duree_heures_decimales"] = round(total_seconds / 3600, 2)
heures = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
duree_format_humain = f"{heures} heures et {minutes} minutes"
else:
if metadata["date_admission"] is None:
metadata["parsing_warnings"].append("date_admission non détectée")
if metadata["date_sortie"] is None:
metadata["parsing_warnings"].append("date_sortie non détectée")
logger.warning(
"[build_dpi_enriched] Durée non calculable — admission=%s sortie=%s",
metadata["date_admission"], metadata["date_sortie"],
)
# ── 4. Construction du bloc FAITS_CALCULÉS ───────────────────────────────
lignes = ["FAITS_CALCULÉS (déterministes, ne pas recalculer) :"]
if metadata["age_ans"] is not None:
lignes.append(f"- Âge du patient : {metadata['age_ans']} ans")
if metadata["date_admission"]:
lignes.append(
f"- Date admission : "
f"{metadata['date_admission'].strftime('%d/%m/%Y à %H:%M')}"
)
if metadata["date_sortie"]:
lignes.append(
f"- Date sortie : "
f"{metadata['date_sortie'].strftime('%d/%m/%Y à %H:%M')}"
)
if duree_format_humain and metadata["duree_heures_decimales"] is not None:
lignes.append(
f"- Durée totale du passage : {duree_format_humain} "
f"(soit {metadata['duree_heures_decimales']} heures décimales)"
)
elif metadata["date_admission"] is None or metadata["date_sortie"] is None:
lignes.append(
"- Durée totale du passage : NON CALCULABLE (horaires non détectés)"
)
if metadata["ccmu"]:
lignes.append(f"- CCMU : {metadata['ccmu']}")
if metadata["gemsa"]:
lignes.append(f"- GEMSA : {metadata['gemsa']}")
if metadata["priorite_iao"]:
lignes.append(f"- Priorité IAO : {metadata['priorite_iao']}")
if metadata["mode_venue"] or metadata["mode_medicalisation"]:
mode_parts = [
p for p in (metadata["mode_venue"], metadata["mode_medicalisation"]) if p
]
lignes.append(f"- Mode de venue : {', '.join(mode_parts)}")
if metadata["mode_entree"]:
lignes.append(f"- Mode d'entrée : {metadata['mode_entree']}")
if metadata["diagnostic_principal"]:
lignes.append(f"- Diagnostic principal : {metadata['diagnostic_principal']}")
bloc_faits = "\n".join(lignes)
dpi_enriched = f"{bloc_faits}\n\n{dpi_raw}"
return dpi_enriched, metadata