feat: Phase 4 — viewer enrichi, non-cumul CCAM, fusion multi-PDFs + rebuild FAISS (21 141 vecteurs)

- Viewer : badges compteurs (DAS, actes, alertes, CMA), raisonnement LLM pliable, regroupement CCAM, navigation patient, alertes NON-CUMUL en rouge
- Non-cumul CCAM : 3 règles heuristiques (même base, même regroupement/jour, paires incompatibles)
- Fusion multi-PDFs : merge_dossiers() avec priorité Trackare, spécificité CIM-10, déduplication, champ source_files
- Index FAISS reconstruit : 21 141 vecteurs (CCAM dict 8 257 + CIM-10 alpha 306)
- 192 tests unitaires passent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-11 12:43:34 +01:00
parent 7e69f994b0
commit 9d07894c6f
12 changed files with 1013 additions and 26 deletions

View File

@@ -0,0 +1,88 @@
"""Tests pour le module de détection de non-cumul CCAM."""
import pytest
from src.config import ActeCCAM
from src.medical.ccam_noncumul import check_noncumul
class TestCheckNoncumul:
def test_no_actes_no_alerts(self):
assert check_noncumul([]) == []
def test_single_acte_no_alert(self):
actes = [ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004")]
assert check_noncumul(actes) == []
def test_same_base_code_different_activity(self):
"""Deux codes avec les 7 premiers caractères identiques déclenchent une alerte."""
actes = [
ActeCCAM(texte="Acte 1", code_ccam_suggestion="HMFC004"),
ActeCCAM(texte="Acte 2", code_ccam_suggestion="HMFC005"),
]
alertes = check_noncumul(actes)
assert any("NON-CUMUL" in a and "HMFC0" in a for a in alertes)
def test_different_base_codes_no_alert(self):
"""Codes de bases différentes : pas d'alerte de base identique."""
actes = [
ActeCCAM(texte="Acte 1", code_ccam_suggestion="HMFC004"),
ActeCCAM(texte="Acte 2", code_ccam_suggestion="ZCQK002"),
]
alertes = check_noncumul(actes)
# Pas d'alerte sur la règle 1 (même base)
assert not any("même base" in a for a in alertes)
def test_same_regroupement_same_day(self, monkeypatch):
"""Même regroupement chirurgical le même jour déclenche une alerte."""
# Monkeypatch pour simuler le regroupement
def mock_get_regroup(acte):
return "ADC"
monkeypatch.setattr(
"src.medical.ccam_noncumul._get_regroupement", mock_get_regroup
)
actes = [
ActeCCAM(texte="Acte 1", code_ccam_suggestion="ABCD001", date="01/03/2023"),
ActeCCAM(texte="Acte 2", code_ccam_suggestion="EFGH002", date="01/03/2023"),
]
alertes = check_noncumul(actes)
assert any("NON-CUMUL" in a and "ADC" in a for a in alertes)
def test_different_regroupement_no_alert(self, monkeypatch):
"""Regroupements différents non incompatibles : pas d'alerte."""
regroup_map = {"ABCD001": "ATM", "EFGH002": "ACI"}
def mock_get_regroup(acte):
return regroup_map.get(acte.code_ccam_suggestion)
monkeypatch.setattr(
"src.medical.ccam_noncumul._get_regroupement", mock_get_regroup
)
actes = [
ActeCCAM(texte="Acte 1", code_ccam_suggestion="ABCD001", date="01/03/2023"),
ActeCCAM(texte="Acte 2", code_ccam_suggestion="EFGH002", date="01/03/2023"),
]
alertes = check_noncumul(actes)
# Pas d'alerte de regroupement unique ni d'incompatibilité
assert not any("regroupement" in a.lower() for a in alertes)
def test_incompatible_regroupement_pairs(self, monkeypatch):
"""Paire de regroupements incompatibles déclenche une alerte."""
regroup_map = {"ABCD001": "ADC", "EFGH002": "ADE"}
def mock_get_regroup(acte):
return regroup_map.get(acte.code_ccam_suggestion)
monkeypatch.setattr(
"src.medical.ccam_noncumul._get_regroupement", mock_get_regroup
)
actes = [
ActeCCAM(texte="Acte 1", code_ccam_suggestion="ABCD001"),
ActeCCAM(texte="Acte 2", code_ccam_suggestion="EFGH002"),
]
alertes = check_noncumul(actes)
assert any("incompatibles" in a and "ADC" in a and "ADE" in a for a in alertes)

239
tests/test_fusion.py Normal file
View File

@@ -0,0 +1,239 @@
"""Tests pour le module de fusion multi-PDFs."""
import pytest
from src.config import (
ActeCCAM,
Diagnostic,
DossierMedical,
Sejour,
Traitement,
BiologieCle,
Imagerie,
)
from src.medical.fusion import (
merge_dossiers,
_cim10_specificity,
_prefer_most_specific_dp,
_merge_sejour,
_dedup_diagnostics,
_dedup_actes,
)
class TestCIM10Specificity:
def test_none(self):
assert _cim10_specificity(None) == 0
def test_short_code(self):
assert _cim10_specificity("I10") == 3
def test_long_code(self):
assert _cim10_specificity("K85.1") == 4
def test_longer_code(self):
assert _cim10_specificity("K80.50") == 5
class TestSpecificityLongerCodeWins:
def test_specificity_longer_code_wins(self):
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Calcul biliaire", cim10_suggestion="K80"),
)
d2 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Calcul cholédoque", cim10_suggestion="K80.5"),
)
dp = _prefer_most_specific_dp([d1, d2])
assert dp is not None
assert dp.cim10_suggestion == "K80.5"
class TestMergeSejourTrackarePriority:
def test_merge_sejour_trackare_priority(self):
d1 = DossierMedical(
document_type="trackare",
sejour=Sejour(sexe="F", age=43, date_entree="25/02/2023"),
)
d2 = DossierMedical(
document_type="crh",
sejour=Sejour(sexe="M", age=45, date_entree="24/02/2023", mode_sortie="domicile"),
)
merged = _merge_sejour([d1, d2])
assert merged.sexe == "F" # Trackare prioritaire
assert merged.age == 43
assert merged.date_entree == "25/02/2023"
assert merged.mode_sortie == "domicile" # Complété depuis CRH
def test_merge_sejour_fills_missing(self):
d1 = DossierMedical(
document_type="trackare",
sejour=Sejour(sexe="F"),
)
d2 = DossierMedical(
document_type="crh",
sejour=Sejour(age=50, poids=75.0),
)
merged = _merge_sejour([d1, d2])
assert merged.sexe == "F"
assert merged.age == 50
assert merged.poids == 75.0
class TestDedupDiagnostics:
def test_dedup_diagnostics_by_code(self):
das = [
Diagnostic(texte="HTA", cim10_suggestion="I10", cim10_confidence="medium"),
Diagnostic(texte="Hypertension", cim10_suggestion="I10", cim10_confidence="high"),
]
result = _dedup_diagnostics(das)
assert len(result) == 1
assert result[0].cim10_confidence == "high"
def test_dedup_keeps_distinct_codes(self):
das = [
Diagnostic(texte="HTA", cim10_suggestion="I10"),
Diagnostic(texte="Diabète", cim10_suggestion="E11.9"),
]
result = _dedup_diagnostics(das)
assert len(result) == 2
class TestDedupActes:
def test_dedup_actes_by_code(self):
actes = [
ActeCCAM(texte="Cholé", code_ccam_suggestion="HMFC004"),
ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004", date="01/03"),
]
result = _dedup_actes(actes)
assert len(result) == 1
assert result[0].date == "01/03" # Celui avec la date est préféré
class TestSingleDossierPassthrough:
def test_single_dossier_passthrough(self):
d = DossierMedical(
source_file="test.pdf",
document_type="crh",
diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"),
)
result = merge_dossiers([d])
assert result.diagnostic_principal.cim10_suggestion == "I10"
assert result.source_files == ["test.pdf"]
class TestDpNonRetainedBecomesDas:
def test_dp_non_retained_becomes_das(self):
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"),
)
d2 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Calcul cholédoque", cim10_suggestion="K80.5"),
)
result = merge_dossiers([d1, d2])
# K80.5 est plus spécifique, donc DP
assert result.diagnostic_principal.cim10_suggestion == "K80.5"
# I10 (ancien DP de d1) doit être dans les DAS
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert "I10" in das_codes
class TestFusionAlertAdded:
def test_fusion_alert_added(self):
d1 = DossierMedical(source_file="a.pdf", alertes_codage=["Alerte 1"])
d2 = DossierMedical(source_file="b.pdf", alertes_codage=["Alerte 2"])
result = merge_dossiers([d1, d2])
assert result.alertes_codage[0] == "FUSION: 2 documents fusionnés"
assert "Alerte 1" in result.alertes_codage
assert "Alerte 2" in result.alertes_codage
class TestSourceFilesPopulated:
def test_source_files_populated(self):
d1 = DossierMedical(source_file="a.pdf")
d2 = DossierMedical(source_file="b.pdf")
result = merge_dossiers([d1, d2])
assert result.source_files == ["a.pdf", "b.pdf"]
class TestFullMergeCROTrackare:
def test_full_merge_cro_trackare(self):
"""Cas réel : fusion Trackare + CRO."""
trackare = DossierMedical(
source_file="trackare.pdf",
document_type="trackare",
sejour=Sejour(sexe="F", age=43, date_entree="25/02/2023", date_sortie="03/03/2023"),
diagnostic_principal=Diagnostic(
texte="Calcul des canaux biliaires",
cim10_suggestion="K80.5",
),
diagnostics_associes=[
Diagnostic(texte="HTA", cim10_suggestion="I10"),
],
actes_ccam=[
ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004", date="01/03"),
],
traitements_sortie=[
Traitement(medicament="Paracétamol"),
],
alertes_codage=["Alerte trackare"],
)
cro = DossierMedical(
source_file="cro.pdf",
document_type="cro",
sejour=Sejour(sexe="F"),
diagnostic_principal=Diagnostic(
texte="Pancréatite aiguë lithiasique",
cim10_suggestion="K85.1",
cim10_confidence="high",
),
diagnostics_associes=[
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
Diagnostic(texte="HTA", cim10_suggestion="I10"), # doublon
],
actes_ccam=[
ActeCCAM(texte="TDM", code_ccam_suggestion="ZCQK002"),
ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004"), # doublon
],
traitements_sortie=[
Traitement(medicament="Paracétamol"), # doublon
Traitement(medicament="Cétirizine"),
],
alertes_codage=["Alerte CRO"],
)
result = merge_dossiers([trackare, cro])
# DP : K85.1 est plus spécifique que K80.5
assert result.diagnostic_principal.cim10_suggestion == "K85.1"
# K80.5 (ancien DP trackare) doit être dans les DAS
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert "K80.5" in das_codes
assert "I10" in das_codes
assert "E66.0" in das_codes
# DAS dédupliqués : I10 ne doit pas être en double
i10_count = sum(1 for d in result.diagnostics_associes if d.cim10_suggestion == "I10")
assert i10_count == 1
# Actes dédupliqués
acte_codes = [a.code_ccam_suggestion for a in result.actes_ccam]
assert acte_codes.count("HMFC004") == 1
assert "ZCQK002" in acte_codes
# Traitements dédupliqués
meds = [t.medicament for t in result.traitements_sortie]
assert meds.count("Paracétamol") == 1
assert "Cétirizine" in meds
# Source files
assert result.source_files == ["trackare.pdf", "cro.pdf"]
# Alertes
assert result.alertes_codage[0].startswith("FUSION:")
assert "Alerte trackare" in result.alertes_codage
assert "Alerte CRO" in result.alertes_codage
# Type prioritaire : trackare
assert result.document_type == "trackare"

