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:
@@ -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
|
||||
|
||||
@@ -143,3 +143,15 @@ class TestDetailPageLoads:
|
||||
"""Un fichier inexistant retourne 404."""
|
||||
response = client.get("/dossier/nonexistent.json")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestSourceTextEndpoint:
|
||||
def test_source_text_404_nonexistent(self, client):
|
||||
"""Un dossier inexistant retourne 404."""
|
||||
response = client.get("/api/source-text/nonexistent_dossier")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_source_text_security_path_traversal(self, client):
|
||||
"""Path traversal bloqué."""
|
||||
response = client.get("/api/source-text/../../etc")
|
||||
assert response.status_code in (403, 404)
|
||||
|
||||
Reference in New Issue
Block a user