feat: traçabilité source systématique + viewer interactif

Ajoute source_page/source_excerpt à tous les types (biologie, imagerie,
traitements, actes CCAM, antécédents, complications). Convertit antecedents
et complications en types structurés (Antecedent/Complication) avec
validators backward-compat pour les vieux JSON. Étend _apply_source_tracking
à tous les éléments du dossier. Ajoute un endpoint /api/source-text/ et un
modal interactif dans le viewer avec surlignage du texte source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-18 20:59:50 +01:00
parent fe22c0f0f5
commit 40934fdc39
10 changed files with 500 additions and 47 deletions

View File

@@ -2,7 +2,7 @@
import pytest
from src.config import DossierMedical, Diagnostic
from src.config import DossierMedical, Diagnostic, Antecedent, Complication
from src.medical.cim10_extractor import (
extract_medical_info,
_lookup_cim10,
@@ -121,7 +121,7 @@ Devenir : sortie le 03/03."""
assert any("Balthazar" in (i.score or "") for i in dossier.imagerie)
# Complications
assert any("cutanée" in c.lower() for c in dossier.complications)
assert any("cutanée" in c.texte.lower() for c in dossier.complications)
def test_extract_without_edsnlp(self):
"""Vérifie que l'extraction fonctionne sans résultat edsnlp."""
@@ -236,7 +236,7 @@ Devenir : sortie le 03/03."""
dossier = extract_medical_info(parsed, text, edsnlp_result=edsnlp_result)
# Fièvre et infection sont niées, ne doivent pas apparaître dans complications
complication_terms = [c.lower() for c in dossier.complications]
complication_terms = [c.texte.lower() for c in dossier.complications]
assert "fièvre" not in complication_terms
assert "infection" not in complication_terms
@@ -504,6 +504,44 @@ class TestIsValidAntecedent:
def test_reject_texte_libre(self):
assert not _is_valid_antecedent("(texte libre)")
# --- Artefacts CRH colonne gauche (médecins) ---
def test_reject_medecin_tag_start(self):
assert not _is_valid_antecedent(
"[MEDECIN] hospitalier - Syndrome anxio depressif suivi Dr [MEDECIN_39]"
)
def test_reject_medecin_assistant(self):
assert not _is_valid_antecedent(
"[MEDECIN] Assistant des Hôpitaux de Lyon - Bilan neurologique"
)
def test_reject_medecin_contractuel(self):
assert not _is_valid_antecedent("[MEDECIN] hospitalier contractuel")
def test_reject_dr_medecin_tag(self):
assert not _is_valid_antecedent("Dr [MEDECIN_7] (Caradoc)")
def test_reject_dr_chef_clinique(self):
assert not _is_valid_antecedent(
"Dr [MEDECIN_37] Chef de Clinique des Hôpitaux aucune aide"
)
def test_reject_de_bordeaux(self):
assert not _is_valid_antecedent("de Bordeaux")
def test_reject_de_lyon(self):
assert not _is_valid_antecedent("de Lyon")
def test_reject_secretariat(self):
assert not _is_valid_antecedent("Secrétariat : [TEL_3] - fracture en 2017")
def test_reject_aucune_aide(self):
assert not _is_valid_antecedent("aucune aide, pas d'ide, pas d'aide ménagère")
def test_accept_de_long_medical(self):
"""'de' suivi d'une vraie description médicale longue passe."""
assert _is_valid_antecedent("dégénérescence maculaire liée à l'âge")
# --- Cas limites ---
def test_reject_too_short(self):
assert not _is_valid_antecedent("de Bo")
@@ -541,3 +579,108 @@ class TestClassifierConfidence:
result = classify(text)
assert isinstance(result, str)
assert result in ("crh", "trackare")
class TestBackwardCompatAntecedent:
"""Tests de rétrocompatibilité pour les antécédents et complications."""
def test_old_format_string_list(self):
"""Charger un vieux JSON avec antecedents: ["HTA", "Diabète"]."""
d = DossierMedical.model_validate({
"antecedents": ["HTA", "Diabète type 2"],
"complications": ["Fièvre"],
})
assert len(d.antecedents) == 2
assert isinstance(d.antecedents[0], Antecedent)
assert d.antecedents[0].texte == "HTA"
assert d.antecedents[1].texte == "Diabète type 2"
assert len(d.complications) == 1
assert isinstance(d.complications[0], Complication)
assert d.complications[0].texte == "Fièvre"
def test_new_format_object_list(self):
"""Charger un nouveau JSON avec antecedents: [{texte: "HTA", source_page: 1}]."""
d = DossierMedical.model_validate({
"antecedents": [{"texte": "HTA", "source_page": 2, "source_excerpt": "contexte HTA"}],
"complications": [{"texte": "Fièvre", "source_page": 3}],
})
assert d.antecedents[0].texte == "HTA"
assert d.antecedents[0].source_page == 2
assert d.antecedents[0].source_excerpt == "contexte HTA"
assert d.complications[0].source_page == 3
def test_mixed_format(self):
"""Un mélange de strings et d'objets est converti correctement."""
d = DossierMedical.model_validate({
"antecedents": ["HTA", {"texte": "Diabète", "source_page": 1}],
})
assert len(d.antecedents) == 2
assert d.antecedents[0].texte == "HTA"
assert d.antecedents[0].source_page is None
assert d.antecedents[1].texte == "Diabète"
assert d.antecedents[1].source_page == 1
def test_empty_list(self):
d = DossierMedical.model_validate({"antecedents": [], "complications": []})
assert d.antecedents == []
assert d.complications == []
def test_antecedent_extraction_produces_objects(self):
"""L'extraction produit bien des objets Antecedent."""
parsed = {
"type": "crh",
"patient": {"sexe": "M"},
"sejour": {},
"diagnostics": [],
}
text = "Antécédents :\n- Diabète type 2\n- Hypertension artérielle\n\nHistoire de la maladie"
dossier = extract_medical_info(parsed, text)
assert len(dossier.antecedents) >= 1
assert all(isinstance(a, Antecedent) for a in dossier.antecedents)
textes = [a.texte for a in dossier.antecedents]
assert "Diabète type 2" in textes
def test_complication_extraction_produces_objects(self):
"""L'extraction produit bien des objets Complication."""
parsed = {
"type": "crh",
"patient": {"sexe": "M"},
"sejour": {},
"diagnostics": [],
}
text = "Patient avec fièvre post-opératoire."
dossier = extract_medical_info(parsed, text)
assert all(isinstance(c, Complication) for c in dossier.complications)
class TestSourceTrackingFields:
"""Tests que les champs source_page/source_excerpt existent sur les modèles."""
def test_biologie_source_fields(self):
from src.config import BiologieCle
b = BiologieCle(test="CRP", valeur="45", source_page=2, source_excerpt="CRP=45")
assert b.source_page == 2
assert b.source_excerpt == "CRP=45"
def test_imagerie_source_fields(self):
from src.config import Imagerie
i = Imagerie(type="TDM", source_page=3)
assert i.source_page == 3
def test_traitement_source_fields(self):
from src.config import Traitement
t = Traitement(medicament="Paracétamol", source_page=4)
assert t.source_page == 4
def test_acte_source_fields(self):
from src.config import ActeCCAM
a = ActeCCAM(texte="Cholécystectomie", source_page=5)
assert a.source_page == 5
def test_antecedent_source_fields(self):
a = Antecedent(texte="HTA", source_page=1, source_excerpt="Antécédents: HTA")
assert a.source_page == 1
def test_complication_source_fields(self):
c = Complication(texte="Fièvre", source_page=2)
assert c.source_page == 2