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:
@@ -119,6 +119,7 @@ class DossierMedical(BaseModel):
|
||||
imagerie: list[Imagerie] = Field(default_factory=list)
|
||||
complications: list[str] = Field(default_factory=list)
|
||||
alertes_codage: list[str] = Field(default_factory=list)
|
||||
source_files: list[str] = Field(default_factory=list)
|
||||
processing_time_s: float | None = None
|
||||
|
||||
|
||||
|
||||
18
src/main.py
18
src/main.py
@@ -243,14 +243,32 @@ def main(input_path: str | None = None) -> None:
|
||||
if subdir:
|
||||
logger.info("--- Dossier %s (%d PDFs) ---", subdir, len(pdfs))
|
||||
|
||||
group_dossiers: list[DossierMedical] = []
|
||||
for pdf_path in pdfs:
|
||||
try:
|
||||
anonymized_text, dossier, report = process_pdf(pdf_path)
|
||||
stem = pdf_path.stem.replace(" ", "_")
|
||||
write_outputs(stem, anonymized_text, dossier, report, subdir=subdir)
|
||||
group_dossiers.append(dossier)
|
||||
except Exception:
|
||||
logger.exception("Erreur lors du traitement de %s", pdf_path.name)
|
||||
|
||||
# Fusion multi-PDFs si plusieurs documents dans le même groupe
|
||||
if len(group_dossiers) > 1 and subdir:
|
||||
try:
|
||||
from .medical.fusion import merge_dossiers
|
||||
merged = merge_dossiers(group_dossiers)
|
||||
struct_dir = STRUCTURED_DIR / subdir
|
||||
struct_dir.mkdir(parents=True, exist_ok=True)
|
||||
merged_path = struct_dir / f"{subdir}_fusionne_cim10.json"
|
||||
merged_path.write_text(
|
||||
merged.model_dump_json(indent=2, exclude_none=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info(" → Dossier fusionné : %s", merged_path)
|
||||
except Exception:
|
||||
logger.exception("Erreur lors de la fusion du groupe %s", subdir)
|
||||
|
||||
logger.info("Terminé.")
|
||||
|
||||
|
||||
|
||||
122
src/medical/ccam_noncumul.py
Normal file
122
src/medical/ccam_noncumul.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Détection des incompatibilités de non-cumul entre actes CCAM.
|
||||
|
||||
Implémente 3 règles heuristiques basées sur les principes T2A :
|
||||
1. Même code de base (7 caractères) avec activités différentes
|
||||
2. Même regroupement chirurgical le même jour
|
||||
3. Paires de regroupements incompatibles connues
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import ActeCCAM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Regroupements chirurgicaux soumis à cumul restreint (un seul par jour)
|
||||
REGROUPEMENT_UNIQUE_PAR_JOUR: set[str] = {
|
||||
"ADC", # Actes de chirurgie
|
||||
"ACO", # Actes de chirurgie orthopédique
|
||||
"ADO", # Actes de chirurgie ORL
|
||||
"ADA", # Actes de chirurgie abdominale/digestive
|
||||
"ADE", # Actes de chirurgie endoscopique
|
||||
}
|
||||
|
||||
# Paires de regroupements incompatibles
|
||||
NONCUMUL_REGROUPEMENT_PAIRS: set[frozenset[str]] = {
|
||||
frozenset({"ADC", "ADE"}),
|
||||
frozenset({"ADC", "ADO"}),
|
||||
frozenset({"ACO", "ADE"}),
|
||||
}
|
||||
|
||||
|
||||
def _get_regroupement(acte: ActeCCAM) -> str | None:
|
||||
"""Récupère le regroupement d'un acte depuis le dictionnaire CCAM."""
|
||||
if not acte.code_ccam_suggestion:
|
||||
return None
|
||||
try:
|
||||
from .ccam_dict import load_dict
|
||||
d = load_dict()
|
||||
info = d.get(acte.code_ccam_suggestion)
|
||||
if info and isinstance(info, dict):
|
||||
return info.get("regroupement")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_noncumul(actes: list[ActeCCAM]) -> list[str]:
|
||||
"""Vérifie les règles de non-cumul entre actes CCAM.
|
||||
|
||||
Args:
|
||||
actes: Liste d'actes CCAM d'un dossier médical.
|
||||
|
||||
Returns:
|
||||
Liste d'alertes de non-cumul détectées.
|
||||
"""
|
||||
if len(actes) < 2:
|
||||
return []
|
||||
|
||||
alertes: list[str] = []
|
||||
|
||||
# Enrichir les actes avec leur regroupement
|
||||
actes_info: list[tuple[ActeCCAM, str | None]] = [
|
||||
(acte, _get_regroupement(acte)) for acte in actes
|
||||
]
|
||||
|
||||
# Règle 1 : même code de base (7 premiers caractères), activités différentes
|
||||
codes_base: dict[str, list[ActeCCAM]] = {}
|
||||
for acte in actes:
|
||||
code = acte.code_ccam_suggestion
|
||||
if code and len(code) >= 7:
|
||||
base = code[:7]
|
||||
codes_base.setdefault(base, []).append(acte)
|
||||
|
||||
for base, group in codes_base.items():
|
||||
if len(group) > 1:
|
||||
codes_full = [a.code_ccam_suggestion for a in group]
|
||||
alertes.append(
|
||||
f"NON-CUMUL: codes de même base {base} avec variantes "
|
||||
f"({', '.join(codes_full)}) — vérifier la facturation"
|
||||
)
|
||||
|
||||
# Règle 2 : même regroupement chirurgical le même jour
|
||||
regroup_par_jour: dict[tuple[str, str | None], list[ActeCCAM]] = {}
|
||||
for acte, regroup in actes_info:
|
||||
if regroup and regroup in REGROUPEMENT_UNIQUE_PAR_JOUR:
|
||||
key = (regroup, acte.date)
|
||||
regroup_par_jour.setdefault(key, []).append(acte)
|
||||
|
||||
for (regroup, date), group in regroup_par_jour.items():
|
||||
if len(group) > 1:
|
||||
codes = [a.code_ccam_suggestion or "?" for a in group]
|
||||
jour = f" le {date}" if date else ""
|
||||
alertes.append(
|
||||
f"NON-CUMUL: {len(group)} actes du regroupement {regroup}{jour} "
|
||||
f"({', '.join(codes)}) — cumul restreint"
|
||||
)
|
||||
|
||||
# Règle 3 : paires de regroupements incompatibles
|
||||
regroups_seen: list[tuple[str, ActeCCAM]] = [
|
||||
(r, a) for a, r in actes_info if r
|
||||
]
|
||||
checked: set[frozenset[int]] = set()
|
||||
for i, (r1, a1) in enumerate(regroups_seen):
|
||||
for j, (r2, a2) in enumerate(regroups_seen):
|
||||
if i >= j:
|
||||
continue
|
||||
pair_key = frozenset({i, j})
|
||||
if pair_key in checked:
|
||||
continue
|
||||
checked.add(pair_key)
|
||||
pair = frozenset({r1, r2})
|
||||
if pair in NONCUMUL_REGROUPEMENT_PAIRS:
|
||||
alertes.append(
|
||||
f"NON-CUMUL: regroupements incompatibles {r1}/{r2} "
|
||||
f"({a1.code_ccam_suggestion or '?'} + {a2.code_ccam_suggestion or '?'})"
|
||||
)
|
||||
|
||||
return alertes
|
||||
@@ -123,6 +123,9 @@ def extract_medical_info(
|
||||
# Post-processing : enrichissement sévérité (CMA/CMS heuristique)
|
||||
_apply_severity_rules(dossier)
|
||||
|
||||
# Post-processing : détection non-cumul actes CCAM
|
||||
_apply_noncumul_rules(dossier)
|
||||
|
||||
return dossier
|
||||
|
||||
|
||||
@@ -702,6 +705,16 @@ def _apply_severity_rules(dossier: DossierMedical) -> None:
|
||||
logger.warning("Erreur lors de l'évaluation de sévérité", exc_info=True)
|
||||
|
||||
|
||||
def _apply_noncumul_rules(dossier: DossierMedical) -> None:
|
||||
"""Détecte les incompatibilités de non-cumul entre actes CCAM."""
|
||||
try:
|
||||
from .ccam_noncumul import check_noncumul
|
||||
alertes = check_noncumul(dossier.actes_ccam)
|
||||
dossier.alertes_codage.extend(alertes)
|
||||
except Exception:
|
||||
logger.warning("Erreur lors de la vérification du non-cumul CCAM", exc_info=True)
|
||||
|
||||
|
||||
def _lookup_cim10(text: str) -> str | None:
|
||||
"""Cherche un code CIM-10 pour un texte donné.
|
||||
|
||||
|
||||
246
src/medical/fusion.py
Normal file
246
src/medical/fusion.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Fusion de dossiers médicaux multi-PDFs pour un même patient.
|
||||
|
||||
Combine les informations de plusieurs documents (Trackare, CRH, CRO) en un
|
||||
dossier unique avec des règles de priorité et de déduplication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..config import (
|
||||
ActeCCAM,
|
||||
BiologieCle,
|
||||
Diagnostic,
|
||||
DossierMedical,
|
||||
Imagerie,
|
||||
Sejour,
|
||||
Traitement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Priorité des types de documents pour les données de séjour
|
||||
_DOC_PRIORITY = {"trackare": 0, "crh": 1, "cro": 2}
|
||||
|
||||
|
||||
def _cim10_specificity(code: str | None) -> int:
|
||||
"""Score de spécificité d'un code CIM-10 : longueur sans le point."""
|
||||
if not code:
|
||||
return 0
|
||||
return len(code.replace(".", ""))
|
||||
|
||||
|
||||
def _prefer_most_specific_dp(dossiers: list[DossierMedical]) -> Diagnostic | None:
|
||||
"""Sélectionne le DP le plus spécifique parmi tous les dossiers."""
|
||||
candidates: list[tuple[Diagnostic, int]] = []
|
||||
for d in dossiers:
|
||||
if d.diagnostic_principal:
|
||||
spec = _cim10_specificity(d.diagnostic_principal.cim10_suggestion)
|
||||
candidates.append((d.diagnostic_principal, spec))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Tri : spécificité décroissante, puis confiance (high > medium > low)
|
||||
conf_order = {"high": 0, "medium": 1, "low": 2}
|
||||
candidates.sort(
|
||||
key=lambda x: (-x[1], conf_order.get(x[0].cim10_confidence or "", 3))
|
||||
)
|
||||
return candidates[0][0]
|
||||
|
||||
|
||||
def _merge_sejour(dossiers: list[DossierMedical]) -> Sejour:
|
||||
"""Fusionne les informations de séjour avec priorité Trackare > CRH > CRO."""
|
||||
# Trier par priorité de type de document
|
||||
sorted_dossiers = sorted(
|
||||
dossiers,
|
||||
key=lambda d: _DOC_PRIORITY.get(d.document_type, 99),
|
||||
)
|
||||
|
||||
merged = Sejour()
|
||||
for d in sorted_dossiers:
|
||||
s = d.sejour
|
||||
if s.sexe and not merged.sexe:
|
||||
merged.sexe = s.sexe
|
||||
if s.age is not None and merged.age is None:
|
||||
merged.age = s.age
|
||||
if s.date_entree and not merged.date_entree:
|
||||
merged.date_entree = s.date_entree
|
||||
if s.date_sortie and not merged.date_sortie:
|
||||
merged.date_sortie = s.date_sortie
|
||||
if s.duree_sejour is not None and merged.duree_sejour is None:
|
||||
merged.duree_sejour = s.duree_sejour
|
||||
if s.mode_entree and not merged.mode_entree:
|
||||
merged.mode_entree = s.mode_entree
|
||||
if s.mode_sortie and not merged.mode_sortie:
|
||||
merged.mode_sortie = s.mode_sortie
|
||||
if s.imc is not None and merged.imc is None:
|
||||
merged.imc = s.imc
|
||||
if s.poids is not None and merged.poids is None:
|
||||
merged.poids = s.poids
|
||||
if s.taille is not None and merged.taille is None:
|
||||
merged.taille = s.taille
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _dedup_diagnostics(all_das: list[Diagnostic]) -> list[Diagnostic]:
|
||||
"""Déduplique les diagnostics associés par code CIM-10, garde la meilleure confiance."""
|
||||
conf_order = {"high": 0, "medium": 1, "low": 2}
|
||||
seen: dict[str | None, Diagnostic] = {}
|
||||
|
||||
for d in all_das:
|
||||
key = d.cim10_suggestion
|
||||
if key is None:
|
||||
# Sans code, dédup par texte normalisé
|
||||
key = f"__text__{d.texte.lower().strip()}"
|
||||
|
||||
if key not in seen:
|
||||
seen[key] = d
|
||||
else:
|
||||
existing = seen[key]
|
||||
# Garder celui avec la meilleure confiance
|
||||
if conf_order.get(d.cim10_confidence or "", 3) < conf_order.get(
|
||||
existing.cim10_confidence or "", 3
|
||||
):
|
||||
seen[key] = d
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def _dedup_actes(all_actes: list[ActeCCAM]) -> list[ActeCCAM]:
|
||||
"""Déduplique les actes CCAM par code."""
|
||||
seen: dict[str | None, ActeCCAM] = {}
|
||||
for a in all_actes:
|
||||
key = a.code_ccam_suggestion
|
||||
if key is None:
|
||||
key = f"__text__{a.texte.lower().strip()}"
|
||||
|
||||
if key not in seen:
|
||||
seen[key] = a
|
||||
else:
|
||||
existing = seen[key]
|
||||
# Garder celui avec date si possible
|
||||
if a.date and not existing.date:
|
||||
seen[key] = a
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
|
||||
"""Fusionne plusieurs dossiers médicaux d'un même patient.
|
||||
|
||||
Args:
|
||||
dossiers: Liste de DossierMedical issus de PDFs différents.
|
||||
|
||||
Returns:
|
||||
Un DossierMedical fusionné.
|
||||
"""
|
||||
if len(dossiers) == 1:
|
||||
result = dossiers[0].model_copy(deep=True)
|
||||
result.source_files = [result.source_file]
|
||||
return result
|
||||
|
||||
merged = DossierMedical()
|
||||
|
||||
# Source files
|
||||
merged.source_files = [d.source_file for d in dossiers if d.source_file]
|
||||
|
||||
# Séjour
|
||||
merged.sejour = _merge_sejour(dossiers)
|
||||
|
||||
# Diagnostic principal : le plus spécifique
|
||||
merged.diagnostic_principal = _prefer_most_specific_dp(dossiers)
|
||||
|
||||
# Collecter tous les DAS + DP non retenus comme DAS
|
||||
all_das: list[Diagnostic] = []
|
||||
for d in dossiers:
|
||||
all_das.extend(d.diagnostics_associes)
|
||||
# Si le DP de ce dossier est différent du DP fusionné, l'ajouter comme DAS
|
||||
if (
|
||||
d.diagnostic_principal
|
||||
and merged.diagnostic_principal
|
||||
and d.diagnostic_principal.cim10_suggestion
|
||||
!= merged.diagnostic_principal.cim10_suggestion
|
||||
):
|
||||
all_das.append(d.diagnostic_principal)
|
||||
|
||||
merged.diagnostics_associes = _dedup_diagnostics(all_das)
|
||||
|
||||
# Actes CCAM
|
||||
all_actes: list[ActeCCAM] = []
|
||||
for d in dossiers:
|
||||
all_actes.extend(d.actes_ccam)
|
||||
merged.actes_ccam = _dedup_actes(all_actes)
|
||||
|
||||
# Biologie : union, dédup par (test, valeur)
|
||||
bio_seen: set[tuple[str, str | None]] = set()
|
||||
for d in dossiers:
|
||||
for b in d.biologie_cle:
|
||||
key = (b.test, b.valeur)
|
||||
if key not in bio_seen:
|
||||
merged.biologie_cle.append(b)
|
||||
bio_seen.add(key)
|
||||
|
||||
# Imagerie : union, dédup par (type, conclusion)
|
||||
img_seen: set[tuple[str, str | None]] = set()
|
||||
for d in dossiers:
|
||||
for i in d.imagerie:
|
||||
key = (i.type, i.conclusion)
|
||||
if key not in img_seen:
|
||||
merged.imagerie.append(i)
|
||||
img_seen.add(key)
|
||||
|
||||
# Traitements : union, dédup par médicament (normalisé)
|
||||
med_seen: set[str] = set()
|
||||
for d in dossiers:
|
||||
for t in d.traitements_sortie:
|
||||
key = t.medicament.lower().strip()
|
||||
if key not in med_seen:
|
||||
merged.traitements_sortie.append(t)
|
||||
med_seen.add(key)
|
||||
|
||||
# Antécédents : union, dédup par texte normalisé
|
||||
ant_seen: set[str] = set()
|
||||
for d in dossiers:
|
||||
for a in d.antecedents:
|
||||
key = a.lower().strip()
|
||||
if key not in ant_seen:
|
||||
merged.antecedents.append(a)
|
||||
ant_seen.add(key)
|
||||
|
||||
# Complications : union, dédup par texte normalisé
|
||||
comp_seen: set[str] = set()
|
||||
for d in dossiers:
|
||||
for c in d.complications:
|
||||
key = c.lower().strip()
|
||||
if key not in comp_seen:
|
||||
merged.complications.append(c)
|
||||
comp_seen.add(key)
|
||||
|
||||
# Alertes : alerte de fusion en tête + union
|
||||
merged.alertes_codage = [f"FUSION: {len(dossiers)} documents fusionnés"]
|
||||
alert_seen: set[str] = set()
|
||||
for d in dossiers:
|
||||
for a in d.alertes_codage:
|
||||
if a not in alert_seen:
|
||||
merged.alertes_codage.append(a)
|
||||
alert_seen.add(a)
|
||||
|
||||
# Document type : le type prioritaire
|
||||
sorted_by_prio = sorted(
|
||||
dossiers,
|
||||
key=lambda d: _DOC_PRIORITY.get(d.document_type, 99),
|
||||
)
|
||||
merged.document_type = sorted_by_prio[0].document_type
|
||||
|
||||
logger.info(
|
||||
"Fusion de %d dossiers : DP=%s, %d DAS, %d actes",
|
||||
len(dossiers),
|
||||
merged.diagnostic_principal.cim10_suggestion if merged.diagnostic_principal else "aucun",
|
||||
len(merged.diagnostics_associes),
|
||||
len(merged.actes_ccam),
|
||||
)
|
||||
|
||||
return merged
|
||||
@@ -10,7 +10,7 @@ import requests
|
||||
from flask import Flask, abort, render_template, request, jsonify
|
||||
from markupsafe import Markup
|
||||
|
||||
from ..config import STRUCTURED_DIR, OLLAMA_URL, DossierMedical
|
||||
from ..config import STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical
|
||||
from .. import config as cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,11 +20,53 @@ logger = logging.getLogger(__name__)
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_group_stats(items: list[dict]) -> dict:
|
||||
"""Calcule des statistiques agrégées pour un groupe de dossiers.
|
||||
|
||||
Returns:
|
||||
{das_count, alertes_count, actes_count, cma_count}
|
||||
"""
|
||||
das_count = 0
|
||||
alertes_count = 0
|
||||
actes_count = 0
|
||||
cma_count = 0
|
||||
|
||||
for item in items:
|
||||
d = item["dossier"]
|
||||
das_count += len(d.diagnostics_associes)
|
||||
alertes_count += len(d.alertes_codage)
|
||||
actes_count += len(d.actes_ccam)
|
||||
for diag in d.diagnostics_associes:
|
||||
if diag.est_cma:
|
||||
cma_count += 1
|
||||
if d.diagnostic_principal and d.diagnostic_principal.est_cma:
|
||||
cma_count += 1
|
||||
|
||||
return {
|
||||
"das_count": das_count,
|
||||
"alertes_count": alertes_count,
|
||||
"actes_count": actes_count,
|
||||
"cma_count": cma_count,
|
||||
}
|
||||
|
||||
|
||||
def load_ccam_dict() -> dict[str, dict]:
|
||||
"""Charge le dictionnaire CCAM pour les regroupements."""
|
||||
if CCAM_DICT_PATH.exists():
|
||||
try:
|
||||
data = json.loads(CCAM_DICT_PATH.read_text(encoding="utf-8"))
|
||||
return data
|
||||
except Exception:
|
||||
logger.warning("Impossible de charger le dictionnaire CCAM")
|
||||
return {}
|
||||
|
||||
|
||||
def scan_dossiers() -> dict[str, list[dict]]:
|
||||
"""Scanne output/structured/ et retourne les fichiers groupés par sous-dossier.
|
||||
|
||||
Returns:
|
||||
{"racine": [{name, path_rel, dossier}, ...], "sous-dossier": [...]}
|
||||
Chaque groupe contient aussi une clé "stats" avec les compteurs agrégés.
|
||||
"""
|
||||
groups: dict[str, list[dict]] = {}
|
||||
|
||||
@@ -112,6 +154,24 @@ def confidence_label(value: str | None) -> str:
|
||||
return _CONFIDENCE_LABELS.get(value, value)
|
||||
|
||||
|
||||
_SEVERITY_STYLES = {
|
||||
"severe": ("Sévère", "#dc2626", "#fee2e2"),
|
||||
"modere": ("Modéré", "#92400e", "#fef3c7"),
|
||||
"leger": ("Léger", "#065f46", "#d1fae5"),
|
||||
}
|
||||
|
||||
|
||||
def severity_badge(value: str | None) -> Markup:
|
||||
if not value or value not in _SEVERITY_STYLES:
|
||||
return Markup("")
|
||||
label, fg, bg = _SEVERITY_STYLES[value]
|
||||
return Markup(
|
||||
f'<span style="display:inline-block;padding:2px 8px;border-radius:9999px;'
|
||||
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">'
|
||||
f'{label}</span>'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -121,16 +181,35 @@ def create_app() -> Flask:
|
||||
|
||||
app.jinja_env.filters["confidence_badge"] = confidence_badge
|
||||
app.jinja_env.filters["confidence_label"] = confidence_label
|
||||
app.jinja_env.filters["severity_badge"] = severity_badge
|
||||
|
||||
ccam_dict = load_ccam_dict()
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
groups = scan_dossiers()
|
||||
return render_template("index.html", groups=groups)
|
||||
group_stats = {name: compute_group_stats(items) for name, items in groups.items()}
|
||||
return render_template("index.html", groups=groups, group_stats=group_stats)
|
||||
|
||||
@app.route("/dossier/<path:filepath>")
|
||||
def detail(filepath: str):
|
||||
dossier = load_dossier(filepath)
|
||||
return render_template("detail.html", dossier=dossier, filepath=filepath)
|
||||
# Trouver les fichiers du même groupe pour la navigation
|
||||
groups = scan_dossiers()
|
||||
siblings = []
|
||||
current_group = None
|
||||
rel_parts = Path(filepath).parts
|
||||
if len(rel_parts) > 1:
|
||||
current_group = str(Path(*rel_parts[:-1]))
|
||||
siblings = groups.get(current_group, [])
|
||||
return render_template(
|
||||
"detail.html",
|
||||
dossier=dossier,
|
||||
filepath=filepath,
|
||||
ccam_dict=ccam_dict,
|
||||
siblings=siblings,
|
||||
current_group=current_group,
|
||||
)
|
||||
|
||||
@app.route("/admin/models", methods=["GET"])
|
||||
def list_models():
|
||||
|
||||
@@ -173,6 +173,31 @@
|
||||
ul.bullet li { margin-bottom: 0.25rem; }
|
||||
a.back { font-size: 0.85rem; color: #3b82f6; text-decoration: none; }
|
||||
a.back:hover { text-decoration: underline; }
|
||||
|
||||
/* Badges compteurs */
|
||||
.badge-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-das { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-actes { background: #e0e7ff; color: #3730a3; }
|
||||
.badge-alertes { background: #ffedd5; color: #c2410c; }
|
||||
.badge-cma { background: #fee2e2; color: #dc2626; }
|
||||
.badge-regroup { background: #f0fdf4; color: #166534; font-size: 0.65rem; }
|
||||
.badge-fusion { background: #ede9fe; color: #5b21b6; }
|
||||
|
||||
/* Alertes non-cumul (rouge) vs standard (orange) */
|
||||
.alerte-noncumul { color: #dc2626; font-weight: 600; }
|
||||
.alerte-standard { color: #9a3412; }
|
||||
|
||||
/* Source files */
|
||||
.source-files { font-size: 0.8rem; color: #64748b; margin-top: 0.5rem; }
|
||||
.source-files code { background: #f1f5f9; padding: 1px 4px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
{% block sidebar %}
|
||||
<div class="group-title">Navigation</div>
|
||||
<a href="/">Retour à la liste</a>
|
||||
{% if siblings %}
|
||||
<div class="group-title" style="margin-top:1rem;">{{ current_group }}</div>
|
||||
{% for sib in siblings %}
|
||||
<a href="/dossier/{{ sib.path_rel }}" {% if sib.path_rel == filepath %}style="color:#e2e8f0;border-left-color:#3b82f6;background:#1e293b;"{% endif %}>
|
||||
{{ sib.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="group-title" style="margin-top:1.5rem;">Actions</div>
|
||||
<button id="reprocess-btn" style="width:100%;padding:0.6rem;background:#3b82f6;color:white;border:none;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;font-weight:600;margin-bottom:0.5rem;">🔄 Relancer l'étude</button>
|
||||
<button id="reprocess-btn" style="width:100%;padding:0.6rem;background:#3b82f6;color:white;border:none;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;font-weight:600;margin-bottom:0.5rem;">Relancer l'étude</button>
|
||||
<div id="reprocess-status" style="font-size:0.75rem;padding:0.25rem;"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,6 +37,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if dossier.source_files %}
|
||||
<div class="source-files" style="margin-top:0.75rem;">
|
||||
<label style="font-size:0.7rem;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;">Documents sources</label>
|
||||
<div style="margin-top:0.25rem;">
|
||||
{% for sf in dossier.source_files %}
|
||||
<code>{{ sf }}</code>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ---- Séjour ---- #}
|
||||
@@ -57,7 +75,11 @@
|
||||
<h3 style="color:#c2410c;">Alertes de codage ({{ dossier.alertes_codage|length }})</h3>
|
||||
<ul style="margin:0;padding-left:1.2rem;">
|
||||
{% for alerte in dossier.alertes_codage %}
|
||||
<li style="font-size:0.85rem;color:#9a3412;margin-bottom:0.25rem;">{{ alerte }}</li>
|
||||
{% if alerte.startswith('NON-CUMUL') %}
|
||||
<li class="alerte-noncumul" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
|
||||
{% else %}
|
||||
<li class="alerte-standard" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -73,13 +95,17 @@
|
||||
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
|
||||
{{ dp.cim10_confidence | confidence_badge }}
|
||||
{% if dp.est_cma %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">CMA</span>{% endif %}
|
||||
{% if dp.niveau_severite == 'severe' %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">Sévère</span>
|
||||
{% elif dp.niveau_severite == 'modere' %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.75rem;">Modéré</span>
|
||||
{% elif dp.niveau_severite == 'leger' %}<span class="badge" style="background:#d1fae5;color:#065f46;font-size:0.75rem;">Léger</span>{% endif %}
|
||||
{{ dp.niveau_severite | severity_badge }}
|
||||
{% endif %}
|
||||
{% if dp.justification %}
|
||||
<div style="margin-top:0.5rem;font-size:0.8rem;color:#475569;">{{ dp.justification }}</div>
|
||||
{% endif %}
|
||||
{% if dp.raisonnement %}
|
||||
<details style="margin-top:0.5rem;">
|
||||
<summary>Raisonnement LLM</summary>
|
||||
<pre>{{ dp.raisonnement }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if dp.sources_rag %}
|
||||
<details>
|
||||
<summary>Sources RAG ({{ dp.sources_rag|length }})</summary>
|
||||
@@ -107,17 +133,22 @@
|
||||
</td>
|
||||
<td>{% if das.cim10_suggestion %}<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ das.cim10_suggestion }}</span>{% endif %}</td>
|
||||
<td>{{ das.cim10_confidence | confidence_badge }}</td>
|
||||
<td>
|
||||
{% if das.niveau_severite == 'severe' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Sévère</span>
|
||||
{% elif das.niveau_severite == 'modere' %}<span class="badge" style="background:#fef3c7;color:#92400e;">Modéré</span>
|
||||
{% elif das.niveau_severite == 'leger' %}<span class="badge" style="background:#d1fae5;color:#065f46;">Léger</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{{ das.niveau_severite | severity_badge }}</td>
|
||||
<td style="font-size:0.8rem;color:#475569;">{{ das.justification or '' }}</td>
|
||||
</tr>
|
||||
{% if das.raisonnement %}
|
||||
<tr>
|
||||
<td colspan="5" style="padding:0 0.75rem 0.5rem;">
|
||||
<details>
|
||||
<summary>Raisonnement LLM</summary>
|
||||
<pre>{{ das.raisonnement }}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if das.sources_rag %}
|
||||
<tr>
|
||||
<td colspan="4" style="padding:0 0.75rem 0.5rem;">
|
||||
<td colspan="5" style="padding:0 0.75rem 0.5rem;">
|
||||
<details>
|
||||
<summary>Sources RAG ({{ das.sources_rag|length }})</summary>
|
||||
{% for src in das.sources_rag %}
|
||||
@@ -139,12 +170,19 @@
|
||||
<div class="card section">
|
||||
<h3>Actes CCAM ({{ dossier.actes_ccam|length }})</h3>
|
||||
<table>
|
||||
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Date</th><th>Validité</th></tr></thead>
|
||||
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Regroupement</th><th>Date</th><th>Validité</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in dossier.actes_ccam %}
|
||||
<tr>
|
||||
<td>{{ a.texte }}</td>
|
||||
<td>{% if a.code_ccam_suggestion %}<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ a.code_ccam_suggestion }}</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if a.code_ccam_suggestion and ccam_dict.get(a.code_ccam_suggestion, {}).get('regroupement') %}
|
||||
<span class="badge badge-regroup">{{ ccam_dict[a.code_ccam_suggestion]['regroupement'] }}</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ a.date or '' }}</td>
|
||||
<td>
|
||||
{% if a.validite == 'valide' %}<span class="badge" style="background:#d1fae5;color:#065f46;">Valide</span>
|
||||
@@ -257,17 +295,17 @@ document.getElementById('reprocess-btn').addEventListener('click', async () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
status.textContent = '✓ ' + data.message;
|
||||
status.textContent = data.message;
|
||||
status.style.color = '#16a34a';
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
status.textContent = '✗ ' + (data.error || 'Erreur');
|
||||
status.textContent = (data.error || 'Erreur');
|
||||
status.style.color = '#dc2626';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Relancer l\'étude';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = '✗ Erreur réseau';
|
||||
status.textContent = 'Erreur réseau';
|
||||
status.style.color = '#dc2626';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Relancer l\'étude';
|
||||
|
||||
@@ -31,12 +31,30 @@
|
||||
{% set ns.count = ns.count + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<h3 style="display:flex;align-items:baseline;gap:0.75rem;">
|
||||
{% set stats = group_stats.get(group_name, {}) %}
|
||||
<h3 style="display:flex;align-items:baseline;gap:0.75rem;flex-wrap:wrap;">
|
||||
{{ group_name }}
|
||||
<span style="font-size:0.75rem;font-weight:400;color:#64748b;">
|
||||
{{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|round(1) }}s{% endif %}
|
||||
</span>
|
||||
{% if stats %}
|
||||
<span class="badge-count badge-das">{{ stats.das_count }} DAS</span>
|
||||
<span class="badge-count badge-actes">{{ stats.actes_count }} actes</span>
|
||||
{% if stats.alertes_count %}<span class="badge-count badge-alertes">{{ stats.alertes_count }} alertes</span>{% endif %}
|
||||
{% if stats.cma_count %}<span class="badge-count badge-cma">{{ stats.cma_count }} CMA</span>{% endif %}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if items|length > 1 %}
|
||||
{% for item in items if 'fusionne' in item.name %}
|
||||
{% if loop.first %}
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<a href="/dossier/{{ item.path_rel }}" class="badge-count badge-fusion" style="text-decoration:none;font-size:0.8rem;padding:4px 12px;">
|
||||
Vue patient fusionnée
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem;">
|
||||
{% for item in items %}
|
||||
<a href="/dossier/{{ item.path_rel }}" style="text-decoration:none;color:inherit;">
|
||||
@@ -44,9 +62,15 @@
|
||||
<div style="font-weight:600;font-size:0.9rem;margin-bottom:0.4rem;color:#0f172a;">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-bottom:0.4rem;">
|
||||
{% if item.dossier.document_type %}
|
||||
<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ item.dossier.document_type }}</span>
|
||||
{% endif %}
|
||||
{% if item.dossier.source_files %}<span class="badge badge-fusion">fusionné</span>{% endif %}
|
||||
{% if item.dossier.diagnostics_associes %}<span class="badge-count badge-das">{{ item.dossier.diagnostics_associes|length }} DAS</span>{% endif %}
|
||||
{% if item.dossier.actes_ccam %}<span class="badge-count badge-actes">{{ item.dossier.actes_ccam|length }} actes</span>{% endif %}
|
||||
{% if item.dossier.alertes_codage %}<span class="badge-count badge-alertes">{{ item.dossier.alertes_codage|length }} alertes</span>{% endif %}
|
||||
</div>
|
||||
{% if item.dossier.diagnostic_principal %}
|
||||
<div style="margin-top:0.5rem;font-size:0.8rem;color:#334155;">
|
||||
<strong>DP :</strong> {{ item.dossier.diagnostic_principal.texte[:80] }}{% if item.dossier.diagnostic_principal.texte|length > 80 %}…{% endif %}
|
||||
|
||||
88
tests/test_ccam_noncumul.py
Normal file
88
tests/test_ccam_noncumul.py
Normal 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
239
tests/test_fusion.py
Normal 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
94
tests/test_viewer.py
Normal 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
|
||||
Reference in New Issue
Block a user