Files
rpa_vision_v3/tests/unit/test_build_dpi_enriched.py
Dom 9872f4510c 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>
2026-05-12 18:49:49 +02:00

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"]