refactor: réorganisation référentiels, nouveaux modules extraction, nettoyage code obsolète

- Réorganisation data/referentiels/ : pdfs/, dicts/, user/ (structure unifiée)
- Fix badges "Source absente" sur page admin référentiels
- Ré-indexation COCOA 2025 (555 → 1451 chunks, couverture 94%)
- Fix VRAM OOM : embeddings forcés CPU via T2A_EMBED_CPU
- Nouveaux modules : document_router, docx_extractor, image_extractor, ocr_engine
- Module complétude (quality/completude.py + config YAML)
- Template DIM (synthèse dimensionnelle)
- Gunicorn config + systemd service t2a-viewer
- Suppression t2a_install_rag_cleanup/ (copie obsolète)
- Suppression scripts/ et scripts_t2a_v2/ (anciens benchmarks)
- Suppression 81 fichiers _doc.txt de test
- Cache Ollama : TTL configurable, corrections loader YAML
- Dashboard : améliorations templates (base, index, detail, cpam, validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-03-07 16:48:10 +01:00
parent 2578afb6ff
commit 4e2b4bd946
210 changed files with 6939 additions and 22104 deletions

View File

@@ -135,46 +135,46 @@ class TestVeto22SameCategory:
# ================================================================
# VETO-23 : Exclusions mutuelles
# VETO-25 : Exclusions mutuelles (ex-VETO-23, refactorisé via diagnostic_conflicts.yaml)
# ================================================================
class TestVeto23MutualExclusions:
class TestVeto25MutualExclusions:
def test_e10_e11_mutual(self):
"""E10 + E11 = diabète type 1 et 2 → VETO-23."""
"""E10 + E11 = diabète type 1 et 2 → VETO-25."""
d = _make_dossier(dp_code="E10.9", das_codes=["E11.9", "I10"])
report = apply_vetos(d)
v23 = [i for i in report.issues if i.veto == "VETO-23"]
assert len(v23) == 1
assert "Diabète" in v23[0].message
v25 = [i for i in report.issues if i.veto == "VETO-25"]
assert len(v25) == 1
assert "Diabète" in v25[0].message
def test_i10_i11_mutual(self):
"""I10 + I11 = HTA essentielle + secondaire → VETO-23."""
"""I10 + I11 = HTA essentielle + secondaire → VETO-25."""
d = _make_dossier(dp_code="I10", das_codes=["I11.9"])
report = apply_vetos(d)
v23 = [i for i in report.issues if i.veto == "VETO-23"]
assert len(v23) == 1
assert "HTA" in v23[0].message
v25 = [i for i in report.issues if i.veto == "VETO-25"]
assert len(v25) == 1
assert "HTA" in v25[0].message
def test_i10_i13_mutual(self):
"""I10 + I13 (HTA cardiorénale) → VETO-23."""
"""I10 + I13 (HTA cardiorénale) → VETO-25."""
d = _make_dossier(dp_code="K35.8", das_codes=["I10", "I13.0"])
report = apply_vetos(d)
v23 = [i for i in report.issues if i.veto == "VETO-23"]
assert len(v23) == 1
v25 = [i for i in report.issues if i.veto == "VETO-25"]
assert len(v25) == 1
def test_no_mutual_exclusion(self):
"""Pas de conflit → pas de VETO-23."""
"""Pas de conflit → pas de VETO-25."""
d = _make_dossier(dp_code="E11.9", das_codes=["I10", "K35.8"])
report = apply_vetos(d)
v23 = [i for i in report.issues if i.veto == "VETO-23"]
assert len(v23) == 0
v25 = [i for i in report.issues if i.veto == "VETO-25"]
assert len(v25) == 0
def test_e10_alone_no_veto(self):
"""E10 seul → pas de VETO-23."""
"""E10 seul → pas de VETO-25."""
d = _make_dossier(dp_code="E10.9", das_codes=["I10"])
report = apply_vetos(d)
v23 = [i for i in report.issues if i.veto == "VETO-23"]
assert len(v23) == 0
v25 = [i for i in report.issues if i.veto == "VETO-25"]
assert len(v25) == 0
# ================================================================
@@ -237,7 +237,7 @@ class TestVerdictIntegration:
veto_ids = {i.veto for i in report.issues}
# Z45 interdit en DP → VETO-20
assert "VETO-20" in veto_ids
# E10+E11 → VETO-23
assert "VETO-23" in veto_ids
# E10+E11 → VETO-25 (ex-VETO-23, via diagnostic_conflicts.yaml)
assert "VETO-25" in veto_ids
# S72 sans externe → VETO-24
assert "VETO-24" in veto_ids

885
tests/test_completude.py Normal file
View File

@@ -0,0 +1,885 @@
"""Tests de la checklist de complétude documentaire DIM."""
import pytest
from src.config import (
ActeCCAM,
BiologieCle,
CheckCompletude,
CompletudeDossier,
Diagnostic,
DossierMedical,
Imagerie,
ItemCompletude,
PreuveClinique,
Sejour,
load_completude_rules,
)
from src.quality.completude import build_completude_checklist
# ── Helpers ──────────────────────────────────────────────────────────
def _make_dossier(**kwargs) -> DossierMedical:
"""Crée un DossierMedical minimal avec des valeurs par défaut."""
return DossierMedical(
sejour=kwargs.get("sejour", Sejour()),
diagnostic_principal=kwargs.get("dp", None),
diagnostics_associes=kwargs.get("das", []),
biologie_cle=kwargs.get("bio", []),
imagerie=kwargs.get("imagerie", []),
actes_ccam=kwargs.get("actes", []),
document_type=kwargs.get("document_type", "crh"),
source_files=kwargs.get("source_files", []),
)
# ── Tests du chargement YAML ────────────────────────────────────────
class TestLoadRules:
def test_load_completude_rules(self):
rules = load_completude_rules()
assert "diagnostics" in rules
assert "actes" in rules
assert len(rules["diagnostics"]) >= 10 # Au moins 10 familles
def test_rules_structure(self):
rules = load_completude_rules()
for family_id, family in rules["diagnostics"].items():
assert "prefixes" in family, f"Famille {family_id} sans prefixes"
assert "items" in family, f"Famille {family_id} sans items"
for item in family["items"]:
assert "categorie" in item
assert "element" in item
assert "importance" in item
assert item["importance"] in ("obligatoire", "recommande")
def test_yaml_version_2(self):
"""Le YAML enrichi doit être en version 2."""
rules = load_completude_rules()
assert rules.get("version") == 2
def test_seuils_present_in_yaml(self):
"""Vérifier que les seuils sont bien chargés sur certains items."""
rules = load_completude_rules()
denut = rules["diagnostics"]["denutrition"]
items_with_seuil = [i for i in denut["items"] if "seuil" in i]
assert len(items_with_seuil) >= 2, "Dénutrition doit avoir au moins 2 items avec seuil"
# ── Tests dénutrition ────────────────────────────────────────────────
class TestDenutrition:
def test_denutrition_complete(self):
"""E43 avec albumine + IMC → defendable, score élevé."""
dossier = _make_dossier(
dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"),
sejour=Sejour(imc=16.5),
bio=[BiologieCle(test="Albumine", valeur="28 g/L", valeur_num=28.0)],
)
result = build_completude_checklist(dossier)
assert len(result.checks) >= 1
check_e43 = next(c for c in result.checks if c.code == "E43")
assert check_e43.verdict == "defendable"
assert check_e43.score >= 70
# Vérifier les items
alb = next(i for i in check_e43.items if i.element == "Albumine")
assert alb.statut == "present_confirme" # 28 < 30 → confirmé
assert alb.confirmation_detail is not None
imc = next(i for i in check_e43.items if i.element == "IMC")
assert imc.statut == "present_confirme" # 16.5 < 18.5 → confirmé
def test_denutrition_albumine_haute(self):
"""E43 avec albumine 38 (> 30) → present_non_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"),
sejour=Sejour(imc=16.5),
bio=[BiologieCle(test="Albumine", valeur="38 g/L", valeur_num=38.0)],
)
result = build_completude_checklist(dossier)
check_e43 = next(c for c in result.checks if c.code == "E43")
alb = next(i for i in check_e43.items if i.element == "Albumine")
assert alb.statut == "present_non_confirme"
assert alb.confirmation_detail is not None
# Verdict doit refléter la non-confirmation
assert check_e43.verdict == "fragile"
def test_denutrition_sans_albumine(self):
"""E43 sans albumine → fragile."""
dossier = _make_dossier(
dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"),
sejour=Sejour(imc=16.5),
)
result = build_completude_checklist(dossier)
check_e43 = next(c for c in result.checks if c.code == "E43")
assert check_e43.verdict == "fragile"
alb = next(i for i in check_e43.items if i.element == "Albumine")
assert alb.statut == "absent"
def test_denutrition_sans_rien(self):
"""E43 sans albumine ni IMC → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"),
)
result = build_completude_checklist(dossier)
check_e43 = next(c for c in result.checks if c.code == "E43")
assert check_e43.verdict == "indefendable"
assert check_e43.score < 30
def test_e44_match_aussi(self):
"""E44.0 (dénutrition modérée) doit aussi matcher les règles de dénutrition."""
dossier = _make_dossier(
dp=Diagnostic(texte="Dénutrition modérée", cim10_suggestion="E44.0"),
sejour=Sejour(imc=19.5),
bio=[BiologieCle(test="Albumine", valeur="32 g/L", valeur_num=32.0)],
)
result = build_completude_checklist(dossier)
assert any(c.code == "E44.0" for c in result.checks)
check = next(c for c in result.checks if c.code == "E44.0")
# 32 est dans [30-35] et 19.5 est dans [18.5-21]
alb = next(i for i in check.items if i.element == "Albumine")
assert alb.statut == "present_confirme"
def test_e44_with_e43_seuils_not_applied(self):
"""E44 ne doit pas appliquer les seuils E43 (code_filter)."""
dossier = _make_dossier(
dp=Diagnostic(texte="Dénutrition modérée", cim10_suggestion="E44.1"),
sejour=Sejour(imc=19.0),
bio=[BiologieCle(test="Albumine", valeur="32 g/L", valeur_num=32.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "E44.1")
# Les items avec code_filter=E43 ne doivent PAS apparaître pour E44
# Les items avec code_filter=E44 DOIVENT apparaître
alb = next((i for i in check.items if i.element == "Albumine"), None)
assert alb is not None
# IMC doit utiliser les seuils E44 (range 18.5-21)
imc = next((i for i in check.items if i.element == "IMC"), None)
assert imc is not None
assert imc.statut == "present_confirme" # 19.0 in [18.5, 21]
# ── Tests anémie ─────────────────────────────────────────────────────
class TestAnemie:
def test_anemie_sans_hb(self):
"""D50 sans hémoglobine → indefendable (Hb est obligatoire)."""
dossier = _make_dossier(
das=[Diagnostic(texte="Anémie ferriprive", cim10_suggestion="D50.9")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "D50.9")
assert check.verdict == "indefendable"
def test_anemie_avec_hb_basse(self):
"""D64 avec Hb basse (homme) → present_confirme."""
dossier = _make_dossier(
sejour=Sejour(sexe="M"),
das=[Diagnostic(texte="Anémie", cim10_suggestion="D64.9")],
bio=[BiologieCle(test="Hémoglobine", valeur="9.5 g/dL", valeur_num=9.5)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "D64.9")
assert check.verdict == "defendable"
hb = next(i for i in check.items if i.element == "Hémoglobine")
assert hb.statut == "present_confirme" # 9.5 < 13
def test_anemie_hb_normale(self):
"""D64 avec Hb 14 (homme) → present_non_confirme."""
dossier = _make_dossier(
sejour=Sejour(sexe="M"),
das=[Diagnostic(texte="Anémie", cim10_suggestion="D64.9")],
bio=[BiologieCle(test="Hémoglobine", valeur="14 g/dL", valeur_num=14.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "D64.9")
hb = next(i for i in check.items if i.element == "Hémoglobine")
assert hb.statut == "present_non_confirme"
def test_anemie_seuil_femme(self):
"""D50 avec Hb 12.5 (femme) → non_confirme (seuil femme: < 12)."""
dossier = _make_dossier(
sejour=Sejour(sexe="F"),
das=[Diagnostic(texte="Anémie", cim10_suggestion="D50.9")],
bio=[BiologieCle(test="Hb", valeur="12.5 g/dL", valeur_num=12.5)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "D50.9")
hb = next(i for i in check.items if i.element == "Hémoglobine")
assert hb.statut == "present_non_confirme"
# ── Tests insuffisance rénale ────────────────────────────────────────
class TestInsuffisanceRenale:
def test_ir_avec_creatinine_haute(self):
"""N18 avec créatinine 180 (> 120) → present_confirme."""
dossier = _make_dossier(
das=[Diagnostic(texte="IRC stade 3", cim10_suggestion="N18.3")],
bio=[BiologieCle(test="Créatinine", valeur="180 µmol/L", valeur_num=180.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "N18.3")
assert check.verdict == "defendable"
creat = next(i for i in check.items if i.element == "Créatinine")
assert creat.statut == "present_confirme"
def test_ir_creatinine_normale(self):
"""N18 avec créatinine 90 (≤ 120) → present_non_confirme."""
dossier = _make_dossier(
das=[Diagnostic(texte="IRC", cim10_suggestion="N18.3")],
bio=[BiologieCle(test="Créatinine", valeur="90 µmol/L", valeur_num=90.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "N18.3")
creat = next(i for i in check.items if i.element == "Créatinine")
assert creat.statut == "present_non_confirme"
# ── Tests sepsis ─────────────────────────────────────────────────────
class TestSepsis:
def test_sepsis_complet_confirme(self):
"""A41 avec CRP > 50 + leucocytes > 10 → confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"),
bio=[
BiologieCle(test="CRP", valeur="180 mg/L", valeur_num=180.0),
BiologieCle(test="Leucocytes", valeur="15 G/L", valeur_num=15.0),
],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "A41.9")
assert check.verdict == "defendable"
crp = next(i for i in check.items if i.element == "CRP")
assert crp.statut == "present_confirme" # 180 > 50
leuco = next(i for i in check.items if i.element == "Leucocytes")
assert leuco.statut == "present_confirme" # 15 hors [4-10]
def test_sepsis_leucocytes_normaux(self):
"""A41 avec leucocytes 7 (dans norme) → present_non_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"),
bio=[
BiologieCle(test="CRP", valeur="180 mg/L", valeur_num=180.0),
BiologieCle(test="Leucocytes", valeur="7 G/L", valeur_num=7.0),
],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "A41.9")
leuco = next(i for i in check.items if i.element == "Leucocytes")
assert leuco.statut == "present_non_confirme"
def test_sepsis_sans_bio(self):
"""A41 sans CRP ni leucocytes → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "A41.9")
assert check.verdict == "indefendable"
# ── Tests seuils spécifiques ─────────────────────────────────────────
class TestSeuils:
def test_pancreatite_lipase_haute(self):
"""K85 avec lipase 250 (> 180) → present_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85"),
bio=[BiologieCle(test="Lipase", valeur="250 UI/L", valeur_num=250.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "K85")
lip = next(i for i in check.items if i.element == "Lipasémie")
assert lip.statut == "present_confirme"
def test_pancreatite_lipase_basse(self):
"""K85 avec lipase 120 (≤ 180) → present_non_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85"),
bio=[BiologieCle(test="Lipase", valeur="120 UI/L", valeur_num=120.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "K85")
lip = next(i for i in check.items if i.element == "Lipasémie")
assert lip.statut == "present_non_confirme"
def test_obesite_imc_confirme(self):
"""E66 avec IMC 42 (> 30) → present_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Obésité morbide", cim10_suggestion="E66.0"),
sejour=Sejour(imc=42.0, poids=130.0, taille=176.0),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "E66.0")
imc = next(i for i in check.items if i.element == "IMC")
assert imc.statut == "present_confirme"
def test_obesite_imc_non_confirme(self):
"""E66 avec IMC 25 (< 30) → present_non_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Obésité", cim10_suggestion="E66.9"),
sejour=Sejour(imc=25.0, poids=75.0),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "E66.9")
imc = next(i for i in check.items if i.element == "IMC")
assert imc.statut == "present_non_confirme"
def test_hepatique_transaminases(self):
"""K72 avec ASAT 85 + ALAT 92 → present_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Insuffisance hépatique", cim10_suggestion="K72.0"),
bio=[
BiologieCle(test="ASAT", valeur="85 UI/L", valeur_num=85.0),
BiologieCle(test="ALAT", valeur="92 UI/L", valeur_num=92.0),
],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "K72.0")
asat = next(i for i in check.items if i.element == "ASAT")
alat = next(i for i in check.items if i.element == "ALAT")
assert asat.statut == "present_confirme"
assert alat.statut == "present_confirme"
def test_ic_bnp_confirme(self):
"""I50 avec BNP 450 (> 100) → present_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Insuffisance cardiaque", cim10_suggestion="I50.0"),
bio=[BiologieCle(test="BNP", valeur="450 pg/mL", valeur_num=450.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I50.0")
bnp = next(i for i in check.items if "BNP" in i.element)
assert bnp.statut == "present_confirme"
def test_electrolytes_sodium_bas(self):
"""E87 avec Na 128 (< 135) → present_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="Hyponatrémie", cim10_suggestion="E87.1"),
bio=[
BiologieCle(test="Sodium", valeur="128 mmol/L", valeur_num=128.0),
BiologieCle(test="Potassium", valeur="4.2 mmol/L", valeur_num=4.2),
],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "E87.1")
na = next(i for i in check.items if i.element == "Sodium")
assert na.statut == "present_confirme"
# K normal (4.2 dans [3.5-5.0]) → non confirmé
k = next(i for i in check.items if i.element == "Potassium")
assert k.statut == "present_non_confirme"
def test_bio_sans_valeur_num(self):
"""Bio présente mais sans valeur_num → statut 'present' (pas de confrontation seuil)."""
dossier = _make_dossier(
dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"),
bio=[BiologieCle(test="Créatinine", valeur="élevée")], # pas de valeur_num
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "N18.3")
creat = next(i for i in check.items if i.element == "Créatinine")
assert creat.statut == "present"
# ── Tests preuves cliniques ──────────────────────────────────────────
class TestPreuvesCliniques:
def test_preuve_clinique_indirect(self):
"""Élément absent mais mentionné dans preuves_cliniques → present_indirect."""
dossier = _make_dossier(
dp=Diagnostic(
texte="Sepsis",
cim10_suggestion="A41.9",
preuves_cliniques=[
PreuveClinique(
type="biologie",
element="CRP 180 mg/L",
interpretation="syndrome inflammatoire majeur",
),
],
),
bio=[
# Pas de CRP dans biologie_cle, mais leucocytes oui
BiologieCle(test="Leucocytes", valeur="15 G/L", valeur_num=15.0),
],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "A41.9")
crp = next(i for i in check.items if i.element == "CRP")
assert crp.statut == "present_indirect"
assert crp.valeur == "CRP 180 mg/L"
assert "preuves cliniques" in crp.confirmation_detail.lower()
def test_preuve_clinique_ne_remplace_pas_present(self):
"""Si l'élément est déjà présent, les preuves ne sont pas utilisées."""
dossier = _make_dossier(
dp=Diagnostic(
texte="Sepsis",
cim10_suggestion="A41.9",
preuves_cliniques=[
PreuveClinique(
type="biologie",
element="CRP 180 mg/L",
interpretation="syndrome inflammatoire majeur",
),
],
),
bio=[
BiologieCle(test="CRP", valeur="180 mg/L", valeur_num=180.0),
BiologieCle(test="Leucocytes", valeur="15 G/L", valeur_num=15.0),
],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "A41.9")
crp = next(i for i in check.items if i.element == "CRP")
# Doit être present_confirme, pas present_indirect
assert crp.statut == "present_confirme"
def test_preuve_imagerie_indirect(self):
"""Imagerie absente mais mentionnée dans preuves → present_indirect."""
dossier = _make_dossier(
dp=Diagnostic(
texte="AVC ischémique",
cim10_suggestion="I63.9",
preuves_cliniques=[
PreuveClinique(
type="imagerie",
element="IRM cérébral avec lésion ischémique",
interpretation="AVC ischémique confirmé à l'IRM",
),
],
),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I63.9")
img = next(i for i in check.items if "Scanner/IRM" in i.element)
assert img.statut == "present_indirect"
# ── Tests scoring pondéré ────────────────────────────────────────────
class TestScoringPondere:
def test_present_confirme_full_weight(self):
"""present_confirme compte pour 1.0."""
dossier = _make_dossier(
dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"),
bio=[BiologieCle(test="Créatinine", valeur="200 µmol/L", valeur_num=200.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "N18.3")
assert check.score >= 70
def test_present_non_confirme_reduced_weight(self):
"""present_non_confirme compte pour 0.25 → score réduit."""
dossier = _make_dossier(
dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"),
bio=[BiologieCle(test="Créatinine", valeur="90 µmol/L", valeur_num=90.0)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "N18.3")
# present_non_confirme → 0.25 weight
assert check.score < 70
def test_present_indirect_half_weight(self):
"""present_indirect compte pour 0.5."""
dossier = _make_dossier(
dp=Diagnostic(
texte="Sepsis",
cim10_suggestion="A41.9",
preuves_cliniques=[
PreuveClinique(type="biologie", element="CRP 200 mg/L", interpretation="CRP élevée"),
PreuveClinique(type="biologie", element="Leucocytes 18 G/L", interpretation="hyperleucocytose"),
],
),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "A41.9")
# Les deux obligatoires sont indirect (0.5 chacun)
assert 20 <= check.score <= 60
# ── Tests tumeurs ────────────────────────────────────────────────────
class TestTumeurs:
def test_tumeur_sans_anapath(self):
"""C34 sans ANAPATH → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Cancer bronchique", cim10_suggestion="C34.1"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "C34.1")
assert check.verdict == "indefendable"
assert "ANAPATH" in result.documents_manquants
def test_tumeur_avec_anapath(self):
"""C34 avec ANAPATH dans les fichiers sources → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Cancer bronchique", cim10_suggestion="C34.1"),
source_files=["CRH_patient.pdf", "ANAPATH_biopsie.pdf"],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "C34.1")
assert check.verdict == "defendable"
# ── Tests actes chirurgicaux ─────────────────────────────────────────
class TestActesChirurgicaux:
def test_chirurgie_sans_cro(self):
"""Acte CCAM chirurgical sans CRO → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Cholécystite", cim10_suggestion="K80.1"),
actes=[ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004")],
)
result = build_completude_checklist(dossier)
acte_check = next((c for c in result.checks if c.type_diag == "Acte"), None)
assert acte_check is not None
assert acte_check.verdict == "indefendable"
assert "CRO" in result.documents_manquants
def test_chirurgie_avec_cro(self):
"""Acte chirurgical avec CRO → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Cholécystite", cim10_suggestion="K80.1"),
actes=[ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004")],
source_files=["CRH_patient.pdf", "CRO_cholecystectomie.pdf"],
)
result = build_completude_checklist(dossier)
acte_check = next((c for c in result.checks if c.type_diag == "Acte"), None)
assert acte_check is not None
assert acte_check.verdict == "defendable"
# ── Tests embolie pulmonaire ─────────────────────────────────────────
class TestEmboliePulmonaire:
def test_ep_avec_scanner(self):
"""I26 avec angioscanner → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Embolie pulmonaire", cim10_suggestion="I26.0"),
imagerie=[Imagerie(type="Angioscanner thoracique", conclusion="EP bilatérale")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I26.0")
assert check.verdict == "defendable"
def test_ep_sans_imagerie(self):
"""I26 sans imagerie → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Embolie pulmonaire", cim10_suggestion="I26.9"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I26.9")
assert check.verdict == "indefendable"
# ── Tests 8 nouvelles familles ───────────────────────────────────────
class TestNouvellesFamilles:
def test_avc_avec_scanner(self):
"""I63 avec scanner cérébral → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="AVC ischémique", cim10_suggestion="I63.3"),
imagerie=[Imagerie(type="Scanner cérébral", conclusion="Ischémie sylvienne gauche")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I63.3")
assert check.verdict == "defendable"
def test_avc_sans_imagerie(self):
"""I63 sans imagerie → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="AVC ischémique", cim10_suggestion="I63.3"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I63.3")
assert check.verdict == "indefendable"
def test_idm_avec_troponine_haute(self):
"""I21 avec troponine 0.5 (> 0.04) → present_confirme."""
dossier = _make_dossier(
dp=Diagnostic(texte="IDM", cim10_suggestion="I21.0"),
bio=[BiologieCle(test="Troponine I", valeur="0.5 ng/mL", valeur_num=0.5)],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I21.0")
tropo = next(i for i in check.items if i.element == "Troponine")
assert tropo.statut == "present_confirme"
def test_idm_sans_troponine(self):
"""I21 sans troponine → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="IDM", cim10_suggestion="I21.0"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I21.0")
assert check.verdict == "indefendable"
def test_pneumopathie_avec_radio(self):
"""J18 avec radio thorax → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Pneumopathie", cim10_suggestion="J18.9"),
imagerie=[Imagerie(type="Radio thorax", conclusion="Foyer alvéolaire droit")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "J18.9")
assert check.verdict == "defendable"
def test_tvp_avec_echodoppler(self):
"""I80 avec écho-doppler → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="TVP", cim10_suggestion="I80.2"),
imagerie=[Imagerie(type="Écho-doppler veineux MI", conclusion="TVP fémorale")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I80.2")
assert check.verdict == "defendable"
def test_tvp_sans_imagerie(self):
"""I80 sans écho-doppler → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="TVP", cim10_suggestion="I80.2"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I80.2")
assert check.verdict == "indefendable"
def test_insuff_resp_avec_gds(self):
"""J96 avec gaz du sang → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Insuffisance respiratoire", cim10_suggestion="J96.0"),
bio=[BiologieCle(test="Gaz du sang", valeur="PaO2 55 mmHg")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "J96.0")
assert check.verdict == "defendable"
def test_fracture_avec_radio(self):
"""S72 avec imagerie → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Fracture col fémoral", cim10_suggestion="S72.0"),
imagerie=[Imagerie(type="Radiographie bassin", conclusion="Fracture cervicale vraie")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "S72.0")
assert check.verdict == "defendable"
def test_fracture_sans_imagerie(self):
"""S72 sans imagerie → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Fracture col fémoral", cim10_suggestion="S72.0"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "S72.0")
assert check.verdict == "indefendable"
def test_iu_avec_ecbu(self):
"""N39.0 avec ECBU → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Infection urinaire", cim10_suggestion="N39.0"),
bio=[BiologieCle(test="ECBU", valeur="E.coli > 10^5")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "N39.0")
assert check.verdict == "defendable"
def test_iu_sans_ecbu(self):
"""N39.0 sans ECBU → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Infection urinaire", cim10_suggestion="N39.0"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "N39.0")
assert check.verdict == "indefendable"
def test_fa_avec_ecg(self):
"""I48 avec ECG → defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="FA", cim10_suggestion="I48.0"),
bio=[BiologieCle(test="ECG", valeur="FA rapide")],
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I48.0")
assert check.verdict == "defendable"
def test_fa_sans_ecg(self):
"""I48 sans ECG → indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="FA", cim10_suggestion="I48.0"),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "I48.0")
assert check.verdict == "indefendable"
def test_ait_g45(self):
"""G45 (AIT) doit aussi matcher la famille AVC/AIT."""
dossier = _make_dossier(
dp=Diagnostic(texte="AIT", cim10_suggestion="G45.9"),
imagerie=[Imagerie(type="IRM cérébral", conclusion="Pas de lésion récente")],
)
result = build_completude_checklist(dossier)
assert any(c.code == "G45.9" for c in result.checks)
check = next(c for c in result.checks if c.code == "G45.9")
assert check.verdict == "defendable"
# ── Tests sans règle applicable ──────────────────────────────────────
class TestSansRegle:
def test_code_sans_regle(self):
"""Code sans règle applicable → pas de check."""
dossier = _make_dossier(
dp=Diagnostic(texte="Grippe", cim10_suggestion="J11.1"),
)
result = build_completude_checklist(dossier)
assert not any(c.code == "J11.1" for c in result.checks)
# Score global par défaut (pas de checks → pas de verdict)
assert result.score_global == 100
def test_dossier_vide(self):
"""Dossier sans codes → pas de checks."""
dossier = _make_dossier()
result = build_completude_checklist(dossier)
assert result.checks == []
assert result.score_global == 100
# ── Tests verdict global ─────────────────────────────────────────────
class TestVerdictGlobal:
def test_mix_defendable_et_fragile(self):
"""Un code defendable + un fragile → verdict global fragile."""
dossier = _make_dossier(
dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"),
das=[Diagnostic(texte="Dénutrition", cim10_suggestion="E43")],
bio=[BiologieCle(test="Créatinine", valeur="180", valeur_num=180.0)],
# E43 n'a ni albumine ni IMC → indefendable
)
result = build_completude_checklist(dossier)
assert result.verdict_global in ("fragile", "indefendable")
def test_tous_defendables(self):
"""Tous les codes defendables → verdict global defendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"),
bio=[BiologieCle(test="Créatinine", valeur="180", valeur_num=180.0)],
)
result = build_completude_checklist(dossier)
assert result.verdict_global == "defendable"
# ── Tests DAS ruled_out (ignorés) ────────────────────────────────────
class TestDasRuledOut:
def test_das_ruled_out_ignore(self):
"""Un DAS ruled_out ne doit pas apparaître dans les checks."""
dossier = _make_dossier(
das=[
Diagnostic(texte="Anémie", cim10_suggestion="D50.9", status="ruled_out"),
],
)
result = build_completude_checklist(dossier)
assert not any(c.code == "D50.9" for c in result.checks)
# ── Tests documents présents ─────────────────────────────────────────
class TestDocumentsPresents:
def test_documents_listes(self):
"""Les types de documents sont listés."""
dossier = _make_dossier(
dp=Diagnostic(texte="Test", cim10_suggestion="J18.9"),
document_type="crh",
source_files=["CRH_1.pdf", "CRO_op.pdf"],
imagerie=[Imagerie(type="Radio thorax", conclusion="Normal")],
)
result = build_completude_checklist(dossier)
assert "crh" in result.documents_presents
assert "cro" in result.documents_presents
# ── Tests obésité ────────────────────────────────────────────────────
class TestObesite:
def test_obesite_avec_imc_et_poids(self):
"""E66 avec IMC 42 + poids → defendable, score élevé."""
dossier = _make_dossier(
dp=Diagnostic(texte="Obésité morbide", cim10_suggestion="E66.0"),
sejour=Sejour(imc=42.0, poids=130.0, taille=176.0),
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "E66.0")
assert check.verdict == "defendable"
assert check.score == 100
def test_obesite_sans_imc(self):
"""E66 sans IMC → fragile/indefendable."""
dossier = _make_dossier(
dp=Diagnostic(texte="Obésité", cim10_suggestion="E66.9"),
sejour=Sejour(poids=130.0), # Poids mais pas d'IMC
)
result = build_completude_checklist(dossier)
check = next(c for c in result.checks if c.code == "E66.9")
assert check.verdict in ("fragile", "indefendable")
# ── Test intégration pipeline ────────────────────────────────────────
class TestIntegration:
def test_completude_dans_dossier_medical(self):
"""Le champ completude existe et accepte un CompletudeDossier."""
dossier = DossierMedical()
assert dossier.completude is None
dossier.completude = build_completude_checklist(dossier)
assert isinstance(dossier.completude, CompletudeDossier)
def test_serialization_json(self):
"""Le résultat se sérialise en JSON sans erreur."""
dossier = _make_dossier(
dp=Diagnostic(texte="Dénutrition", cim10_suggestion="E43"),
sejour=Sejour(imc=16.5),
bio=[BiologieCle(test="Albumine", valeur="28 g/L", valeur_num=28.0)],
)
result = build_completude_checklist(dossier)
json_str = result.model_dump_json()
assert "E43" in json_str
assert "defendable" in json_str
# Le nouveau champ doit apparaître
assert "confirmation_detail" in json_str
def test_confirmation_detail_in_model(self):
"""Le champ confirmation_detail est bien sérialisé."""
item = ItemCompletude(
categorie="biologie",
element="Albumine",
statut="present_confirme",
valeur="28 g/L",
importance="obligatoire",
confirmation_detail="Albumine 28 g/L < 30 → confirme E43",
)
data = item.model_dump()
assert data["confirmation_detail"] == "Albumine 28 g/L < 30 → confirme E43"
assert data["statut"] == "present_confirme"

View File

@@ -1,5 +1,6 @@
"""Tests pour la génération de contre-argumentation CPAM."""
import os
from unittest.mock import patch, call
import pytest
@@ -1659,6 +1660,7 @@ class TestBuildBioSummary:
assert "CRP" not in summary
@patch.dict(os.environ, {"T2A_CPAM_MAX_CORRECTIONS": "2"})
class TestCorrectionLoop:
"""Tests pour la boucle de correction adversariale."""

View File

@@ -0,0 +1,417 @@
"""Tests pour l'extraction multi-format (PDF, images, DOCX)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.extraction.document_router import (
SUPPORTED_EXTENSIONS,
extract_document_with_pages,
)
from src.extraction.page_tracker import PageTracker
from src.extraction.pdf_extractor import (
ExtractionMethod,
ExtractionStats,
_compute_extraction_stats,
)
# ---------------------------------------------------------------------------
# Tests du router — dispatch par extension
# ---------------------------------------------------------------------------
class TestDocumentRouter:
"""Tests unitaires du dispatch par extension."""
def test_router_pdf_dispatches_correctly(self, tmp_path):
"""Un fichier .pdf est dispatché vers extract_text_with_pages."""
pdf_file = tmp_path / "test.pdf"
pdf_file.touch()
mock_stats = ExtractionStats(total_pages=1, source_format="pdf")
mock_tracker = PageTracker([(0, 10)])
mock_return = ("texte pdf", mock_tracker, mock_stats)
with patch(
"src.extraction.pdf_extractor.extract_text_with_pages",
return_value=mock_return,
):
result = extract_document_with_pages(pdf_file)
assert len(result) == 3
assert result[0] == "texte pdf"
assert result[2].source_format == "pdf"
def test_router_image_dispatches_correctly(self, tmp_path):
"""Un fichier .png est dispatché vers extract_text_from_image."""
png_file = tmp_path / "test.png"
png_file.touch()
mock_stats = ExtractionStats(
total_pages=1, source_format="image",
methods=[ExtractionMethod.IMAGE],
)
mock_tracker = PageTracker([(0, 10)])
mock_return = ("texte ocr", mock_tracker, mock_stats)
with patch(
"src.extraction.image_extractor.extract_text_from_image",
return_value=mock_return,
):
result = extract_document_with_pages(png_file)
assert len(result) == 3
assert result[0] == "texte ocr"
assert result[2].source_format == "image"
def test_router_docx_dispatches_correctly(self, tmp_path):
"""Un fichier .docx est dispatché vers extract_text_from_docx."""
docx_file = tmp_path / "test.docx"
docx_file.touch()
mock_stats = ExtractionStats(
total_pages=1, source_format="docx",
methods=[ExtractionMethod.DOCX],
)
mock_tracker = PageTracker([(0, 10)])
mock_return = ("texte docx", mock_tracker, mock_stats)
with patch(
"src.extraction.docx_extractor.extract_text_from_docx",
return_value=mock_return,
):
result = extract_document_with_pages(docx_file)
assert len(result) == 3
assert result[0] == "texte docx"
assert result[2].source_format == "docx"
def test_router_unsupported_extension_raises(self, tmp_path):
"""Une extension non supportée lève ValueError."""
xyz_file = tmp_path / "test.xyz"
xyz_file.touch()
with pytest.raises(ValueError, match="Format non supporté"):
extract_document_with_pages(xyz_file)
def test_router_supported_extensions_complete(self):
"""Vérifie que SUPPORTED_EXTENSIONS contient tous les formats prévus."""
expected = {".pdf", ".jpg", ".jpeg", ".png", ".tiff", ".tif", ".docx"}
assert SUPPORTED_EXTENSIONS == expected
@pytest.mark.parametrize("ext", [".jpg", ".jpeg", ".tiff", ".tif"])
def test_router_all_image_extensions(self, tmp_path, ext):
"""Toutes les extensions image sont reconnues."""
img_file = tmp_path / f"test{ext}"
img_file.touch()
mock_stats = ExtractionStats(total_pages=1, source_format="image")
mock_tracker = PageTracker([(0, 5)])
with patch(
"src.extraction.image_extractor.extract_text_from_image",
return_value=("texte", mock_tracker, mock_stats),
):
result = extract_document_with_pages(img_file)
assert result[2].source_format == "image"
# ---------------------------------------------------------------------------
# Tests ExtractionStats enrichi
# ---------------------------------------------------------------------------
class TestExtractionStats:
"""Tests des nouveaux champs d'ExtractionStats."""
def test_stats_tracks_method(self):
"""Vérifie que methods/backend/source_format sont renseignés."""
methods = [
ExtractionMethod.NATIVE_PDFPLUMBER,
ExtractionMethod.NATIVE_PDFPLUMBER,
ExtractionMethod.OCR_DOCTR,
]
stats = _compute_extraction_stats(
["page 1 avec du texte", "page 2 avec du texte", "page 3 ocr"],
methods=methods,
backend="pdfplumber",
)
assert stats.methods == methods
assert stats.backend == "pdfplumber"
assert stats.source_format == "pdf"
assert stats.native_pages == 2
assert stats.ocr_pages == 1
def test_stats_default_values(self):
"""Les valeurs par défaut sont correctes."""
stats = ExtractionStats()
assert stats.methods == []
assert stats.native_pages == 0
assert stats.ocr_pages == 0
assert stats.backend == "pdfplumber"
assert stats.source_format == "pdf"
def test_stats_image_format(self):
"""ExtractionStats pour une image."""
stats = ExtractionStats(
total_pages=1,
source_format="image",
methods=[ExtractionMethod.IMAGE],
backend="doctr",
)
assert stats.source_format == "image"
assert stats.methods[0] == ExtractionMethod.IMAGE
def test_stats_docx_format(self):
"""ExtractionStats pour un DOCX."""
stats = ExtractionStats(
total_pages=3,
source_format="docx",
methods=[ExtractionMethod.DOCX] * 3,
backend="python-docx",
native_pages=3,
)
assert stats.source_format == "docx"
assert len(stats.methods) == 3
assert all(m == ExtractionMethod.DOCX for m in stats.methods)
def test_compute_stats_with_methods(self):
"""_compute_extraction_stats calcule correctement native_pages et ocr_pages."""
pages = ["Hello world" * 10, "", "Texte OCR récupéré"]
methods = [
ExtractionMethod.NATIVE_PDFPLUMBER,
ExtractionMethod.OCR_DOCTR,
ExtractionMethod.OCR_DOCTR,
]
stats = _compute_extraction_stats(pages, methods, "pdfplumber")
assert stats.total_pages == 3
assert stats.native_pages == 1
assert stats.ocr_pages == 2
assert 2 in stats.empty_pages # page 2 (1-indexed) est vide
def test_extraction_method_enum_values(self):
"""Vérifie les valeurs de l'enum ExtractionMethod."""
assert ExtractionMethod.NATIVE_PDFPLUMBER.value == "native_pdfplumber"
assert ExtractionMethod.NATIVE_PYMUPDF.value == "native_pymupdf"
assert ExtractionMethod.OCR_DOCTR.value == "ocr_doctr"
assert ExtractionMethod.DOCX.value == "docx"
assert ExtractionMethod.IMAGE.value == "image_ocr"
# ---------------------------------------------------------------------------
# Tests OCR fallback
# ---------------------------------------------------------------------------
class TestOCRFallback:
"""Tests du mécanisme de fallback OCR."""
def test_ocr_fallback_disabled_by_default(self):
"""Le fallback OCR est désactivé par défaut."""
from src.extraction.pdf_extractor import OCR_FALLBACK_ENABLED
# Par défaut (sans variable d'environnement), le fallback est désactivé
# Note : ce test vérifie le comportement par défaut, pas une variable statique
# car elle peut être modifiée par les variables d'environnement du CI
assert isinstance(OCR_FALLBACK_ENABLED, bool)
def test_ocr_fallback_config_values(self):
"""Les constantes de config sont cohérentes."""
from src.extraction.pdf_extractor import OCR_FALLBACK_MIN_CHARS, PDF_BACKEND
assert isinstance(OCR_FALLBACK_MIN_CHARS, int)
assert OCR_FALLBACK_MIN_CHARS > 0
assert PDF_BACKEND in ("pdfplumber", "pymupdf")
# ---------------------------------------------------------------------------
# Tests DOCX extracteur (avec fixture)
# ---------------------------------------------------------------------------
class TestDocxExtractor:
"""Tests de l'extracteur DOCX."""
@pytest.fixture
def sample_docx(self, tmp_path):
"""Crée un petit DOCX de test."""
try:
from docx import Document
except ImportError:
pytest.skip("python-docx non installé")
doc = Document()
doc.add_paragraph("Premier paragraphe du document médical.")
doc.add_paragraph("Diagnostic principal : Pneumopathie J18.9")
doc.add_paragraph("Traitement de sortie : Amoxicilline 1g x3/j")
docx_path = tmp_path / "test_medical.docx"
doc.save(str(docx_path))
return docx_path
@pytest.fixture
def docx_with_page_breaks(self, tmp_path):
"""Crée un DOCX avec des sauts de page."""
try:
from docx import Document
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
except ImportError:
pytest.skip("python-docx non installé")
doc = Document()
doc.add_paragraph("Page 1 : Antécédents du patient.")
# Ajouter un saut de page
p = doc.add_paragraph()
run = p.add_run()
br = OxmlElement("w:br")
br.set(qn("w:type"), "page")
run._element.append(br)
doc.add_paragraph("Page 2 : Compte-rendu opératoire.")
docx_path = tmp_path / "test_pages.docx"
doc.save(str(docx_path))
return docx_path
def test_extract_docx_basic(self, sample_docx):
"""Extraction basique d'un DOCX."""
from src.extraction.docx_extractor import extract_text_from_docx
text, tracker, stats = extract_text_from_docx(sample_docx)
assert "Pneumopathie" in text
assert "Amoxicilline" in text
assert stats.source_format == "docx"
assert stats.total_pages >= 1
assert stats.total_chars > 0
assert all(m == ExtractionMethod.DOCX for m in stats.methods)
def test_extract_docx_with_page_breaks(self, docx_with_page_breaks):
"""Extraction d'un DOCX avec sauts de page."""
from src.extraction.docx_extractor import extract_text_from_docx
text, tracker, stats = extract_text_from_docx(docx_with_page_breaks)
assert stats.total_pages == 2
assert "Antécédents" in text
assert "Compte-rendu" in text
# PageTracker fonctionne
assert tracker.char_to_page(0) == 1
def test_extract_docx_file_not_found(self, tmp_path):
"""FileNotFoundError si le fichier n'existe pas."""
from src.extraction.docx_extractor import extract_text_from_docx
with pytest.raises(FileNotFoundError):
extract_text_from_docx(tmp_path / "inexistant.docx")
def test_extract_docx_stats_backend(self, sample_docx):
"""Le backend est bien 'python-docx'."""
from src.extraction.docx_extractor import extract_text_from_docx
_, _, stats = extract_text_from_docx(sample_docx)
assert stats.backend == "python-docx"
# ---------------------------------------------------------------------------
# Tests image extracteur (mock OCR)
# ---------------------------------------------------------------------------
class TestImageExtractor:
"""Tests de l'extracteur d'images (avec OCR mocké)."""
def test_extract_image_file_not_found(self, tmp_path):
"""FileNotFoundError si l'image n'existe pas."""
from src.extraction.image_extractor import extract_text_from_image
with pytest.raises(FileNotFoundError):
extract_text_from_image(tmp_path / "inexistant.png")
def test_extract_image_stats_format(self, tmp_path):
"""Vérifie le format des stats pour une image."""
# Créer une petite image PNG
from PIL import Image
img = Image.new("RGB", (100, 50), color="white")
img_path = tmp_path / "test.png"
img.save(str(img_path))
with patch("src.extraction.image_extractor.ocr_image", return_value="Texte OCR extrait"):
from src.extraction.image_extractor import extract_text_from_image
text, tracker, stats = extract_text_from_image(img_path)
assert text == "Texte OCR extrait"
assert stats.source_format == "image"
assert stats.total_pages == 1
assert stats.ocr_pages == 1
assert stats.native_pages == 0
assert stats.methods == [ExtractionMethod.IMAGE]
assert stats.backend == "doctr"
def test_extract_image_empty_result(self, tmp_path):
"""Image sans texte détectable."""
from PIL import Image
img = Image.new("RGB", (100, 50), color="white")
img_path = tmp_path / "blank.png"
img.save(str(img_path))
with patch("src.extraction.image_extractor.ocr_image", return_value=""):
from src.extraction.image_extractor import extract_text_from_image
text, tracker, stats = extract_text_from_image(img_path)
assert text == ""
assert stats.empty_pages == [1]
assert stats.total_chars == 0
# ---------------------------------------------------------------------------
# Tests de non-régression
# ---------------------------------------------------------------------------
class TestBackwardCompat:
"""Tests de rétrocompatibilité."""
def test_process_pdf_alias_exists(self):
"""process_pdf est un alias de process_document."""
from src.main import process_document, process_pdf
assert process_pdf is process_document
def test_extraction_stats_existing_properties(self):
"""Les propriétés existantes d'ExtractionStats fonctionnent toujours."""
stats = ExtractionStats(
total_pages=5,
empty_pages=[2, 4],
chars_per_page=[100, 0, 200, 0, 300],
total_chars=600,
)
assert stats.usable_pages == 3
assert stats.coverage_ratio == 0.6
assert stats.has_quality_issues() is True
alert = stats.to_alert()
assert alert is not None
assert "2/5" in alert
flags = stats.to_flags()
assert flags["extraction_empty_pages"] == [2, 4]
assert flags["extraction_total_pages"] == 5
assert flags["extraction_coverage"] == 0.6
def test_extraction_stats_no_issues(self):
"""Pas d'alerte quand tout va bien."""
stats = ExtractionStats(
total_pages=3,
chars_per_page=[100, 200, 300],
total_chars=600,
)
assert not stats.has_quality_issues()
assert stats.to_alert() is None
assert stats.to_flags() == {}

View File

@@ -0,0 +1,198 @@
"""Tests unitaires pour la détection dénutrition HAS/FFN 2021."""
import re
import pytest
from src.config import BiologieCle, Diagnostic, DossierMedical, Sejour
from src.medical.diagnostic_extraction import _detect_nutrition_has2021, _DAS_PATTERNS
from src.medical.cim10_dict import normalize_text
# ── Helpers ──────────────────────────────────────────────────────────
def _make_dossier(age=None, imc=None, albumine=None, existing_codes=None):
"""Construit un DossierMedical minimal pour les tests."""
dossier = DossierMedical()
dossier.sejour = Sejour(age=age, imc=imc)
if albumine is not None:
dossier.biologie_cle.append(
BiologieCle(
test="Albumine",
valeur=str(albumine),
valeur_num=float(albumine),
anomalie=True,
quality="ok",
)
)
for code in (existing_codes or []):
dossier.diagnostics_associes.append(
Diagnostic(texte="existant", cim10_suggestion=code, source="test")
)
return dossier
# ── Tests _detect_nutrition_has2021 ──────────────────────────────────
class TestDetectNutritionHAS2021:
"""Tests de la détection déterministe basée sur IMC/âge/albumine."""
def test_adulte_imc17_albumine28_gives_E43(self):
"""Adulte IMC 17.0 + albumine 28 → E43 (sévère via IMC ≤17 ET albumine <30)."""
dossier = _make_dossier(age=50, imc=17.0, albumine=28)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "E43" in codes
def test_adulte_imc18_sans_albumine_gives_E44(self):
"""Adulte IMC 18.0 sans albumine → E44.0 (modéré, 17 < IMC < 18.5)."""
dossier = _make_dossier(age=45, imc=18.0)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "E44.0" in codes
def test_personne_agee_75_imc21_gives_E44(self):
"""≥70 ans IMC 21.0 → E44.0 (seuil gériatrique < 22)."""
dossier = _make_dossier(age=75, imc=21.0)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "E44.0" in codes
def test_personne_agee_75_imc19_gives_E43(self):
"""≥70 ans IMC 19.0 → E43 (sévère, IMC < 20)."""
dossier = _make_dossier(age=75, imc=19.0)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "E43" in codes
def test_adulte_imc25_no_das(self):
"""Adulte IMC 25.0 → aucun DAS (au-dessus du seuil)."""
dossier = _make_dossier(age=40, imc=25.0)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert not any(c in codes for c in ("E43", "E44.0", "E46"))
def test_e46_deja_code_no_ajout(self):
"""E46 déjà codé → aucun ajout."""
dossier = _make_dossier(age=50, imc=16.0, existing_codes=["E46"])
_detect_nutrition_has2021(dossier)
e_codes = [d.cim10_suggestion for d in dossier.diagnostics_associes
if d.cim10_suggestion in ("E43", "E44.0", "E46")]
# Seul le E46 existant doit être présent
assert e_codes == ["E46"]
def test_pas_imc_no_ajout(self):
"""Pas d'IMC → aucun ajout (dégradation gracieuse)."""
dossier = _make_dossier(age=50, imc=None)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert not any(c in codes for c in ("E43", "E44.0", "E46"))
def test_albumine_upgrade_severity(self):
"""IMC modéré + albumine < 30 → upgrade vers E43."""
dossier = _make_dossier(age=50, imc=18.0, albumine=25)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "E43" in codes # Albumine < 30 → sévère
def test_alerte_codage_added(self):
"""Vérifie qu'une alerte codage est ajoutée."""
dossier = _make_dossier(age=50, imc=17.0)
_detect_nutrition_has2021(dossier)
assert any("HAS 2021" in a for a in dossier.alertes_codage)
def test_source_is_has2021(self):
"""Vérifie que la source est 'has2021'."""
dossier = _make_dossier(age=50, imc=17.0)
_detect_nutrition_has2021(dossier)
has_diags = [d for d in dossier.diagnostics_associes if d.source == "has2021"]
assert len(has_diags) == 1
def test_age_inconnu_seuils_adulte(self):
"""Âge inconnu → seuils adulte par défaut."""
dossier = _make_dossier(age=None, imc=17.0)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "E43" in codes # IMC ≤ 17 → sévère (seuils adulte)
def test_personne_agee_70_exact_seuil(self):
"""70 ans exactement → utilise les seuils gériatriques."""
dossier = _make_dossier(age=70, imc=21.5)
_detect_nutrition_has2021(dossier)
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "E44.0" in codes # < 22 → modéré avec seuils ≥70
# ── Tests regex albumine (bio_extraction) ────────────────────────────
class TestAlbumineRegex:
"""Vérifie l'extraction regex de l'albumine."""
ALBUMINE_PATTERN = r"(?:[Aa]lbumin[ée]?(?:mie)?|[Aa]lb(?:u)?[ée]?(?:mie)?)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/[Ll])?"
PREALBUMINE_PATTERN = r"(?:[Pp]r[ée]albumine|[Tt]ransthyr[ée]tine)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll]|g/[Ll])?"
def test_albumine_standard(self):
m = re.search(self.ALBUMINE_PATTERN, "Albumine = 28 g/L")
assert m and m.group(1) == "28"
def test_albumine_colon(self):
m = re.search(self.ALBUMINE_PATTERN, "albumine: 32.5 g/L")
assert m and m.group(1) == "32.5"
def test_albumine_sans_unite(self):
m = re.search(self.ALBUMINE_PATTERN, "Albumine 28")
assert m and m.group(1) == "28"
def test_albuminemie(self):
m = re.search(self.ALBUMINE_PATTERN, "Albuminémie à 25 g/L")
assert m and m.group(1) == "25"
def test_prealbumine(self):
m = re.search(self.PREALBUMINE_PATTERN, "Préalbumine = 0.15 g/L")
assert m and m.group(1) == "0.15"
def test_transthyretine(self):
m = re.search(self.PREALBUMINE_PATTERN, "Transthyrétine: 180 mg/L")
assert m and m.group(1) == "180"
# ── Tests regex texte dénutrition (DAS patterns) ────────────────────
class TestDenutritionRegexSeverity:
"""Vérifie que les patterns textuels de dénutrition détectent la sévérité."""
def _match_pattern(self, text):
"""Retourne le (label, code) du premier pattern DAS matché."""
text_norm = normalize_text(text.lower())
for pat, label, code in _DAS_PATTERNS:
if re.search(pat, text_norm):
return label, code
return None, None
def test_denutrition_severe_gives_E43(self):
_, code = self._match_pattern("denutrition severe")
assert code == "E43"
def test_denutrition_moderee_gives_E44(self):
_, code = self._match_pattern("denutrition moderee")
assert code == "E44.0"
def test_denutrition_generic_gives_E46(self):
_, code = self._match_pattern("denutrition")
assert code == "E46"
def test_malnutrition_severe_gives_E43(self):
_, code = self._match_pattern("malnutrition severe")
assert code == "E43"
def test_denutrition_grade_iii_gives_E43(self):
_, code = self._match_pattern("denutrition grade III")
assert code == "E43"
def test_hypoalbuminemie_severe_gives_E46(self):
"""hypoalbuminemie severe → E46 (pattern générique)."""
_, code = self._match_pattern("hypoalbuminemie severe")
assert code == "E46"

View File

@@ -0,0 +1,364 @@
"""Tests d'intégration : détection dénutrition HAS/FFN 2021 sur dossiers réalistes.
4 cas cliniques réalistes passés dans le pipeline complet extract_medical_info.
Vérifie l'interaction entre regex bio, détection HAS 2021, conflits, et sévérité CMA.
"""
import pytest
from src.config import DossierMedical
from src.medical.cim10_extractor import extract_medical_info
# ── Cas 1 : Personne âgée dénutrie (≥70 ans, seuils gériatriques) ───
class TestCas1PersonneAgeeDenutrie:
"""Mme D., 81 ans, hospitalisée pour pneumopathie.
IMC 20.5 → sous le seuil gériatrique < 22 → dénutrition modérée.
Albumine 27 g/L → < 30 → critère de sévérité → upgrade vers E43.
Attendu : E43 (dénutrition sévère) détecté par HAS 2021.
"""
@pytest.fixture
def dossier(self) -> DossierMedical:
parsed = {
"type": "crh",
"patient": {
"sexe": "F",
"date_naissance": "15/03/1943",
},
"sejour": {
"date_entree": "10/01/2025",
"date_sortie": "20/01/2025",
},
"diagnostics": [],
"signes_vitaux": {"imc": 20.5, "poids_kg": 48, "taille_cm": 153},
}
text = """\
Votre patiente née le 15/03/1943 a été hospitalisée du 10/01/2025 au 20/01/2025.
Antécédents :
- Hypertension artérielle
- Fibrillation auriculaire paroxystique
- Arthrose invalidante
Au total : Pneumopathie basale droite d'évolution favorable sous antibiothérapie.
Biologie d'entrée :
CRP = 145 mg/L
Albumine = 27 g/L
Créatinine = 95 µmol/L
Sodium = 138 mmol/L
Potassium = 4.1 mmol/L
Hémoglobine = 10.2 g/dL
IMC: 20.5
TTT de sortie :
Amoxicilline 1g matin et soir pendant 5 jours
Paracétamol si besoin
Devenir : retour à domicile."""
return extract_medical_info(parsed, text)
def test_age_81_ans(self, dossier):
assert dossier.sejour.age == 81
def test_imc_extrait(self, dossier):
assert dossier.sejour.imc == 20.5
def test_albumine_extraite(self, dossier):
"""L'albumine doit être extraite par le nouveau regex."""
alb = [b for b in dossier.biologie_cle if b.test == "Albumine"]
assert len(alb) >= 1
assert alb[0].valeur_num == 27.0
def test_denutrition_severe_E43(self, dossier):
"""IMC 20.5 (modéré ≥70 ans) + albumine 27 (<30) → E43 sévère."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E43" in codes, f"E43 attendu, trouvé : {codes}"
def test_source_has2021(self, dossier):
has_diags = [d for d in dossier.diagnostics_associes if d.source == "has2021"]
assert len(has_diags) == 1
assert has_diags[0].cim10_suggestion == "E43"
def test_alerte_has2021(self, dossier):
assert any("HAS 2021" in a for a in dossier.alertes_codage)
def test_pneumopathie_detectee(self, dossier):
"""Le DP/DAS pneumopathie ne doit pas être impacté."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
# Pneumopathie J18.9 ou DP
all_codes = codes.copy()
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
all_codes.add(dossier.diagnostic_principal.cim10_suggestion)
assert "J18.9" in all_codes
def test_hta_detectee(self, dossier):
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "I10" in codes
# ── Cas 2 : Patient obèse ET dénutri (conflit E66 + E43 = MEDIUM) ───
class TestCas2ObeseDenutri:
"""M. B., 58 ans, hospitalisé pour pancréatite aiguë sur obésité.
IMC 35.2 → obèse (E66.0). Dénutrition sévère mentionnée dans le texte (E43).
Le conflit E66/dénutrition doit être MEDIUM (pas HARD) selon HAS 2021.
La coexistence est cliniquement possible (sarcopénie de l'obèse).
DP fourni via Trackare (K85.9) pour éviter que NUKE-3 ne réorganise les codes.
"""
@pytest.fixture
def dossier(self) -> DossierMedical:
parsed = {
"type": "trackare",
"patient": {
"sexe": "M",
"date_naissance": "22/07/1967",
},
"sejour": {
"date_entree": "05/02/2025",
"date_sortie": "12/02/2025",
},
"diagnostics": [
{
"type": "Principal",
"statut": "actif",
"code_cim10": "K85.9",
"libelle": "Pancréatite aiguë",
}
],
"signes_vitaux": {"imc": 35.2, "poids_kg": 110, "taille_cm": 177},
}
text = """\
Pancréatite aiguë sur terrain d'obésité morbide.
Dénutrition sévère protéique avec sarcopénie documentée,
perte de poids de 15 kg en 3 mois.
Antécédents :
- Diabète type 2
- Tabagisme actif
Biologie :
CRP = 145 mg/L
Albumine = 25 g/L
Lipasémie = 1200 UI/L
HbA1c = 7.8 %
Hémoglobine = 13.5 g/dL
IMC: 35.2
TTT de sortie :
Metformine 1000mg matin et soir
Paracétamol si besoin
Devenir : retour à domicile."""
return extract_medical_info(parsed, text)
def test_dp_pancreatite(self, dossier):
"""DP = K85.9 (fourni par Trackare)."""
assert dossier.diagnostic_principal is not None
assert dossier.diagnostic_principal.cim10_suggestion == "K85.9"
def test_obesite_E66_detectee(self, dossier):
"""L'obésité doit être détectée via IMC ≥ 30."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E66.0" in codes
def test_denutrition_severe_regex_E43(self, dossier):
"""'Dénutrition sévère' dans le texte → E43 via regex."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E43" in codes, f"E43 attendu via regex 'denutrition severe', trouvé : {codes}"
def test_coexistence_E66_E43(self, dossier):
"""E66.0 et E43 doivent coexister (pas de blocage HARD)."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E66.0" in codes and "E43" in codes
def test_albumine_extraite(self, dossier):
alb = [b for b in dossier.biologie_cle if b.test == "Albumine"]
assert len(alb) >= 1
assert alb[0].valeur_num == 25.0
def test_diabete_detecte(self, dossier):
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E11.9" in codes
def test_tabagisme_detecte(self, dossier):
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "F17.2" in codes
# ── Cas 3 : Adulte dénutri modéré sans albumine (IMC seul) ──────────
class TestCas3AdulteDenutriModere:
"""Mme L., 45 ans, hospitalisée pour angiocholite.
IMC 17.8 → entre 17 et 18.5 → dénutrition modérée (E44.0) par HAS 2021.
Pas d'albumine → pas d'upgrade de sévérité.
"""
@pytest.fixture
def dossier(self) -> DossierMedical:
parsed = {
"type": "crh",
"patient": {
"sexe": "F",
"date_naissance": "12/09/1979",
},
"sejour": {
"date_entree": "15/03/2025",
"date_sortie": "21/03/2025",
},
"diagnostics": [],
"signes_vitaux": {"imc": 17.8, "poids_kg": 48, "taille_cm": 164},
}
text = """\
Votre patiente née le 12/09/1979 a été hospitalisée du 15/03/2025 au 21/03/2025.
Antécédents :
- Lithiases vésiculaires
- Anorexie restrictive ancienne (adolescence)
Au total : Angiocholite sur lithiase du cholédoque traitée par CPRE
puis cholécystectomie par cœlioscopie.
Biologie d'entrée :
CRP = 92 mg/L
ASAT = 180 UI/L
ALAT = 210 UI/L
Bilirubine totale = 45 µmol/L
Lipasémie = 890 UI/L
Hémoglobine = 11.8 g/dL
Leucocytes = 12.5 G/L
IMC: 17.8
TTT de sortie :
Paracétamol 1g x3/jour
Spasfon si besoin
Devenir : retour à domicile."""
return extract_medical_info(parsed, text)
def test_imc_178(self, dossier):
assert dossier.sejour.imc == 17.8
def test_denutrition_moderee_E44(self, dossier):
"""IMC 17.8 adulte → E44.0 (modéré, 17 < IMC < 18.5)."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E44.0" in codes, f"E44.0 attendu, trouvé : {codes}"
def test_pas_E43(self, dossier):
"""Pas de sévère sans albumine basse ni IMC ≤ 17."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E43" not in codes
def test_pas_albumine_extraite(self, dossier):
"""Pas d'albumine dans le texte → pas d'extraction."""
alb = [b for b in dossier.biologie_cle if b.test == "Albumine"]
assert len(alb) == 0
def test_angiocholite_detectee(self, dossier):
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
all_codes = codes.copy()
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
all_codes.add(dossier.diagnostic_principal.cim10_suggestion)
assert "K83.0" in all_codes
def test_cholecystectomie_detectee(self, dossier):
acte_codes = {a.code_ccam_suggestion for a in dossier.actes_ccam}
assert "HMFC004" in acte_codes
def test_alerte_has2021(self, dossier):
assert any("HAS 2021" in a for a in dossier.alertes_codage)
# ── Cas 4 : IMC normal, pas de dénutrition (contrôle négatif) ───────
class TestCas4ControleNegatif:
"""M. R., 55 ans, hospitalisé pour colique hépatique.
IMC 26.3 → au-dessus de tous les seuils → aucune dénutrition.
Albumine 38 g/L → normale.
Vérifie que la détection HAS 2021 ne produit pas de faux positif.
"""
@pytest.fixture
def dossier(self) -> DossierMedical:
parsed = {
"type": "crh",
"patient": {
"sexe": "M",
"date_naissance": "30/11/1969",
},
"sejour": {
"date_entree": "01/04/2025",
"date_sortie": "03/04/2025",
},
"diagnostics": [],
"signes_vitaux": {"imc": 26.3, "poids_kg": 82, "taille_cm": 176},
}
text = """\
Votre patient né le 30/11/1969 a été hospitalisé du 01/04/2025 au 03/04/2025.
Antécédents :
- Hypertension artérielle
- Dyslipidémie
Au total : Colique hépatique sur lithiase vésiculaire.
Bonne évolution. Cholécystectomie programmée à distance.
Biologie :
CRP = 12 mg/L
Albumine = 38 g/L
ASAT = 35 UI/L
ALAT = 42 UI/L
Hémoglobine = 14.5 g/dL
Créatinine = 78 µmol/L
IMC: 26.3
TTT de sortie :
Paracétamol si besoin
Spasfon si besoin
Devenir : retour à domicile."""
return extract_medical_info(parsed, text)
def test_imc_normal(self, dossier):
assert dossier.sejour.imc == 26.3
def test_albumine_normale_extraite(self, dossier):
"""Albumine 38 g/L → extraite mais normale."""
alb = [b for b in dossier.biologie_cle if b.test == "Albumine"]
assert len(alb) >= 1
assert alb[0].valeur_num == 38.0
def test_pas_de_denutrition(self, dossier):
"""IMC 26.3 + albumine 38 → aucun code E40-E46."""
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
nutrition_codes = {c for c in codes if c and c.startswith("E4") and c[:3] in
("E40", "E41", "E42", "E43", "E44", "E45", "E46")}
assert not nutrition_codes, f"Faux positif dénutrition : {nutrition_codes}"
def test_pas_alerte_has2021(self, dossier):
"""Aucune alerte HAS 2021 ne doit apparaître."""
assert not any("HAS 2021" in a for a in dossier.alertes_codage)
def test_hta_detectee(self, dossier):
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "I10" in codes
def test_dyslipidemie_detectee(self, dossier):
codes = {d.cim10_suggestion for d in dossier.diagnostics_associes}
assert "E78.5" in codes

View File

@@ -192,7 +192,7 @@ class TestSplitDocuments:
# --- Test intégration process_pdf ---
class TestProcessPdfMulti:
@patch("src.main.extract_text_with_pages")
@patch("src.main.extract_document_with_pages")
@patch("src.main.extract_medical_info")
@patch("src.main._run_edsnlp", return_value=None)
@patch("src.main._use_edsnlp", False)
@@ -203,9 +203,14 @@ class TestProcessPdfMulti:
from src.main import process_pdf
from src.config import DossierMedical, Diagnostic
from src.extraction.page_tracker import PageTracker
from src.extraction.pdf_extractor import ExtractionStats
# Mock extract_text_with_pages retournant un texte multi-épisodes Trackare
mock_extract.return_value = (TRACKARE_MULTI, PageTracker([(0, len(TRACKARE_MULTI))]))
# Mock extract_document_with_pages retournant un texte multi-épisodes Trackare
mock_extract.return_value = (
TRACKARE_MULTI,
PageTracker([(0, len(TRACKARE_MULTI))]),
ExtractionStats(total_pages=1, chars_per_page=[len(TRACKARE_MULTI)], total_chars=len(TRACKARE_MULTI)),
)
# Mock extract_medical_info retournant un DossierMedical minimal
mock_medical.return_value = DossierMedical(

View File

@@ -1,6 +1,7 @@
"""Tests pour le viewer Flask."""
import json
import os
import pytest
from pathlib import Path
from unittest.mock import patch
@@ -12,9 +13,10 @@ from src.config import DossierMedical, Diagnostic, ActeCCAM
@pytest.fixture
def app():
app = create_app()
app.config["TESTING"] = True
return app
with patch.dict(os.environ, {"T2A_DEMO_USER": "", "T2A_DEMO_PASS": ""}):
app = create_app()
app.config["TESTING"] = True
yield app
@pytest.fixture