94
tests/test_viewer.py Normal file
View File

@@ -0,0 +1,94 @@
"""Tests pour le viewer Flask."""
import pytest
from src.viewer.app import create_app, compute_group_stats, severity_badge
from src.config import DossierMedical, Diagnostic, ActeCCAM
@pytest.fixture
def app():
app = create_app()
app.config["TESTING"] = True
return app
@pytest.fixture
def client(app):
return app.test_client()
class TestGroupStats:
def test_group_stats(self):
items = [
{
"dossier": DossierMedical(
diagnostics_associes=[
Diagnostic(texte="HTA", cim10_suggestion="I10"),
Diagnostic(texte="Diabète", cim10_suggestion="E11.9", est_cma=True),
],
actes_ccam=[
ActeCCAM(texte="Cholé", code_ccam_suggestion="HMFC004"),
],
alertes_codage=["Alerte 1", "Alerte 2"],
),
},
{
"dossier": DossierMedical(
diagnostics_associes=[
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
],
actes_ccam=[
ActeCCAM(texte="TDM", code_ccam_suggestion="ZCQK002"),
],
alertes_codage=[],
),
},
]
stats = compute_group_stats(items)
assert stats["das_count"] == 3
assert stats["actes_count"] == 2
assert stats["alertes_count"] == 2
assert stats["cma_count"] == 1
def test_group_stats_empty(self):
stats = compute_group_stats([])
assert stats["das_count"] == 0
assert stats["alertes_count"] == 0
class TestSeverityBadgeFilter:
def test_severe(self):
result = severity_badge("severe")
assert "Sévère" in result
assert "#dc2626" in result
def test_modere(self):
result = severity_badge("modere")
assert "Modéré" in result
def test_leger(self):
result = severity_badge("leger")
assert "Léger" in result
def test_none(self):
result = severity_badge(None)
assert result == ""
def test_unknown(self):
result = severity_badge("inconnu")
assert result == ""
class TestIndexPageLoads:
def test_index_page_loads(self, client):
response = client.get("/")
assert response.status_code == 200
assert b"Dossiers" in response.data
class TestDetailPageLoads:
def test_detail_page_404(self, client):
"""Un fichier inexistant retourne 404."""
response = client.get("/dossier/nonexistent.json")
assert response.status_code == 404