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>
251 lines
9.1 KiB
Python
251 lines
9.1 KiB
Python
"""Tests de la fonction build_dpi_enriched (core/llm/t2a_decision).
|
|
|
|
Voir docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md pour le contexte.
|
|
|
|
Cas pilote : MOREL Catherine (IPP 25003284), passage urgences 01/01/2025
|
|
03:12 → 06:49 (3h37 réelles). Bug initial : LLM hallucine 23h (confusion
|
|
avec "depuis 23h" présent dans Observ. IDE Urg).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from core.llm.t2a_decision import build_dpi_enriched
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Fixtures DPI — reproduisent le format texte attendu en sortie OCR scroll auto.
|
|
# Le bandeau Easily Assure est répété en tête de chaque onglet (effet de bord
|
|
# de extract_text(top_var) qui capture la zone visible incluant l'en-tête fixe).
|
|
# ----------------------------------------------------------------------------
|
|
|
|
_BANDEAU_MOREL = (
|
|
"IPP : 25003284 MOREL Catherine Né(e) le 14/03/1947 | 77 ans | Sexe : F | "
|
|
"Arrivée : 01/01/2025 03:12 | IAO : CARON Sandrine (03:25) | "
|
|
"Médecin : BONNET Antoine | Sortie : 01/01/2025 06:49"
|
|
)
|
|
|
|
_BANDEAU_MOREL_SANS_SORTIE = (
|
|
"IPP : 25003284 MOREL Catherine Né(e) le 14/03/1947 | 77 ans | Sexe : F | "
|
|
"Arrivée : 01/01/2025 03:12 | IAO : CARON Sandrine (03:25) | "
|
|
"Médecin : BONNET Antoine"
|
|
)
|
|
|
|
|
|
def _build_dpi_morel(
|
|
bandeau: str = _BANDEAU_MOREL,
|
|
inclure_date_decision: bool = True,
|
|
) -> str:
|
|
"""Construit un DPI MOREL plausible (bandeau répété 5x + sections d'onglets +
|
|
Synthèse Urgences). Permet de générer la variante "test négatif" en
|
|
retirant la Date de décision médicale.
|
|
"""
|
|
date_decision_ligne = (
|
|
"Date de décision médicale 01/01/2025 à 06:49\n"
|
|
if inclure_date_decision
|
|
else ""
|
|
)
|
|
return f"""{bandeau}
|
|
|
|
Motif d'admission :
|
|
Asthme — quintes de toux sèche depuis 23h, fièvre, tachycardie.
|
|
|
|
{bandeau}
|
|
|
|
Examens cliniques :
|
|
À l'admission : fièvre 39.2°C, tachycardie 91 bpm, peakflow 260.
|
|
Sibilants bilatéraux, conscience normale.
|
|
|
|
{bandeau}
|
|
|
|
Imagerie :
|
|
Condensation parenchymateuse basithoracique gauche avec émoussement
|
|
du cul-de-sac pleural.
|
|
|
|
{bandeau}
|
|
|
|
Notes médicales :
|
|
Bilan biologique : hyperleucocytose 11 000 GB, CRP 25.
|
|
PCR positive pour virus respiratoire syncytial.
|
|
Traitement : Augmentin + Ventoline 3/j.
|
|
|
|
{bandeau}
|
|
|
|
Synthèse Urgences
|
|
|
|
Détails de l'épisode
|
|
Episode - Date 01/01/2025 à 03:12
|
|
Mode de transport à l'arrivée Véhicule personnel
|
|
Médicalisation du transport Aucune médicalisation
|
|
Mode d'entrée Autres admissions urgentes
|
|
Origine du transfert
|
|
|
|
Détails de l'orientation aux Urgences
|
|
Date d'orientation 01/01/2025 à 03:25
|
|
IAO CARON Sandrine
|
|
Priorité Priorité 3
|
|
Episode - Sous-type Médecine
|
|
Circonstances
|
|
Motif de prise en charge Asthme
|
|
Observ. IDE Urg depuis 23h , quintes de toux sèche
|
|
a pris 2+2 B de VENTO = inefficace
|
|
ATCD = asthme , insuf coronarienne
|
|
|
|
Détails de la prise en charge
|
|
Médecin de la prise en charge médicale BONNET Antoine
|
|
Date de la prise en charge médicale 01/01/2025 à 03:12
|
|
CCMU 3. Etat lésionnel et/ou pronostic fonctionnel jugés susceptibles de s'aggraver aux urgences ou durant l'intervention du SMUR, sans mettre en jeu le pronostic vital
|
|
GEMSA 2. Patient non convoqué sortant après consultation ou soins.
|
|
Diagnostics J12.1 Pneumopathie due au virus respiratoire syncytial [VRS] [CMA2] - actif
|
|
|
|
Décision médicale
|
|
Médecin de la décision médicale BONNET Antoine
|
|
{date_decision_ligne}Décision médicale Consultation externe
|
|
Orientation du patient
|
|
US de destination UC CONSULT.URGENCES
|
|
"""
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Test golden — cas MOREL complet
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_golden_morel_dpi_enriched_string():
|
|
"""Le bloc FAITS_CALCULÉS contient toutes les valeurs attendues."""
|
|
dpi_raw = _build_dpi_morel()
|
|
dpi_enriched, _metadata = build_dpi_enriched(dpi_raw)
|
|
|
|
# Durée — format humain + décimal
|
|
assert "Durée totale du passage : 3 heures et 37 minutes" in dpi_enriched
|
|
assert "(soit 3.62 heures décimales)" in dpi_enriched
|
|
|
|
# Âge
|
|
assert "Âge du patient : 77 ans" in dpi_enriched
|
|
|
|
# CCMU libellé COMPLET (début + portion fin = reconstitution multi-ligne)
|
|
assert "CCMU : 3. Etat lésionnel" in dpi_enriched
|
|
assert "sans mettre en jeu le pronostic vital" in dpi_enriched
|
|
|
|
# GEMSA libellé COMPLET
|
|
assert "GEMSA : 2. Patient non convoqué sortant après consultation ou soins" in dpi_enriched
|
|
|
|
# Priorité IAO
|
|
assert "Priorité IAO : Priorité 3" in dpi_enriched
|
|
|
|
# Diagnostic principal
|
|
assert "J12.1 Pneumopathie" in dpi_enriched
|
|
|
|
# Bloc FAITS_CALCULÉS positionné AVANT le bandeau brut
|
|
idx_faits = dpi_enriched.index("FAITS_CALCULÉS")
|
|
idx_bandeau = dpi_enriched.index("IPP : 25003284")
|
|
assert idx_faits < idx_bandeau, "FAITS_CALCULÉS doit être en tête du DPI"
|
|
|
|
# decision_terrain et orientation_terrain ABSENTS du bloc FAITS_CALCULÉS
|
|
# (ils sont dans metadata mais ne doivent pas biaiser le LLM)
|
|
bloc_faits = dpi_enriched.split("\n\n", 1)[0]
|
|
assert "Consultation externe" not in bloc_faits
|
|
assert "UC CONSULT.URGENCES" not in bloc_faits
|
|
assert "Décision médicale terrain" not in bloc_faits
|
|
|
|
|
|
def test_golden_morel_metadata_complet():
|
|
"""Le dict metadata contient toutes les valeurs Python extraites."""
|
|
dpi_raw = _build_dpi_morel()
|
|
_enriched, metadata = build_dpi_enriched(dpi_raw)
|
|
|
|
# Durée précise (tolérance arrondi)
|
|
assert metadata["duree_heures_decimales"] is not None
|
|
assert abs(metadata["duree_heures_decimales"] - 3.62) < 0.01
|
|
|
|
# Âge
|
|
assert metadata["age_ans"] == 77
|
|
|
|
# CCMU/GEMSA libellé complet (validation startswith + extrait fin)
|
|
assert metadata["ccmu"] is not None
|
|
assert metadata["ccmu"].startswith("3.")
|
|
assert "Etat lésionnel" in metadata["ccmu"]
|
|
assert "pronostic vital" in metadata["ccmu"]
|
|
|
|
assert metadata["gemsa"] is not None
|
|
assert metadata["gemsa"].startswith("2.")
|
|
assert "non convoqué" in metadata["gemsa"]
|
|
|
|
# Priorité IAO
|
|
assert metadata["priorite_iao"] == "Priorité 3"
|
|
|
|
# Mode de venue + médicalisation
|
|
assert metadata["mode_venue"] == "Véhicule personnel"
|
|
assert metadata["mode_medicalisation"] == "Aucune médicalisation"
|
|
assert metadata["mode_entree"] == "Autres admissions urgentes"
|
|
|
|
# Diagnostic principal
|
|
assert metadata["diagnostic_principal"] is not None
|
|
assert "J12.1" in metadata["diagnostic_principal"]
|
|
assert "Pneumopathie" in metadata["diagnostic_principal"]
|
|
|
|
# Décision terrain (metadata uniquement, pour garde-fou serveur commit 2)
|
|
assert metadata["decision_terrain"] == "Consultation externe"
|
|
assert metadata["orientation_terrain"] == "UC CONSULT.URGENCES"
|
|
|
|
# Dates parsées
|
|
assert metadata["date_admission"] is not None
|
|
assert metadata["date_admission"].strftime("%Y-%m-%d %H:%M") == "2025-01-01 03:12"
|
|
assert metadata["date_sortie"] is not None
|
|
assert metadata["date_sortie"].strftime("%Y-%m-%d %H:%M") == "2025-01-01 06:49"
|
|
|
|
# Pas de warning de parsing sur un cas complet
|
|
assert metadata["parsing_warnings"] == []
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Test négatif — horaires de sortie absents
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_negatif_sortie_absente():
|
|
"""Si Date de décision médicale ET Sortie bandeau sont retirées,
|
|
la fonction ne crashe pas, signale NON CALCULABLE et accumule un warning.
|
|
"""
|
|
dpi_raw = _build_dpi_morel(
|
|
bandeau=_BANDEAU_MOREL_SANS_SORTIE,
|
|
inclure_date_decision=False,
|
|
)
|
|
dpi_enriched, metadata = build_dpi_enriched(dpi_raw)
|
|
|
|
# Pas de crash, retour valide
|
|
assert isinstance(dpi_enriched, str)
|
|
assert isinstance(metadata, dict)
|
|
|
|
# Ligne explicite de signalement dans FAITS_CALCULÉS
|
|
assert "Durée totale du passage : NON CALCULABLE" in dpi_enriched
|
|
|
|
# Metadata : duree None, date_sortie None
|
|
assert metadata["duree_heures_decimales"] is None
|
|
assert metadata["date_sortie"] is None
|
|
|
|
# Admission reste détectable (Episode-Date + bandeau Arrivée présents)
|
|
assert metadata["date_admission"] is not None
|
|
|
|
# Warnings explicites
|
|
assert metadata["parsing_warnings"]
|
|
assert any("date_sortie" in w for w in metadata["parsing_warnings"])
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Test robustesse — DPI vide / malformé ne crashe pas
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_dpi_vide_ne_crashe_pas():
|
|
"""Un DPI vide retourne un tuple valide avec metadata tous None."""
|
|
dpi_enriched, metadata = build_dpi_enriched("")
|
|
|
|
assert isinstance(dpi_enriched, str)
|
|
assert dpi_enriched.startswith("FAITS_CALCULÉS")
|
|
assert metadata["duree_heures_decimales"] is None
|
|
assert metadata["age_ans"] is None
|
|
assert metadata["ccmu"] is None
|
|
assert metadata["decision_terrain"] is None
|
|
# Warnings accumulés sur tous les champs critiques
|
|
assert metadata["parsing_warnings"]
|