From 9d07894c6f4f98e27d7cb6c6825da4c6570663c2 Mon Sep 17 00:00:00 2001 From: dom Date: Wed, 11 Feb 2026 12:43:34 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20viewer=20enrich?= =?UTF-8?q?i,=20non-cumul=20CCAM,=20fusion=20multi-PDFs=20+=20rebuild=20FA?= =?UTF-8?q?ISS=20(21=20141=20vecteurs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/config.py | 1 + src/main.py | 18 +++ src/medical/ccam_noncumul.py | 122 +++++++++++++++ src/medical/cim10_extractor.py | 13 ++ src/medical/fusion.py | 246 +++++++++++++++++++++++++++++++ src/viewer/app.py | 85 ++++++++++- src/viewer/templates/base.html | 25 ++++ src/viewer/templates/detail.html | 76 +++++++--- src/viewer/templates/index.html | 32 +++- tests/test_ccam_noncumul.py | 88 +++++++++++ tests/test_fusion.py | 239 ++++++++++++++++++++++++++++++ tests/test_viewer.py | 94 ++++++++++++ 12 files changed, 1013 insertions(+), 26 deletions(-) create mode 100644 src/medical/ccam_noncumul.py create mode 100644 src/medical/fusion.py create mode 100644 tests/test_ccam_noncumul.py create mode 100644 tests/test_fusion.py create mode 100644 tests/test_viewer.py diff --git a/src/config.py b/src/config.py index ee865ff..4811ae8 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/src/main.py b/src/main.py index 41ae3b9..c1b3bb4 100644 --- a/src/main.py +++ b/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é.") diff --git a/src/medical/ccam_noncumul.py b/src/medical/ccam_noncumul.py new file mode 100644 index 0000000..0a59df0 --- /dev/null +++ b/src/medical/ccam_noncumul.py @@ -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 diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index 1932cdb..0dc6626 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -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é. diff --git a/src/medical/fusion.py b/src/medical/fusion.py new file mode 100644 index 0000000..0c4d822 --- /dev/null +++ b/src/medical/fusion.py @@ -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 diff --git a/src/viewer/app.py b/src/viewer/app.py index e1f6aa2..b1ccfa3 100644 --- a/src/viewer/app.py +++ b/src/viewer/app.py @@ -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'' + f'{label}' + ) + + # --------------------------------------------------------------------------- # 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/") 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(): diff --git a/src/viewer/templates/base.html b/src/viewer/templates/base.html index b40dd0b..c955224 100644 --- a/src/viewer/templates/base.html +++ b/src/viewer/templates/base.html @@ -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; } diff --git a/src/viewer/templates/detail.html b/src/viewer/templates/detail.html index 08bae8e..a692a68 100644 --- a/src/viewer/templates/detail.html +++ b/src/viewer/templates/detail.html @@ -4,8 +4,16 @@ {% block sidebar %}
Navigation
Retour à la liste +{% if siblings %} +
{{ current_group }}
+{% for sib in siblings %} + + {{ sib.name }} + +{% endfor %} +{% endif %}
Actions
- +
{% endblock %} @@ -29,6 +37,16 @@ {% endif %} + {% if dossier.source_files %} +
+ +
+ {% for sf in dossier.source_files %} + {{ sf }}{% if not loop.last %}, {% endif %} + {% endfor %} +
+
+ {% endif %} {# ---- Séjour ---- #} @@ -57,7 +75,11 @@

Alertes de codage ({{ dossier.alertes_codage|length }})

    {% for alerte in dossier.alertes_codage %} -
  • {{ alerte }}
  • + {% if alerte.startswith('NON-CUMUL') %} +
  • {{ alerte }}
  • + {% else %} +
  • {{ alerte }}
  • + {% endif %} {% endfor %}
@@ -73,13 +95,17 @@ {{ dp.cim10_suggestion }} {{ dp.cim10_confidence | confidence_badge }} {% if dp.est_cma %}CMA{% endif %} - {% if dp.niveau_severite == 'severe' %}Sévère - {% elif dp.niveau_severite == 'modere' %}Modéré - {% elif dp.niveau_severite == 'leger' %}Léger{% endif %} + {{ dp.niveau_severite | severity_badge }} {% endif %} {% if dp.justification %}
{{ dp.justification }}
{% endif %} + {% if dp.raisonnement %} +
+ Raisonnement LLM +
{{ dp.raisonnement }}
+
+ {% endif %} {% if dp.sources_rag %}
Sources RAG ({{ dp.sources_rag|length }}) @@ -107,17 +133,22 @@ {% if das.cim10_suggestion %}{{ das.cim10_suggestion }}{% endif %} {{ das.cim10_confidence | confidence_badge }} - - {% if das.niveau_severite == 'severe' %}Sévère - {% elif das.niveau_severite == 'modere' %}Modéré - {% elif das.niveau_severite == 'leger' %}Léger - {% else %}—{% endif %} - + {{ das.niveau_severite | severity_badge }} {{ das.justification or '' }} + {% if das.raisonnement %} + + +
+ Raisonnement LLM +
{{ das.raisonnement }}
+
+ + + {% endif %} {% if das.sources_rag %} - +
Sources RAG ({{ das.sources_rag|length }}) {% for src in das.sources_rag %} @@ -139,12 +170,19 @@

Actes CCAM ({{ dossier.actes_ccam|length }})

- + {% for a in dossier.actes_ccam %} +
TexteCode CCAMDateValidité
TexteCode CCAMRegroupementDateValidité
{{ a.texte }} {% if a.code_ccam_suggestion %}{{ a.code_ccam_suggestion }}{% endif %} + {% if a.code_ccam_suggestion and ccam_dict.get(a.code_ccam_suggestion, {}).get('regroupement') %} + {{ ccam_dict[a.code_ccam_suggestion]['regroupement'] }} + {% else %} + — + {% endif %} + {{ a.date or '' }} {% if a.validite == 'valide' %}Valide @@ -246,28 +284,28 @@ document.getElementById('reprocess-btn').addEventListener('click', async () => { const btn = document.getElementById('reprocess-btn'); const status = document.getElementById('reprocess-status'); - + btn.disabled = true; btn.textContent = 'Traitement en cours...'; status.textContent = ''; status.style.color = '#3b82f6'; - + try { const response = await fetch('/reprocess/{{ filepath }}', { method: 'POST' }); 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'; diff --git a/src/viewer/templates/index.html b/src/viewer/templates/index.html index e7f3a2f..a506272 100644 --- a/src/viewer/templates/index.html +++ b/src/viewer/templates/index.html @@ -31,12 +31,30 @@ {% set ns.count = ns.count + 1 %} {% endif %} {% endfor %} -

+ {% set stats = group_stats.get(group_name, {}) %} +

{{ group_name }} {{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|round(1) }}s{% endif %} + {% if stats %} + {{ stats.das_count }} DAS + {{ stats.actes_count }} actes + {% if stats.alertes_count %}{{ stats.alertes_count }} alertes{% endif %} + {% if stats.cma_count %}{{ stats.cma_count }} CMA{% endif %} + {% endif %}

+ {% if items|length > 1 %} + {% for item in items if 'fusionne' in item.name %} + {% if loop.first %} + + {% endif %} + {% endfor %} + {% endif %}
{% for item in items %} @@ -44,9 +62,15 @@
{{ item.name }}
- {% if item.dossier.document_type %} - {{ item.dossier.document_type }} - {% endif %} +
+ {% if item.dossier.document_type %} + {{ item.dossier.document_type }} + {% endif %} + {% if item.dossier.source_files %}fusionné{% endif %} + {% if item.dossier.diagnostics_associes %}{{ item.dossier.diagnostics_associes|length }} DAS{% endif %} + {% if item.dossier.actes_ccam %}{{ item.dossier.actes_ccam|length }} actes{% endif %} + {% if item.dossier.alertes_codage %}{{ item.dossier.alertes_codage|length }} alertes{% endif %} +
{% if item.dossier.diagnostic_principal %}
DP : {{ item.dossier.diagnostic_principal.texte[:80] }}{% if item.dossier.diagnostic_principal.texte|length > 80 %}…{% endif %} diff --git a/tests/test_ccam_noncumul.py b/tests/test_ccam_noncumul.py new file mode 100644 index 0000000..1f5bb1e --- /dev/null +++ b/tests/test_ccam_noncumul.py @@ -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) diff --git a/tests/test_fusion.py b/tests/test_fusion.py new file mode 100644 index 0000000..44ef07d --- /dev/null +++ b/tests/test_fusion.py @@ -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" diff --git a/tests/test_viewer.py b/tests/test_viewer.py new file mode 100644 index 0000000..21e2a7f --- /dev/null +++ b/tests/test_viewer.py @@ -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