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

@@ -8,11 +8,14 @@ from functools import lru_cache
from pathlib import Path
from typing import Optional, Any, Dict
import logging
import yaml
from dotenv import load_dotenv
from pydantic import BaseModel, Field, field_validator
_cfg_logger = logging.getLogger(__name__)
load_dotenv()
@@ -33,6 +36,7 @@ DIAGNOSTIC_CONFLICTS_PATH = CONFIG_DIR / "diagnostic_conflicts.yaml"
PROCEDURE_DIAGNOSIS_RULES_PATH = CONFIG_DIR / "procedure_diagnosis_rules.yaml"
TEMPORAL_RULES_PATH = CONFIG_DIR / "temporal_rules.yaml"
PARCOURS_RULES_PATH = CONFIG_DIR / "parcours_rules.yaml"
COMPLETUDE_RULES_PATH = CONFIG_DIR / "completude_rules.yaml"
RULES_DIR = CONFIG_DIR / "rules"
RULES_BASE_PATH = RULES_DIR / "base.yaml"
RULES_ENABLED_PATH = RULES_DIR / "enabled.yaml"
@@ -131,14 +135,16 @@ RAG_INDEX_DIR = BASE_DIR / "data" / "rag_index"
REFERENTIELS_DIR = BASE_DIR / "data" / "referentiels"
UPLOAD_MAX_SIZE_MB = 50
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json"
_DICTS_DIR = REFERENTIELS_DIR / "dicts"
_PDFS_DIR = REFERENTIELS_DIR / "pdfs"
CIM10_DICT_PATH = _DICTS_DIR / "cim10_dict.json"
CIM10_SUPPLEMENTS_PATH = _DICTS_DIR / "cim10_supplements.json"
BIO_CONCEPTS_PATH = BASE_DIR / "data" / "bio_concepts.json"
CMA_LEVELS_PATH = BASE_DIR / "data" / "cma_levels.json"
CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json"
CIM10_PDF = Path(os.environ.get("T2A_CIM10_PDF", "/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf"))
GUIDE_METHODO_PDF = Path(os.environ.get("T2A_GUIDE_METHODO_PDF", "/home/dom/ai/aivanov_CIM/guide_methodo_mco_2026_version_provisoire.pdf"))
CCAM_PDF = Path(os.environ.get("T2A_CCAM_PDF", "/home/dom/ai/aivanov_CIM/actualisation_ccam_descriptive_a_usage_pmsi_v4_2025.pdf"))
CCAM_DICT_PATH = _DICTS_DIR / "ccam_dict.json"
CIM10_PDF = Path(os.environ.get("T2A_CIM10_PDF", str(_PDFS_DIR / "cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf")))
GUIDE_METHODO_PDF = Path(os.environ.get("T2A_GUIDE_METHODO_PDF", str(_PDFS_DIR / "guide_methodo_mco_2026_version_provisoire.pdf")))
CCAM_PDF = Path(os.environ.get("T2A_CCAM_PDF", str(_PDFS_DIR / "actualisation_ccam_descriptive_a_usage_pmsi_v4_2025.pdf")))
# --- Modèle d'embedding ---
@@ -150,18 +156,37 @@ RERANKER_MODEL = os.environ.get("T2A_RERANKER_MODEL", "cross-encoder/ms-marco-Mi
# --- Références biologiques (fallback) ---
def _load_yaml_config(path: Path, defaults: Dict[str, Any], label: str) -> Dict[str, Any]:
"""Helper : charge un YAML config avec merge sur defaults et logging explicite.
- Si le fichier n'existe pas : retourne defaults (info log).
- Si le YAML est invalide : retourne defaults + log error.
- Sinon : merge YAML sur defaults.
"""
if not path.exists():
_cfg_logger.debug("Config %s : fichier absent (%s), defaults utilisés", label, path)
return defaults
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
_cfg_logger.error("Config %s : contenu invalide (attendu dict, reçu %s) dans %s",
label, type(data).__name__, path)
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except yaml.YAMLError as e:
_cfg_logger.error("Config %s : erreur de syntaxe YAML dans %s%s", label, path, e)
return defaults
except Exception as e:
_cfg_logger.error("Config %s : erreur lecture %s%s", label, path, e)
return defaults
@lru_cache(maxsize=1)
def load_reference_ranges() -> Dict[str, Any]:
"""Charge les intervalles de référence biologiques depuis config/reference_ranges.yaml.
Hiérarchie d'usage recommandée dans les règles :
1) Normes présentes dans le document (ex: [N: 135-145])
2) Table YAML (par bande d'âge)
3) "Safe zones" conservatrices si âge inconnu
Le YAML est volontairement éditable par des non-informaticiens (future UI).
"""
# Defaults minimalistes (adultes) si YAML absent
"""Charge les intervalles de référence biologiques depuis config/reference_ranges.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"age_bands": {"adult_min_years": 18},
@@ -171,8 +196,6 @@ def load_reference_ranges() -> Dict[str, Any]:
"sodium": {"low": 135, "high": 145, "unit": "mmol/L"},
"potassium": {"low": 3.5, "high": 5.0, "unit": "mmol/L"},
},
# Valeurs pédiatriques: à affiner (par bandes d'âge) si besoin.
# Pour les règles "ruled_out" on utilise plutôt les safe_zones_unknown_age
"child": {
"platelets": {"low": 150, "high": 450, "unit": "G/L"},
"sodium": {"low": 135, "high": 145, "unit": "mmol/L"},
@@ -186,28 +209,7 @@ def load_reference_ranges() -> Dict[str, Any]:
"potassium_ruled_out_low": 3.7,
},
}
path = REFERENCE_RANGES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
except Exception:
# PyYAML absent: on garde les valeurs par défaut
return defaults
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
# Merge léger: defaults comme socle, YAML surcharge
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(REFERENCE_RANGES_PATH, defaults, "reference_ranges")
# --- Règles biologiques (pilotées par YAML) ---
@@ -215,14 +217,7 @@ def load_reference_ranges() -> Dict[str, Any]:
@lru_cache(maxsize=1)
def load_bio_rules() -> Dict[str, Any]:
"""Charge les règles biologiques depuis config/bio_rules.yaml.
Objectif: permettre d'activer/désactiver et de paramétrer les règles
de type "contradiction bio ⇒ ruled_out" sans modifier le code.
Le fichier est volontairement simple (future UI).
"""
"""Charge les règles biologiques depuis config/bio_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"rules": {
@@ -231,144 +226,55 @@ def load_bio_rules() -> Dict[str, Any]:
"hypokalemia": {"enabled": True, "codes": ["E87.6"], "analyte": "potassium"},
},
}
path = BIO_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
except Exception:
return defaults
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(BIO_RULES_PATH, defaults, "bio_rules")
@lru_cache(maxsize=1)
def load_demographic_rules() -> Dict[str, Any]:
"""Charge les règles démographiques (sexe/âge) depuis config/demographic_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"sex_rules": {},
"age_rules": {},
}
path = DEMOGRAPHIC_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(DEMOGRAPHIC_RULES_PATH, {
"version": 1, "sex_rules": {}, "age_rules": {},
}, "demographic_rules")
@lru_cache(maxsize=1)
def load_diagnostic_conflicts() -> Dict[str, Any]:
"""Charge les conflits diagnostics depuis config/diagnostic_conflicts.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"mutual_exclusions": [],
"incompatibilities": [],
}
path = DIAGNOSTIC_CONFLICTS_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(DIAGNOSTIC_CONFLICTS_PATH, {
"version": 1, "mutual_exclusions": [], "incompatibilities": [],
}, "diagnostic_conflicts")
@lru_cache(maxsize=1)
def load_procedure_diagnosis_rules() -> Dict[str, Any]:
"""Charge les règles de corrélation actes/diagnostics depuis config/procedure_diagnosis_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"rules": [],
}
path = PROCEDURE_DIAGNOSIS_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(PROCEDURE_DIAGNOSIS_RULES_PATH, {
"version": 1, "rules": [],
}, "procedure_diagnosis_rules")
@lru_cache(maxsize=1)
def load_temporal_rules() -> Dict[str, Any]:
"""Charge les règles temporelles depuis config/temporal_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"rules": [],
}
path = TEMPORAL_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(TEMPORAL_RULES_PATH, {
"version": 1, "rules": [],
}, "temporal_rules")
@lru_cache(maxsize=1)
def load_parcours_rules() -> Dict[str, Any]:
"""Charge les règles de parcours patient depuis config/parcours_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"documentary_rules": {},
"pathway_rules": {},
}
path = PARCOURS_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(PARCOURS_RULES_PATH, {
"version": 1, "documentary_rules": {}, "pathway_rules": {},
}, "parcours_rules")
@lru_cache(maxsize=1)
def load_completude_rules() -> Dict[str, Any]:
"""Charge les règles de complétude documentaire depuis config/completude_rules.yaml."""
return _load_yaml_config(COMPLETUDE_RULES_PATH, {
"version": 1, "diagnostics": {}, "actes": {},
}, "completude_rules")
# --- Garde-fous de parsing des valeurs biologiques (anti-OCR) ---
@@ -418,25 +324,7 @@ def load_lab_value_sanity() -> Dict[str, Any]:
},
}
path = LAB_SANITY_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
except Exception:
return defaults
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
return _load_yaml_config(LAB_SANITY_PATH, defaults, "lab_value_sanity")
# --- Catalogue de règles (vetos + décisions), piloté par YAML ---
@@ -506,11 +394,6 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]:
(=> ne casse pas le comportement historique)
"""
try:
import yaml # type: ignore
except Exception:
return {}
catalog: Dict[str, Dict[str, Any]] = {}
# 1) base
@@ -519,7 +402,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]:
base_data = yaml.safe_load(RULES_BASE_PATH.read_text(encoding="utf-8")) or {}
if isinstance(base_data, dict):
catalog = _flatten_rules_yaml(base_data)
except Exception:
except (yaml.YAMLError, Exception) as e:
_cfg_logger.error("Rules catalog : erreur lecture base.yaml — %s", e)
catalog = {}
# 2) enabled overlays
@@ -537,8 +421,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]:
extra = active.get("extra")
if isinstance(extra, list):
extra_files = [str(x) for x in extra if str(x).strip()]
except Exception:
pass
except (yaml.YAMLError, Exception) as e:
_cfg_logger.error("Rules catalog : erreur lecture enabled.yaml — %s", e)
else:
# fallback env
active_site = os.environ.get("T2A_SITE", "").strip()
@@ -552,8 +436,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
catalog = _merge_rule_catalog(catalog, _flatten_rules_yaml(data))
except Exception:
pass
except (yaml.YAMLError, Exception) as e:
_cfg_logger.error("Rules catalog : erreur overlay spécialité %s%s", active_specialty, e)
# 4) site overlay
if active_site:
@@ -563,8 +447,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
catalog = _merge_rule_catalog(catalog, _flatten_rules_yaml(data))
except Exception:
pass
except (yaml.YAMLError, Exception) as e:
_cfg_logger.error("Rules catalog : erreur overlay site %s%s", active_site, e)
# 5) extra overlays
for rel in extra_files:
@@ -574,8 +458,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
catalog = _merge_rule_catalog(catalog, _flatten_rules_yaml(data))
except Exception:
pass
except (yaml.YAMLError, Exception) as e:
_cfg_logger.error("Rules catalog : erreur overlay %s%s", rel, e)
return catalog
@@ -611,17 +495,7 @@ def load_rules_router() -> Dict[str, Any]:
"defaults": {"enabled_packs": ["vetos_core", "decisions_core"]},
"triggers": [],
}
path = RULES_ROUTER_PATH
if not path.exists():
return defaults
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
# merge conservateur
if isinstance(data, dict):
defaults.update({k: v for k, v in data.items() if v is not None})
return defaults
except Exception:
return defaults
return _load_yaml_config(RULES_ROUTER_PATH, defaults, "rules_router")
def rule_enabled(rule_id: str) -> bool:
@@ -877,6 +751,7 @@ class DossierMedical(BaseModel):
ghm_estimation: Optional[GHMEstimation] = None
controles_cpam: list[ControleCPAM] = Field(default_factory=list)
veto_report: Optional["VetoReport"] = None
completude: Optional["CompletudeDossier"] = None
processing_time_s: float | None = None
metrics: Optional[DossierMetrics] = None
rules_runtime: Optional[dict] = None
@@ -924,6 +799,14 @@ class GHMEstimation(BaseModel):
alertes: list[str] = Field(default_factory=list)
class FinancialImpact(BaseModel):
"""Estimation de l'impact financier d'un contrôle UCR."""
delta_severite: int = 0 # ex: -2 (perte 2 niveaux)
impact_estime_euros: int = 0 # estimation grossière
priorite: str = "normale" # "critique" | "haute" | "normale" | "faible"
raison: str = ""
class ControleCPAM(BaseModel):
numero_ogc: int
titre: str = ""
@@ -933,12 +816,22 @@ class ControleCPAM(BaseModel):
da_ucr: Optional[str] = None
dr_ucr: Optional[str] = None
actes_ucr: Optional[str] = None
type_desaccord: Optional[str] = None # "DP" | "DAS" | "DP+DAS" | "Actes"
financial_impact: Optional[FinancialImpact] = None
contre_argumentation: Optional[str] = None
response_data: Optional[dict] = None
sources_reponse: list[RAGSource] = Field(default_factory=list)
quality_tier: Optional[str] = None # "A" | "B" | "C"
requires_review: bool = False
quality_warnings: list[str] = Field(default_factory=list)
# Délais réglementaires
date_notification: Optional[str] = None # JJ/MM/AAAA
date_limite_reponse: Optional[str] = None # calculé : notification + 30j
statut_reponse: str = "a_traiter" # "a_traiter" | "en_cours" | "envoye" | "hors_delai"
# Workflow validation DIM
validation_dim: str = "non_valide" # "non_valide" | "en_revision" | "valide" | "rejete"
commentaire_dim: Optional[str] = None
date_validation: Optional[str] = None
# --- Qualité / Vetos (contestabilité) ---
@@ -962,6 +855,43 @@ class VetoReport(BaseModel):
issues: list[VetoIssue] = Field(default_factory=list)
# --- Complétude documentaire DIM ---
class ItemCompletude(BaseModel):
"""Élément requis/recommandé pour justifier un code."""
categorie: str # "biologie" | "imagerie" | "document" | "acte" | "clinique"
element: str # "Albumine" | "CRO" | "Scanner abdominal"
statut: str # "present" | "absent" | "present_confirme" | "present_non_confirme" | "present_indirect"
valeur: Optional[str] = None # "28 g/L" si présent
importance: str # "obligatoire" | "recommande"
impact_cpam: str = "" # explication du risque
confirmation_detail: Optional[str] = None # "Albumine 28 g/L < 30 → confirme E43"
class CheckCompletude(BaseModel):
"""Vérification de complétude pour un code diagnostique."""
code: str # "E43"
libelle: str # "Dénutrition sévère"
type_diag: str # "DP" | "DAS"
items: list[ItemCompletude] = Field(default_factory=list)
score: int = 100 # 0-100
verdict: str = "defendable" # "defendable" | "fragile" | "indefendable"
resume: str = "" # "2/3 éléments obligatoires présents"
class CompletudeDossier(BaseModel):
"""Rapport global de complétude documentaire pour un dossier."""
checks: list[CheckCompletude] = Field(default_factory=list)
score_global: int = 100
verdict_global: str = "defendable"
documents_presents: list[str] = Field(default_factory=list)
documents_manquants: list[str] = Field(default_factory=list)
class AnonymizationReport(BaseModel):
source_file: str
total_replacements: int = 0

View File

@@ -568,6 +568,39 @@ def _assess_dossier_strength(dossier: DossierMedical) -> dict:
}
def _build_strategie_type(controle: ControleCPAM) -> str:
"""Construit le bloc de stratégie conditionnel selon le type de désaccord."""
td = controle.type_desaccord or "DP"
blocs: dict[str, str] = {
"DP": (
"STRATÉGIE DE CONTESTATION — TYPE : DP (Diagnostic Principal)\n"
"Démontrer que le DP retenu par l'UCR ne correspond pas au motif réel "
"d'hospitalisation. S'appuyer sur le CRH, les règles D1 (symptôme si cause "
"non identifiée) et D2 (cause si identifiée). Le DP doit refléter le diagnostic "
"ayant consommé le plus de ressources pendant le séjour."
),
"DAS": (
"STRATÉGIE DE CONTESTATION — TYPE : DAS (Diagnostics Associés)\n"
"Prouver que la comorbidité a bien été prise en charge pendant le séjour : "
"prescription active, acte spécifique, surveillance documentée, ou allongement "
"de durée. Chaque DAS doit mobiliser des ressources supplémentaires documentées."
),
"Actes": (
"STRATÉGIE DE CONTESTATION — TYPE : Actes CCAM\n"
"Vérifier le code CCAM exact, la date de réalisation, et la concordance avec "
"le compte-rendu opératoire. S'appuyer sur la nomenclature CCAM et les notes "
"d'inclusion/exclusion des codes concernés."
),
}
if td == "DP+DAS":
return (
"STRATÉGIE DE CONTESTATION — TYPE : DP + DAS (contestation combinée)\n"
+ blocs["DP"].split("\n", 1)[1] + "\n"
+ blocs["DAS"].split("\n", 1)[1]
)
return blocs.get(td, blocs["DP"])
def _build_cpam_prompt(
dossier: DossierMedical,
controle: ControleCPAM,
@@ -844,6 +877,9 @@ def _build_cpam_prompt(
+ "\n".join(ext_lines)
)
# Bloc de stratégie conditionnel selon le type de désaccord
strategie_type_str = _build_strategie_type(controle)
tags_disponibles_str = (
", ".join(f"[{t}]" for t in sorted(tag_map.keys()))
if tag_map else "(aucun)"
@@ -863,5 +899,6 @@ def _build_cpam_prompt(
bio_confrontation_str=bio_confrontation,
numero_ogc=controle.numero_ogc,
tags_disponibles_str=tags_disponibles_str,
strategie_type_str=strategie_type_str,
)
return prompt, tag_map

View File

@@ -6,6 +6,8 @@ import logging
import re
from pathlib import Path
from datetime import datetime, timedelta
import openpyxl
from ..config import ControleCPAM
@@ -15,6 +17,9 @@ logger = logging.getLogger(__name__)
# Colonnes attendues dans le fichier Excel
_EXPECTED_COLUMNS = ("N° OGC", "Titre", "Arg_UCR", "Décision_UCR", "DP_UCR", "DA_UCR", "DR_UCR", "Actes_UCR")
# Colonnes optionnelles de dates
_DATE_COLUMNS = ("Date_notification", "Date_limite")
def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]:
"""Lit le fichier Excel de contrôle CPAM et retourne un dict OGC -> liste de contrôles.
@@ -76,6 +81,22 @@ def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]:
dr_ucr=_clean_optional(row, col_map.get("DR_UCR")),
actes_ucr=_clean_optional(row, col_map.get("Actes_UCR")),
)
controle.type_desaccord = _infer_type_desaccord(controle)
# Dates réglementaires (optionnelles)
date_notif_raw = _clean_optional(row, col_map.get("Date_notification"))
date_limite_raw = _clean_optional(row, col_map.get("Date_limite"))
if date_notif_raw:
controle.date_notification = _parse_date(date_notif_raw)
if controle.date_notification and not date_limite_raw:
# Calculer la date limite (notification + 30 jours)
try:
dt = datetime.strptime(controle.date_notification, "%d/%m/%Y")
controle.date_limite_reponse = (dt + timedelta(days=30)).strftime("%d/%m/%Y")
except ValueError:
pass
if date_limite_raw:
controle.date_limite_reponse = _parse_date(date_limite_raw)
result.setdefault(numero_ogc, []).append(controle)
count += 1
@@ -84,6 +105,41 @@ def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]:
return result
def _parse_date(raw: str) -> str | None:
"""Parse une date depuis l'Excel (formats courants) vers JJ/MM/AAAA."""
if not raw:
return None
raw = raw.strip()
# Si c'est un objet datetime (openpyxl peut retourner un datetime)
if hasattr(raw, "strftime"):
return raw.strftime("%d/%m/%Y")
for fmt in ("%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y", "%d.%m.%Y"):
try:
return datetime.strptime(raw, fmt).strftime("%d/%m/%Y")
except ValueError:
continue
return raw # retourner tel quel si format inconnu
def _infer_type_desaccord(controle: ControleCPAM) -> str | None:
"""Déduit le type de désaccord depuis les champs UCR renseignés.
Retourne None si aucun champ UCR n'est renseigné (données incomplètes).
"""
has_dp = bool(controle.dp_ucr)
has_das = bool(controle.da_ucr)
has_actes = bool(controle.actes_ucr)
if has_dp and has_das:
return "DP+DAS"
if has_dp:
return "DP"
if has_das:
return "DAS"
if has_actes:
return "Actes"
return None
def _clean_optional(row: tuple, idx: int | None) -> str | None:
"""Extrait une valeur optionnelle depuis une ligne Excel."""
if idx is None or idx >= len(row):
@@ -95,21 +151,58 @@ def _clean_optional(row: tuple, idx: int | None) -> str | None:
return val if val else None
def match_dossier_ogc(source_name: str, cpam_data: dict[int, list[ControleCPAM]]) -> list[ControleCPAM]:
def match_dossier_ogc(
source_name: str,
cpam_data: dict[int, list[ControleCPAM]],
structured_dir: Path | None = None,
) -> list[ControleCPAM]:
"""Cherche les contrôles CPAM correspondant à un dossier par préfixe OGC.
Le nom du dossier suit le format "17_23100690" où 17 est le N° OGC.
Stratégie de matching (par ordre de priorité) :
1. Regex sur le nom du répertoire (format "17_23100690" → OGC 17)
2. Fallback : chercher l'OGC dans les métadonnées du JSON fusionné
Args:
source_name: Nom du sous-dossier (ex: "17_23100690").
cpam_data: Dict OGC -> contrôles retourné par parse_cpam_excel().
structured_dir: Répertoire structured/ pour le fallback JSON (optionnel).
Returns:
Liste des contrôles CPAM pour cet OGC, ou liste vide.
"""
# 1. Match par nom de répertoire (méthode existante)
match = re.match(r"^(\d+)_", source_name)
if not match:
return []
if match:
ogc = int(match.group(1))
result = cpam_data.get(ogc, [])
if result:
return result
ogc = int(match.group(1))
return cpam_data.get(ogc, [])
# 2. Fallback : chercher l'OGC dans le JSON fusionné
if structured_dir is not None:
dossier_dir = structured_dir / source_name
if dossier_dir.is_dir():
import json
for json_file in dossier_dir.glob("*_fusionne_cim10.json"):
try:
data = json.loads(json_file.read_text(encoding="utf-8"))
# Chercher dans controles_cpam existants
for ctrl in data.get("controles_cpam", []):
ctrl_ogc = ctrl.get("numero_ogc")
if ctrl_ogc and ctrl_ogc in cpam_data:
logger.info(
"OGC %d trouvé via fallback JSON pour dossier '%s'",
ctrl_ogc, source_name,
)
return cpam_data[ctrl_ogc]
except Exception:
pass
# Log des OGC non matchés
if cpam_data:
available_ogcs = sorted(cpam_data.keys())
logger.warning(
"OGC non trouvé pour dossier '%s'. OGC disponibles : %s",
source_name, available_ogcs,
)
return []

View File

@@ -26,15 +26,15 @@ def _search_rag_for_control(controle: ControleCPAM, dossier: DossierMedical) ->
"""
try:
from ..medical.rag_search import search_similar_cpam
except Exception:
logger.warning("Index RAG non disponible pour la contre-argumentation")
except ImportError:
logger.error("CPAM RAG : module rag_search non disponible (faiss-cpu manquant ?)")
return []
try:
return _search_rag_queries(controle, dossier, search_similar_cpam)
except Exception:
logger.warning("Erreur RAG pour la contre-argumentation — génération sans sources",
exc_info=True)
logger.error("CPAM RAG : erreur recherche — contre-argumentation sans sources",
exc_info=True)
return []

View File

@@ -8,9 +8,13 @@ Orchestrateur principal — délègue aux sous-modules :
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from ..config import ControleCPAM, DossierMedical, RAGSource, rule_enabled
from ..config import ControleCPAM, DossierMedical, RAGSource, STRUCTURED_DIR, rule_enabled
from ..medical.ollama_client import call_anthropic, call_ollama
from ..prompts import CPAM_EXTRACTION
@@ -50,6 +54,70 @@ from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_
logger = logging.getLogger(__name__)
def _save_version(
dossier: DossierMedical,
controle: ControleCPAM,
) -> None:
"""Sauvegarde la version actuelle de l'argumentaire avant régénération.
Stocke dans output/structured/{dossier}/_cpam_versions/{ogc}_{timestamp}.json
"""
if not controle.contre_argumentation and not controle.response_data:
return # rien à versionner
# Trouver le dossier structuré (depuis source_files ou source_file)
dossier_dir = None
if not STRUCTURED_DIR.is_dir():
logger.debug("Versioning : STRUCTURED_DIR inexistant, skip")
return
structured_dirs = [d for d in STRUCTURED_DIR.iterdir() if d.is_dir()]
# Tentative 1 : matcher un source_file contre les noms de sous-dossiers
candidates = list(dossier.source_files or [])
if dossier.source_file and dossier.source_file not in candidates:
candidates.append(dossier.source_file)
for src in candidates:
src_stem = Path(src).stem.replace(" ", "_")
for d in structured_dirs:
if src_stem in d.name:
dossier_dir = d
break
if dossier_dir:
break
if not dossier_dir:
logger.debug("Versioning : pas de dossier structuré trouvé, skip")
return
versions_dir = dossier_dir / "_cpam_versions"
versions_dir.mkdir(exist_ok=True)
# Compter les versions existantes pour cet OGC
existing = sorted(versions_dir.glob(f"{controle.numero_ogc}_*.json"))
version_num = len(existing) + 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{controle.numero_ogc}_{timestamp}_v{version_num}.json"
version_data = {
"numero_ogc": controle.numero_ogc,
"version": version_num,
"timestamp": timestamp,
"contre_argumentation": controle.contre_argumentation,
"response_data": controle.response_data,
"quality_tier": controle.quality_tier,
"validation_dim": controle.validation_dim,
}
(versions_dir / filename).write_text(
json.dumps(version_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(" Version %d sauvegardée : %s", version_num, filename)
def _extraction_pass(
dossier: DossierMedical,
controle: ControleCPAM,
@@ -121,6 +189,9 @@ def generate_cpam_response(
logger.info("CPAM : génération contre-argumentation pour OGC %d%s",
controle.numero_ogc, controle.titre)
# 0. Versioning — sauvegarder la version précédente avant d'écraser
_save_version(dossier, controle)
# 1. Passe 1 — Extraction structurée (compréhension avant argumentation)
extraction = _extraction_pass(dossier, controle)
degraded_pass1 = extraction is None
@@ -137,12 +208,12 @@ def generate_cpam_response(
prompt, tag_map = _build_cpam_prompt(dossier, controle, sources, extraction)
# 4. Appel LLM — Ollama (rôle cpam) > Haiku fallback
result = call_ollama(prompt, temperature=0.1, max_tokens=16000, role="cpam")
result = call_ollama(prompt, temperature=0.1, max_tokens=8000, role="cpam")
if result is not None:
logger.info(" Contre-argumentation via Ollama")
else:
logger.info(" Ollama indisponible → fallback Anthropic Haiku")
result = call_anthropic(prompt, temperature=0.1, max_tokens=16000)
result = call_anthropic(prompt, temperature=0.1, max_tokens=8000)
if result is not None:
logger.info(" Contre-argumentation via Anthropic Haiku")
@@ -213,8 +284,8 @@ def generate_cpam_response(
if adversarial_warnings:
adversarial_warnings.append(f"Score de confiance : {score}/10")
# 8b. Boucle de correction (max 2 retries)
max_corrections = 2
# 8b. Boucle de correction (configurable via T2A_CPAM_MAX_CORRECTIONS, défaut 2)
max_corrections = int(os.environ.get("T2A_CPAM_MAX_CORRECTIONS", "2"))
for attempt in range(max_corrections):
if not (validation
and not validation.get("coherent", True)

View File

@@ -0,0 +1,2 @@
from .document_router import SUPPORTED_EXTENSIONS, extract_document_with_pages
from .pdf_extractor import ExtractionMethod, ExtractionStats

View File

@@ -0,0 +1,62 @@
"""Router d'extraction multi-format.
Point d'entrée unique qui dispatch vers le bon extracteur selon l'extension du fichier.
"""
from __future__ import annotations
import logging
from pathlib import Path
from .page_tracker import PageTracker
from .pdf_extractor import ExtractionStats
logger = logging.getLogger(__name__)
SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".tiff", ".tif", ".docx"}
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
def extract_document_with_pages(
file_path: str | Path,
) -> tuple[str, PageTracker, ExtractionStats]:
"""Extrait le texte de n'importe quel format supporté.
Dispatch automatique selon l'extension du fichier :
- .pdf → extraction native (pdfplumber/PyMuPDF) avec fallback OCR optionnel
- .jpg/.jpeg/.png/.tiff/.tif → OCR via docTR
- .docx → extraction via python-docx
Args:
file_path: Chemin vers le document.
Returns:
(texte_complet, page_tracker, extraction_stats)
Raises:
ValueError: Si l'extension n'est pas supportée.
FileNotFoundError: Si le fichier n'existe pas.
"""
file_path = Path(file_path)
ext = file_path.suffix.lower()
if ext == ".pdf":
from .pdf_extractor import extract_text_with_pages
return extract_text_with_pages(file_path)
if ext in _IMAGE_EXTENSIONS:
from .image_extractor import extract_text_from_image
return extract_text_from_image(file_path)
if ext == ".docx":
from .docx_extractor import extract_text_from_docx
return extract_text_from_docx(file_path)
raise ValueError(
f"Format non supporté : {ext}. "
f"Formats acceptés : {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
)

View File

@@ -0,0 +1,106 @@
"""Extraction de texte depuis des fichiers DOCX via python-docx."""
from __future__ import annotations
import logging
from pathlib import Path
from .page_tracker import PageTracker
from .pdf_extractor import ExtractionMethod, ExtractionStats
logger = logging.getLogger(__name__)
def extract_text_from_docx(
docx_path: str | Path,
) -> tuple[str, PageTracker, ExtractionStats]:
"""Extrait le texte d'un fichier DOCX.
Détecte les sauts de page via les éléments <w:br w:type="page"/> dans les runs.
Chaque section entre sauts de page est considérée comme une "page".
Args:
docx_path: Chemin vers le fichier DOCX.
Returns:
(texte_complet, page_tracker, extraction_stats)
"""
from docx import Document
from docx.oxml.ns import qn
docx_path = Path(docx_path)
if not docx_path.exists():
raise FileNotFoundError(f"DOCX non trouvé : {docx_path}")
logger.info("Extraction de %s", docx_path.name)
doc = Document(str(docx_path))
# Collecter le texte par "page" (séparé par les sauts de page)
pages_text: list[str] = []
current_page_lines: list[str] = []
for paragraph in doc.paragraphs:
# Vérifier les sauts de page dans les runs
has_page_break = False
for run in paragraph.runs:
for br in run._element.findall(qn("w:br")):
if br.get(qn("w:type")) == "page":
has_page_break = True
break
if has_page_break:
break
if has_page_break and current_page_lines:
pages_text.append("\n".join(current_page_lines))
current_page_lines = []
text = paragraph.text.strip()
if text:
current_page_lines.append(text)
# Dernière page
if current_page_lines:
pages_text.append("\n".join(current_page_lines))
# Si aucun saut de page détecté, tout est sur une seule "page"
if not pages_text:
pages_text = [""]
# Construire le texte complet avec séparateurs
separator = "\n\n"
page_offsets: list[tuple[int, int]] = []
offset = 0
for page_text in pages_text:
start = offset
end = offset + len(page_text)
page_offsets.append((start, end))
offset = end + len(separator)
full_text = separator.join(pages_text)
# Stats
total_chars = sum(len(p.strip()) for p in pages_text)
chars_per_page = [len(p.strip()) for p in pages_text]
empty_pages = [i + 1 for i, n in enumerate(chars_per_page) if n == 0]
stats = ExtractionStats(
total_pages=len(pages_text),
empty_pages=empty_pages,
chars_per_page=chars_per_page,
total_chars=total_chars,
methods=[ExtractionMethod.DOCX] * len(pages_text),
native_pages=len(pages_text),
ocr_pages=0,
backend="python-docx",
source_format="docx",
)
tracker = PageTracker(page_offsets)
logger.info(
" DOCX : %d page(s), %d caractères",
len(pages_text),
total_chars,
)
return full_text, tracker, stats

View File

@@ -0,0 +1,56 @@
"""Extraction de texte depuis des images (JPEG, PNG, TIFF) via docTR OCR."""
from __future__ import annotations
import logging
from pathlib import Path
import numpy as np
from PIL import Image
from .ocr_engine import ocr_image
from .page_tracker import PageTracker
from .pdf_extractor import ExtractionMethod, ExtractionStats
logger = logging.getLogger(__name__)
def extract_text_from_image(
image_path: str | Path,
) -> tuple[str, PageTracker, ExtractionStats]:
"""Extrait le texte d'une image via docTR OCR.
Args:
image_path: Chemin vers l'image (JPEG, PNG, TIFF).
Returns:
(texte_complet, page_tracker, extraction_stats)
"""
image_path = Path(image_path)
if not image_path.exists():
raise FileNotFoundError(f"Image non trouvée : {image_path}")
logger.info("Extraction OCR de %s", image_path.name)
img = Image.open(image_path).convert("RGB")
img_array = np.array(img)
text = ocr_image(img_array)
n_chars = len(text.strip())
stats = ExtractionStats(
total_pages=1,
empty_pages=[1] if n_chars == 0 else [],
chars_per_page=[n_chars],
total_chars=n_chars,
methods=[ExtractionMethod.IMAGE],
native_pages=0,
ocr_pages=1,
backend="doctr",
source_format="image",
)
page_offsets = [(0, len(text))]
tracker = PageTracker(page_offsets)
logger.info(" OCR image : %d caractères extraits", n_chars)
return text, tracker, stats

View File

@@ -0,0 +1,54 @@
"""Moteur OCR partagé basé sur docTR (lazy loading)."""
from __future__ import annotations
import logging
import numpy as np
logger = logging.getLogger(__name__)
_doctr_predictor = None
def get_ocr_model():
"""Charge le modèle docTR une seule fois (lazy).
Le chargement est coûteux (~1-2s + mémoire), d'où le singleton.
"""
global _doctr_predictor
if _doctr_predictor is None:
logger.info("Chargement du modèle docTR...")
from doctr.models import ocr_predictor
_doctr_predictor = ocr_predictor(
det_arch="db_resnet50",
reco_arch="crnn_vgg16_bn",
pretrained=True,
assume_straight_pages=True,
)
logger.info("Modèle docTR chargé.")
return _doctr_predictor
def ocr_image(image: np.ndarray) -> str:
"""OCR une image numpy (RGB, HxWx3) et retourne le texte extrait.
Args:
image: Array numpy RGB (H, W, 3).
Returns:
Texte extrait, lignes séparées par '\\n'.
"""
predictor = get_ocr_model()
result = predictor([image])
lines: list[str] = []
for page in result.pages:
for block in page.blocks:
for line in block.lines:
words = [w.value for w in line.words if w.confidence >= 0.3]
if words:
lines.append(" ".join(words))
return "\n".join(lines)

View File

@@ -1,66 +1,281 @@
"""Extraction de texte et tableaux depuis les PDF via pdfplumber."""
"""Extraction de texte et tableaux depuis les PDF via pdfplumber / PyMuPDF."""
from __future__ import annotations
import io
import logging
import os
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
import pdfplumber
from .page_tracker import PageTracker
from .text_cleaner import clean_extracted_text
logger = logging.getLogger(__name__)
def extract_text(pdf_path: str | Path) -> str:
# Seuil en caractères : en-dessous, une page est considérée "pauvre"
_MIN_CHARS_USEFUL = 30
# --- Configuration multi-backend ---
# Backend PDF : "pdfplumber" (défaut) ou "pymupdf"
PDF_BACKEND = os.environ.get("T2A_PDF_BACKEND", "pdfplumber")
# OCR fallback pour pages vides dans les PDF (désactivé par défaut)
OCR_FALLBACK_ENABLED = os.environ.get("T2A_OCR_FALLBACK", "0") == "1"
# Seuil min de caractères pour déclencher le fallback OCR
OCR_FALLBACK_MIN_CHARS = int(os.environ.get("T2A_OCR_MIN_CHARS", "30"))
class ExtractionMethod(str, Enum):
"""Méthode d'extraction utilisée pour une page."""
NATIVE_PDFPLUMBER = "native_pdfplumber"
NATIVE_PYMUPDF = "native_pymupdf"
OCR_DOCTR = "ocr_doctr"
DOCX = "docx"
IMAGE = "image_ocr"
@dataclass
class ExtractionStats:
"""Statistiques de qualité d'extraction."""
total_pages: int = 0
empty_pages: list[int] = field(default_factory=list) # 1-indexed
low_content_pages: list[int] = field(default_factory=list) # < _MIN_CHARS_USEFUL
chars_per_page: list[int] = field(default_factory=list)
total_chars: int = 0
methods: list[ExtractionMethod] = field(default_factory=list) # par page
native_pages: int = 0
ocr_pages: int = 0
backend: str = "pdfplumber"
source_format: str = "pdf"
@property
def usable_pages(self) -> int:
return self.total_pages - len(self.empty_pages)
@property
def coverage_ratio(self) -> float:
"""Ratio de pages avec contenu exploitable (0.0 → 1.0)."""
if self.total_pages == 0:
return 1.0
return self.usable_pages / self.total_pages
def has_quality_issues(self) -> bool:
return len(self.empty_pages) > 0
def to_alert(self) -> str | None:
"""Génère une alerte lisible si des pages sont vides."""
if not self.empty_pages:
return None
pages_str = ", ".join(str(p) for p in self.empty_pages)
pct = round((1 - self.coverage_ratio) * 100)
return (
f"EXTRACTION : {len(self.empty_pages)}/{self.total_pages} page(s) "
f"sans texte extractible (p. {pages_str}) — {pct}% du document ignoré, "
f"possibles pages scannées ou images"
)
def to_flags(self) -> dict:
"""Retourne un dict pour quality_flags."""
if not self.empty_pages:
return {}
return {
"extraction_empty_pages": self.empty_pages,
"extraction_total_pages": self.total_pages,
"extraction_coverage": round(self.coverage_ratio, 2),
}
def _compute_extraction_stats(
pages_text: list[str],
methods: list[ExtractionMethod] | None = None,
backend: str = "pdfplumber",
) -> ExtractionStats:
"""Analyse la qualité d'extraction page par page."""
stats = ExtractionStats(
total_pages=len(pages_text),
backend=backend,
source_format="pdf",
)
if methods:
stats.methods = methods
for i, text in enumerate(pages_text):
n = len(text.strip())
stats.chars_per_page.append(n)
stats.total_chars += n
if n == 0:
stats.empty_pages.append(i + 1) # 1-indexed
elif n < _MIN_CHARS_USEFUL:
stats.low_content_pages.append(i + 1)
# Compteurs native/ocr
for m in stats.methods:
if m == ExtractionMethod.OCR_DOCTR:
stats.ocr_pages += 1
else:
stats.native_pages += 1
return stats
def _open_pdf(pdf_path: str | Path, backend: str):
"""Ouvre un PDF avec le backend choisi."""
if backend == "pymupdf":
import fitz
return fitz.open(str(pdf_path))
import pdfplumber
return pdfplumber.open(pdf_path)
def _extract_page_native(page, backend: str) -> str:
"""Extrait le texte natif d'une page selon le backend."""
if backend == "pymupdf":
return page.get_text() or ""
return page.extract_text() or ""
def _page_to_image_array(page, backend: str):
"""Convertit une page PDF en array numpy RGB pour OCR."""
import numpy as np
from PIL import Image
if backend == "pymupdf":
zoom = 300 / 72 # 300 DPI
import fitz
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB")
else:
# pdfplumber → PIL Image
img_obj = page.to_image(resolution=300)
img = img_obj.original.convert("RGB")
return np.array(img)
def _get_pages(pdf, backend: str):
"""Retourne la liste des pages selon le backend."""
if backend == "pymupdf":
return [pdf[i] for i in range(len(pdf))]
return pdf.pages
def extract_text(pdf_path: str | Path, backend: str | None = None) -> str:
"""Extrait le texte de toutes les pages d'un PDF."""
backend = backend or PDF_BACKEND
pages_text: list[str] = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
with _open_pdf(pdf_path, backend) as pdf:
for page in _get_pages(pdf, backend):
text = _extract_page_native(page, backend)
text = clean_extracted_text(text)
pages_text.append(text)
return "\n\n".join(pages_text)
def extract_text_with_pages(pdf_path: str | Path) -> tuple[str, PageTracker]:
"""Extrait le texte avec un tracker de pages pour la traçabilité.
def extract_text_with_pages(
pdf_path: str | Path,
backend: str | None = None,
) -> tuple[str, PageTracker, ExtractionStats]:
"""Extrait le texte avec un tracker de pages et des statistiques de qualité.
Supporte pdfplumber et PyMuPDF, avec fallback OCR optionnel (T2A_OCR_FALLBACK=1).
Returns:
(texte_complet, page_tracker) où page_tracker permet de retrouver
la page source de chaque position de caractère.
(texte_complet, page_tracker, extraction_stats)
"""
pages_text: list[str] = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
text = clean_extracted_text(text)
pages_text.append(text)
backend = backend or PDF_BACKEND
native_method = (
ExtractionMethod.NATIVE_PYMUPDF
if backend == "pymupdf"
else ExtractionMethod.NATIVE_PDFPLUMBER
)
# Construire le texte complet avec "\n\n" comme séparateur (identique à extract_text)
pages_text_final: list[str] = []
methods_final: list[ExtractionMethod] = []
with _open_pdf(pdf_path, backend) as pdf:
pages = _get_pages(pdf, backend)
for i, page in enumerate(pages):
text = _extract_page_native(page, backend)
text = clean_extracted_text(text)
method = native_method
# OCR fallback si activé et page pauvre
if (
OCR_FALLBACK_ENABLED
and len(text.strip()) < OCR_FALLBACK_MIN_CHARS
):
try:
from .ocr_engine import ocr_image
img_array = _page_to_image_array(page, backend)
ocr_text = ocr_image(img_array)
if len(ocr_text.strip()) > len(text.strip()):
logger.info(
" Page %d : fallback OCR (%d%d chars)",
i + 1,
len(text.strip()),
len(ocr_text.strip()),
)
text = ocr_text
method = ExtractionMethod.OCR_DOCTR
except Exception:
logger.warning(
" Page %d : échec OCR fallback", i + 1, exc_info=True
)
pages_text_final.append(text)
methods_final.append(method)
stats = _compute_extraction_stats(pages_text_final, methods_final, backend)
if stats.empty_pages:
logger.warning(
" %s : %d/%d pages vides (p. %s) — possibles scans/images",
Path(pdf_path).name,
len(stats.empty_pages),
stats.total_pages,
", ".join(str(p) for p in stats.empty_pages),
)
if stats.ocr_pages:
logger.info(
" %s : %d page(s) via OCR fallback",
Path(pdf_path).name,
stats.ocr_pages,
)
# Construire le texte complet avec "\n\n" comme séparateur
separator = "\n\n"
page_offsets: list[tuple[int, int]] = []
offset = 0
for i, page_text in enumerate(pages_text):
for page_text in pages_text_final:
start = offset
end = offset + len(page_text)
page_offsets.append((start, end))
offset = end + len(separator)
full_text = separator.join(pages_text)
return full_text, PageTracker(page_offsets)
full_text = separator.join(pages_text_final)
return full_text, PageTracker(page_offsets), stats
def extract_pages(pdf_path: str | Path) -> list[str]:
def extract_pages(pdf_path: str | Path, backend: str | None = None) -> list[str]:
"""Extrait le texte page par page."""
backend = backend or PDF_BACKEND
pages: list[str] = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
pages.append(page.extract_text() or "")
with _open_pdf(pdf_path, backend) as pdf:
for page in _get_pages(pdf, backend):
text = _extract_page_native(page, backend)
text = clean_extracted_text(text)
pages.append(text)
return pages
def extract_tables(pdf_path: str | Path) -> list[list[list[str | None]]]:
"""Extrait tous les tableaux détectés dans le PDF."""
"""Extrait tous les tableaux détectés dans le PDF (pdfplumber uniquement)."""
all_tables: list[list[list[str | None]]] = []
import pdfplumber
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
tables = page.extract_tables() or []

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import re
from src.medical.das_filter import clean_diagnostic_text, is_valid_diagnostic_text
from ..medical.das_filter import clean_diagnostic_text, is_valid_diagnostic_text
def parse_trackare(text: str) -> dict:

View File

@@ -26,12 +26,14 @@ from .config import (
from .extraction.document_classifier import classify
from .extraction.crh_parser import parse_crh
from .extraction.document_splitter import split_documents
from .extraction.document_router import SUPPORTED_EXTENSIONS, extract_document_with_pages
from .extraction.pdf_extractor import extract_text, extract_text_with_pages
from .extraction.trackare_parser import parse_trackare
from .medical.cim10_extractor import extract_medical_info
from .medical.ghm import estimate_ghm
from .quality.veto_engine import apply_vetos
from .quality.decision_engine import apply_decisions, decision_summaries
from .quality.completude import build_completude_checklist
from .quality.rules_router import build_rules_runtime_context
logging.basicConfig(
@@ -141,17 +143,19 @@ _use_edsnlp = True
_use_rag = True
def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, AnonymizationReport]]:
"""Traite un PDF : extraction → splitting → parsing → anonymisation → extraction CIM-10.
def process_document(file_path: Path) -> list[tuple[str, DossierMedical, AnonymizationReport]]:
"""Traite un document : extraction → splitting → parsing → anonymisation → extraction CIM-10.
Supporte PDF, images (JPEG/PNG/TIFF) et DOCX via le router d'extraction.
Retourne une liste de (texte_anonymisé, dossier, rapport) — un par dossier détecté.
"""
t0 = time.time()
logger.info("Traitement de %s", pdf_path.name)
logger.info("Traitement de %s", file_path.name)
# 1. Extraction texte avec pages
raw_text, page_tracker = extract_text_with_pages(pdf_path)
logger.info(" Texte extrait : %d caractères", len(raw_text))
# 1. Extraction texte avec pages (multi-format)
raw_text, page_tracker, extraction_stats = extract_document_with_pages(file_path)
logger.info(" Texte extrait : %d caractères (%d pages, format=%s)", len(raw_text), extraction_stats.total_pages, extraction_stats.source_format)
# 2. Classification
doc_type = classify(raw_text)
@@ -160,7 +164,7 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
# 3. Splitting multi-dossiers
chunks = split_documents(raw_text, doc_type)
if len(chunks) > 1:
logger.info(" Découpage : %d dossiers détectés dans %s", len(chunks), pdf_path.name)
logger.info(" Découpage : %d dossiers détectés dans %s", len(chunks), file_path.name)
results: list[tuple[str, DossierMedical, AnonymizationReport]] = []
for i, chunk_text in enumerate(chunks):
@@ -177,7 +181,7 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
anonymizer = Anonymizer(parsed_data=parsed)
anonymized_text = anonymizer.anonymize(chunk_text)
report = anonymizer.report
report.source_file = pdf_path.name
report.source_file = file_path.name
logger.info(
" Anonymisation%s : %d remplacements (regex=%d, ner=%d, sweep=%d)",
part_label,
@@ -197,10 +201,18 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
parsed, anonymized_text, edsnlp_result, use_rag=_use_rag,
page_tracker=page_tracker, raw_text=raw_text,
)
dossier.source_file = pdf_path.name
dossier.source_file = file_path.name
dossier.document_type = doc_type
logger.info(" DP%s : %s", part_label, dossier.diagnostic_principal)
# Injection des stats d'extraction dans quality_flags
extraction_flags = extraction_stats.to_flags()
if extraction_flags:
dossier.quality_flags.update(extraction_flags)
extraction_alert = extraction_stats.to_alert()
if extraction_alert:
dossier.alertes_codage.append(extraction_alert)
# 8. Vetos (contestabilité) + décisions (post-traitement)
# Routage des règles (packs) : par défaut, on garde le socle vetos/decisions,
# et on active des packs additionnels selon les signaux du dossier (codes/labs/extraits).
@@ -216,14 +228,17 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
if rules_ctx.get("triggers_fired"):
logger.info(" Règles%s : triggers=%s", part_label, ",".join(rules_ctx["triggers_fired"]))
except Exception:
logger.warning(" Routage règles : erreur", exc_info=True)
logger.error(" Routage règles : erreur", exc_info=True)
dossier.quality_flags["rules_routing"] = "error"
veto = None
try:
veto = apply_vetos(dossier)
dossier.veto_report = veto
except Exception:
logger.warning(" Vetos : erreur lors du contrôle", exc_info=True)
logger.error(" Vetos : erreur lors du contrôle", exc_info=True)
dossier.quality_flags["veto_engine"] = "error"
dossier.alertes_codage.append("QUALITE DEGRADEE : moteur de vetos en erreur")
try:
apply_decisions(dossier)
@@ -231,11 +246,18 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
if veto is not None:
_inject_veto_alerts(dossier, veto, scope="PDF")
except Exception:
logger.warning(" Décisions : erreur lors du post-traitement", exc_info=True)
logger.error(" Décisions : erreur lors du post-traitement", exc_info=True)
dossier.quality_flags["decision_engine"] = "error"
finally:
if rules_token is not None:
reset_rules_runtime(rules_token)
try:
dossier.completude = build_completude_checklist(dossier)
except Exception:
logger.error(" Complétude : erreur lors du contrôle", exc_info=True)
dossier.quality_flags["completude"] = "error"
# 9. Estimation GHM (sur codes finaux) + métriques (actifs vs écartés)
try:
metrics = _compute_metrics(dossier)
@@ -260,14 +282,17 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
ghm.ghm_approx or "?",
)
except Exception:
logger.warning(" Erreur estimation GHM/metrics", exc_info=True)
logger.error(" Erreur estimation GHM/metrics", exc_info=True)
dossier.quality_flags["ghm_estimation"] = "error"
dossier.alertes_codage.append("QUALITE DEGRADEE : estimation GHM en erreur")
# 10. Finalizer DP (arbitrage Trackare vs CRH, traçabilité)
try:
from .medical.dp_finalizer import finalize_dp
finalize_dp(dossier)
except Exception:
logger.warning(" Finalizer DP : erreur", exc_info=True)
logger.error(" Finalizer DP : erreur", exc_info=True)
dossier.quality_flags["dp_finalizer"] = "error"
dossier.processing_time_s = round(time.time() - t0, 2)
results.append((anonymized_text, dossier, report))
@@ -276,6 +301,10 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
return results
# Alias backward-compatible
process_pdf = process_document
def _run_edsnlp(text: str):
"""Exécute l'analyse edsnlp avec fallback gracieux."""
try:
@@ -351,13 +380,13 @@ def main(input_path: str | None = None) -> None:
global _use_edsnlp, _use_rag
parser = argparse.ArgumentParser(
description="Anonymisation de documents médicaux PDF et extraction CIM-10",
description="Anonymisation de documents médicaux et extraction CIM-10 (PDF, images, DOCX)",
)
parser.add_argument(
"input",
nargs="*",
default=[input_path or "input/"],
help="Chemin(s) vers des PDFs, dossiers patients, ou le dossier racine (défaut: input/)",
help="Chemin(s) vers des documents, dossiers patients, ou le dossier racine (défaut: input/)",
)
parser.add_argument(
"--no-ner",
@@ -459,6 +488,24 @@ def main(input_path: str | None = None) -> None:
if args.no_rag:
_use_rag = False
# Vérification FAISS obligatoire si RAG actif
if _use_rag:
from .medical.rag_index import check_faiss_ready
faiss_status = check_faiss_ready()
if faiss_status["ok"]:
total_vecs = faiss_status["ref"] + faiss_status["proc"] + faiss_status["bio"] + faiss_status["legacy"]
logger.info("FAISS OK : %d vecteurs (ref=%d, proc=%d, bio=%d)",
total_vecs, faiss_status["ref"], faiss_status["proc"], faiss_status["bio"])
else:
for err in faiss_status["errors"]:
logger.error("FAISS : %s", err)
logger.error("FAISS non fonctionnel — le codage CIM-10 sera dégradé. "
"Lancez : python3 -m src.main --rebuild-index")
print("\n*** ATTENTION : Index FAISS absent ou invalide ***")
print("*** Le RAG est désactivé — qualité de codage dégradée ***")
print("*** Corrigez avec : python3 -m src.main --rebuild-index ***\n")
_use_rag = False
export_rum_flag = args.export_rum
# Chargement contrôle CPAM (auto-détection ou flag explicite)
@@ -480,7 +527,14 @@ def main(input_path: str | None = None) -> None:
input_paths = args.input
# Collecte des groupes (pdfs, subdir) à traiter
def _glob_supported(directory: Path) -> list[Path]:
"""Collecte tous les fichiers supportés dans un dossier."""
files: list[Path] = []
for ext in sorted(SUPPORTED_EXTENSIONS):
files.extend(directory.glob(f"*{ext}"))
return sorted(set(files))
# Collecte des groupes (documents, subdir) à traiter
groups: list[tuple[list[Path], str | None]] = []
for p in input_paths:
@@ -490,47 +544,47 @@ def main(input_path: str | None = None) -> None:
subdir = input_p.parent.name if input_p.parent.name != "input" else None
groups.append(([input_p], subdir))
elif input_p.is_dir():
# Vérifier s'il y a des PDFs directement dans ce dossier
root_pdfs = sorted(input_p.glob("*.pdf"))
# Vérifier s'il y a des sous-dossiers avec PDFs
sub_dirs = [c for c in sorted(input_p.iterdir()) if c.is_dir() and list(c.glob("*.pdf"))]
# Vérifier s'il y a des documents directement dans ce dossier
root_docs = _glob_supported(input_p)
# Vérifier s'il y a des sous-dossiers avec des documents
sub_dirs = [c for c in sorted(input_p.iterdir()) if c.is_dir() and _glob_supported(c)]
if sub_dirs:
# C'est un dossier racine (comme input/) → traiter chaque sous-dossier
for child in sub_dirs:
sub_pdfs = sorted(child.glob("*.pdf"))
groups.append((sub_pdfs, child.name))
elif root_pdfs:
sub_docs = _glob_supported(child)
groups.append((sub_docs, child.name))
elif root_docs:
# C'est un dossier patient directement → utiliser son nom comme subdir
groups.append((root_pdfs, input_p.name))
groups.append((root_docs, input_p.name))
else:
logger.error("Chemin introuvable : %s", input_p)
sys.exit(1)
total = sum(len(pdfs) for pdfs, _ in groups)
total = sum(len(docs) for docs, _ in groups)
if total == 0:
logger.warning("Aucun PDF trouvé dans %s", input_p)
logger.warning("Aucun document supporté trouvé dans %s", input_p)
sys.exit(0)
logger.info("Traitement de %d PDF(s)...", total)
logger.info("Traitement de %d document(s)...", total)
def _process_group(pdfs: list[Path], subdir: str | None) -> None:
"""Traite un groupe de PDFs (un dossier patient)."""
def _process_group(docs: list[Path], subdir: str | None) -> None:
"""Traite un groupe de documents (un dossier patient)."""
if subdir:
logger.info("--- Dossier %s (%d PDFs) ---", subdir, len(pdfs))
logger.info("--- Dossier %s (%d documents) ---", subdir, len(docs))
group_dossiers: list[DossierMedical] = []
for pdf_path in pdfs:
for doc_path in docs:
try:
pdf_results = process_pdf(pdf_path)
stem = pdf_path.stem.replace(" ", "_")
multi = len(pdf_results) > 1
for part_idx, (anonymized_text, dossier, report) in enumerate(pdf_results):
doc_results = process_document(doc_path)
stem = doc_path.stem.replace(" ", "_")
multi = len(doc_results) > 1
for part_idx, (anonymized_text, dossier, report) in enumerate(doc_results):
part_stem = f"{stem}_part{part_idx + 1}" if multi else stem
write_outputs(part_stem, anonymized_text, dossier, report, subdir=subdir, export_rum_flag=export_rum_flag)
group_dossiers.append(dossier)
except Exception:
logger.exception("Erreur lors du traitement de %s", pdf_path.name)
logger.exception("Erreur lors du traitement de %s", doc_path.name)
# Fusion multi-PDFs si plusieurs documents dans le même groupe
merged = None
@@ -611,6 +665,11 @@ def main(input_path: str | None = None) -> None:
if rules_token is not None:
reset_rules_runtime(rules_token)
try:
merged.completude = build_completude_checklist(merged)
except Exception:
logger.warning(" Complétude fusionné : erreur lors du contrôle", exc_info=True)
# Re-estimer le GHM (sur codes finaux) + métriques (actifs vs écartés)
try:
metrics = _compute_metrics(merged)
@@ -660,8 +719,8 @@ def main(input_path: str | None = None) -> None:
logger.info("Mode parallèle : %d workers", args.workers)
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {
executor.submit(_process_group, pdfs, subdir): subdir
for pdfs, subdir in groups
executor.submit(_process_group, docs, subdir): subdir
for docs, subdir in groups
}
for future in as_completed(futures):
try:
@@ -669,8 +728,8 @@ def main(input_path: str | None = None) -> None:
except Exception:
logger.exception("Erreur groupe %s", futures[future])
else:
for pdfs, subdir in groups:
_process_group(pdfs, subdir)
for docs, subdir in groups:
_process_group(docs, subdir)
logger.info("Terminé.")

View File

@@ -202,6 +202,9 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
(r"(?:[Gg]lyc[ée]mie|[Gg]lucose)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|g/L)?", "Glycémie"),
(r"\bHbA1c\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:%)?", "HbA1c"),
(r"\bTSH\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mUI/L)?", "TSH"),
# Albumine / Préalbumine (critère de sévérité HAS 2021 dénutrition)
(r"(?:[Aa]lbumin[ée]?(?:mie)?|[Aa]lb(?:u)?[ée]?(?:mie)?)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/[Ll])?", "Albumine"),
(r"(?:[Pp]r[ée]albumine|[Tt]ransthyr[ée]tine)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll]|g/[Ll])?", "Préalbumine"),
]

View File

@@ -38,6 +38,7 @@ from .bio_extraction import _extract_biologie
from .diagnostic_extraction import (
_extract_diagnostics,
_extract_actes,
_detect_nutrition_has2021,
CIM10_MAP,
CCAM_MAP,
)
@@ -58,6 +59,7 @@ from .bio_normals import BIO_NORMALS, _is_abnormal # noqa: F401
from .validation_pipeline import _is_dp_family_redundant # noqa: F401
from .diagnostic_extraction import _lookup_cim10 # noqa: F401
from .diagnostic_extraction import _DAS_PATTERNS # noqa: F401
from .diagnostic_extraction import _detect_nutrition_has2021 # noqa: F401
def extract_medical_info(
@@ -86,6 +88,7 @@ def extract_medical_info(
_extract_antecedents(anonymized_text, dossier)
_extract_traitements(parsed_data, anonymized_text, dossier, edsnlp_result)
_extract_biologie(anonymized_text, dossier)
_detect_nutrition_has2021(dossier)
_extract_imagerie(anonymized_text, dossier)
_extract_complications(anonymized_text, dossier, edsnlp_result)
@@ -134,7 +137,9 @@ def extract_medical_info(
f"NUKE-3 REVIEW: DP ambigu — {selection.reason}"
)
except Exception:
logger.warning("NUKE-3: erreur sélection DP", exc_info=True)
logger.error("NUKE-3: erreur sélection DP", exc_info=True)
dossier.quality_flags["dp_selection_status"] = "error"
dossier.alertes_codage.append("QUALITE DEGRADEE : sélection DP (NUKE-3) en erreur")
# Post-processing : validation des codes CCAM contre le dictionnaire
_validate_ccam(dossier)
@@ -240,7 +245,8 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
cache.save()
except Exception:
logger.warning("Erreur lors de l'extraction DAS LLM", exc_info=True)
logger.error("Erreur lors de l'extraction DAS LLM", exc_info=True)
dossier.quality_flags["das_llm_status"] = "error"
def _enrich_with_rag(dossier: DossierMedical) -> None:
@@ -249,9 +255,13 @@ def _enrich_with_rag(dossier: DossierMedical) -> None:
from .rag_search import enrich_dossier
enrich_dossier(dossier)
except ImportError:
logger.warning("Module RAG non disponible (faiss-cpu ou sentence-transformers manquant)")
logger.error("RAG INDISPONIBLE : faiss-cpu ou sentence-transformers manquant")
dossier.quality_flags["rag_status"] = "unavailable"
dossier.alertes_codage.append("QUALITE DEGRADEE : RAG indisponible — codage sans référentiels")
except Exception:
logger.warning("Erreur lors de l'enrichissement RAG", exc_info=True)
logger.error("RAG EN ERREUR : enrichissement échoué", exc_info=True)
dossier.quality_flags["rag_status"] = "error"
dossier.alertes_codage.append("QUALITE DEGRADEE : erreur RAG — codage sans référentiels")
def _extract_sejour(parsed: dict, dossier: DossierMedical) -> None:

View File

@@ -104,7 +104,9 @@ _DAS_PATTERNS: list[tuple[str, str, str]] = [
(r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+2|diabete\s+type\s*2", "Diabète de type 2", "E11.9"),
(r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+1|diabete\s+type\s*1", "Diabète de type 1", "E10.9"),
(r"dyslipidemie|hypercholesterolemie", "Dyslipidémie", "E78.5"),
(r"denutrition|malnutrition", "Dénutrition", "E46"),
(r"denutrition\s+severe|malnutrition\s+severe|denutrition\s+grade\s+(?:3|iii|III)", "Dénutrition sévère", "E43"),
(r"denutrition\s+moderee?|malnutrition\s+moderee?|denutrition\s+grade\s+(?:2|ii|II)", "Dénutrition modérée", "E44.0"),
(r"denutrition|malnutrition|hypoalbuminemie\s+severe", "Dénutrition", "E46"),
# Infectieux
(r"pneumopathie|pneumonie", "Pneumopathie", "J18.9"),
(r"infection\s+urinaire|pyelonephrite", "Infection urinaire", "N39.0"),
@@ -271,6 +273,91 @@ def _find_diagnostics_associes(
return das
def _detect_nutrition_has2021(dossier: DossierMedical) -> None:
"""Détecte la dénutrition selon les critères HAS/FFN novembre 2021.
Logique déterministe basée sur données structurées (IMC + âge + albumine).
- Critère phénotypique : IMC < seuil (âge-dépendant)
- Critère de sévérité : albumine < 30 g/L → sévère, 30-35 → modéré
- Code final : max(sévérité IMC, sévérité albumine) → E43 ou E44.0
Ref: HAS/FFN nov 2021 « Diagnostic de la dénutrition chez l'enfant,
l'adulte, et la personne de 70 ans et plus »
"""
# 1. Vérifier qu'aucun code E40-E46 n'est déjà codé
existing_codes: set[str] = set()
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
existing_codes.add(dossier.diagnostic_principal.cim10_suggestion)
for d in dossier.diagnostics_associes:
if d.cim10_suggestion:
existing_codes.add(d.cim10_suggestion)
for code in existing_codes:
if code.startswith(("E4",)) and code[:3] in ("E40", "E41", "E42", "E43", "E44", "E45", "E46"):
return # Déjà codé
# 2. Vérifier qu'on a un IMC (critère phénotypique obligatoire)
imc = dossier.sejour.imc if dossier.sejour else None
if imc is None:
return
age = dossier.sejour.age if dossier.sejour else None
# 3. Seuils IMC HAS 2021 (âge-dépendants)
if age is not None and age >= 70:
# Personne âgée ≥ 70 ans
if imc >= 22:
return # Au-dessus du seuil
imc_severe = imc < 20
imc_moderate = not imc_severe # 20 ≤ IMC < 22
else:
# Adulte 18-69 ans (ou âge inconnu → seuils adulte par défaut)
if imc >= 18.5:
return # Au-dessus du seuil
imc_severe = imc <= 17
imc_moderate = not imc_severe # 17 < IMC < 18.5
# 4. Critère de sévérité : albumine
albumine_val = None
for bio in dossier.biologie_cle:
if bio.test == "Albumine" and bio.valeur_num is not None:
if bio.quality != "discarded":
albumine_val = bio.valeur_num
break
albumine_severe = albumine_val is not None and albumine_val < 30
albumine_moderate = albumine_val is not None and 30 <= albumine_val < 35
# 5. Code final : max(sévérité IMC, sévérité albumine)
is_severe = imc_severe or albumine_severe
is_moderate = imc_moderate or albumine_moderate
if is_severe:
code = "E43"
label = "Dénutrition sévère"
elif is_moderate:
code = "E44.0"
label = "Dénutrition modérée"
else:
return # Ne devrait pas arriver vu les checks précédents
# 6. Construire l'alerte explicative
parts = []
if age is not None and age >= 70:
parts.append(f"IMC {imc} (seuil ≥70 ans : <22 modéré, <20 sévère)")
else:
parts.append(f"IMC {imc} (seuil adulte : <18.5 modéré, ≤17 sévère)")
if albumine_val is not None:
parts.append(f"Albumine {albumine_val} g/L (<30 sévère, 30-35 modéré)")
alerte = f"HAS 2021 — {label} ({code}) : {' ; '.join(parts)}"
dossier.diagnostics_associes.append(
Diagnostic(texte=label, cim10_suggestion=code, source="has2021")
)
dossier.alertes_codage.append(alerte)
logger.info("HAS 2021 dénutrition : %s ajouté (%s)", code, alerte)
def _extract_actes(text: str, dossier: DossierMedical) -> None:
"""Extrait les actes CCAM."""
text_lower = text.lower()

View File

@@ -11,7 +11,7 @@ Principes :
from __future__ import annotations
from src.config import DossierMedical, DPSelection
from ..config import DossierMedical, DPSelection
# Whitelist Z-codes admis en DP CONFIRMED (même que dp_selector)
_Z_CODE_DP_WHITELIST = frozenset({

View File

@@ -13,7 +13,7 @@ from __future__ import annotations
import bisect
from typing import Optional
from ..config import DossierMedical, GHMEstimation
from ..config import DossierMedical, FinancialImpact, GHMEstimation
# ---------------------------------------------------------------------------
@@ -229,3 +229,99 @@ def estimate_ghm(dossier: DossierMedical) -> GHMEstimation:
estimation.ghm_approx = f"{estimation.cmd}{estimation.type_ghm}??{estimation.severite}"
return estimation
# ---------------------------------------------------------------------------
# Tarifs moyens par CMD (source ATIH open data 2024, valeurs arrondies)
# Utilisé pour le tri relatif, pas pour la facturation.
# Format : cmd -> (tarif_base_euros, supplement_par_niveau_severite)
# ---------------------------------------------------------------------------
_CMD_TARIFS: dict[str, tuple[int, int]] = {
"01": (5500, 1200), # Neurologie
"02": (2800, 600), # Ophtalmologie
"03": (2500, 550), # ORL
"04": (3800, 900), # Pneumologie
"05": (4800, 1100), # Cardiologie
"06": (3500, 800), # Digestif (tube)
"07": (3200, 900), # Hépatobiliaire
"08": (4200, 950), # Ostéo-articulaire
"09": (2400, 500), # Peau
"10": (3000, 700), # Endocrinologie
"11": (3300, 800), # Rein/urinaire
"12": (2800, 650), # Génital masculin
"13": (2600, 600), # Génital féminin
"14": (3100, 700), # Obstétrique
"15": (4500, 1000), # Néonat/périnat
"16": (3400, 800), # Hémato/tumeurs bénignes
"17": (5200, 1100), # Tumeurs malignes
"18": (3600, 850), # Infectieux
"19": (2800, 600), # Psychiatrie
"20": (2200, 500), # Alcool/toxiques
"21": (3500, 800), # Traumatismes
"22": (5800, 1300), # Brûlures
"23": (2000, 400), # Symptômes/Z
"24": (2500, 500), # Causes externes
"25": (4200, 950), # VIH
"26": (3000, 700), # Catégories spéciales
}
_DEFAULT_TARIF = (3000, 800)
def estimate_financial_impact(
ghm_etab: GHMEstimation | None,
ghm_ucr: GHMEstimation | None = None,
) -> FinancialImpact:
"""Estime l'impact financier entre le GHM établissement et le GHM UCR.
Si ghm_ucr est None, on estime l'impact de perdre le codage actuel
vers une sévérité 1 (scénario conservateur).
"""
if not ghm_etab:
return FinancialImpact(raison="GHM établissement non estimé")
cmd = ghm_etab.cmd or ""
base, supplement = _CMD_TARIFS.get(cmd, _DEFAULT_TARIF)
sev_etab = ghm_etab.severite or 1
type_etab = ghm_etab.type_ghm or "M"
if ghm_ucr:
sev_ucr = ghm_ucr.severite or 1
type_ucr = ghm_ucr.type_ghm or "M"
else:
sev_ucr = 1
type_ucr = type_etab
delta_sev = sev_ucr - sev_etab # négatif = perte de sévérité
impact = abs(delta_sev) * supplement
# Changement de type (C→M = perte importante)
changement_type = type_etab != type_ucr
if changement_type and type_etab == "C" and type_ucr == "M":
impact += base # perte du GHS chirurgical
raison = f"Changement C→M + delta sévérité {delta_sev}"
elif changement_type:
impact += supplement
raison = f"Changement type {type_etab}{type_ucr} + delta sévérité {delta_sev}"
elif delta_sev == 0:
raison = "Pas de différence de sévérité estimée"
else:
raison = f"Delta sévérité {delta_sev} (CMD {cmd})"
# Classification priorité
if impact >= 2000 or (changement_type and type_etab == "C"):
priorite = "critique"
elif impact >= 1000 or abs(delta_sev) >= 2:
priorite = "haute"
elif impact > 0:
priorite = "normale"
else:
priorite = "faible"
return FinancialImpact(
delta_severite=delta_sev,
impact_estime_euros=impact,
priorite=priorite,
raison=raison,
)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -20,9 +21,15 @@ class OllamaCache:
Migration automatique depuis l'ancien format (model global) au chargement.
"""
def __init__(self, cache_path: Path, model: str | None = None):
# TTL par défaut : 30 jours (en secondes)
DEFAULT_TTL = 30 * 24 * 3600
def __init__(self, cache_path: Path, model: str | None = None, max_entries: int = 5000,
ttl: int | None = None):
self._path = cache_path
self._default_model = model
self._max_entries = max_entries
self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
self._lock = threading.Lock()
self._data: dict[str, dict] = {}
self._dirty = False
@@ -70,7 +77,7 @@ class OllamaCache:
return f"{diag_type}::{texte.strip().lower()}"
def get(self, texte: str, diag_type: str, model: str | None = None) -> dict | None:
"""Récupère un résultat caché, ou None si absent ou modèle différent."""
"""Récupère un résultat caché, ou None si absent, modèle différent ou expiré."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model
with self._lock:
@@ -79,15 +86,32 @@ class OllamaCache:
return None
if use_model and entry.get("model") != use_model:
return None
# Vérifier TTL
ts = entry.get("ts")
if self._ttl and ts and (time.time() - ts) > self._ttl:
del self._data[key]
self._dirty = True
return None
return entry.get("result")
def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None:
"""Stocke un résultat dans le cache avec le modèle utilisé."""
"""Stocke un résultat dans le cache avec le modèle utilisé et un timestamp."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model
with self._lock:
self._data[key] = {"model": use_model, "result": result}
self._data[key] = {"model": use_model, "result": result, "ts": time.time()}
self._dirty = True
self._evict_if_needed()
def _evict_if_needed(self) -> None:
"""Éviction LRU : supprime les 20% plus anciens si seuil dépassé."""
if self._max_entries and len(self._data) > self._max_entries:
to_remove = int(len(self._data) * 0.2)
keys_to_remove = list(self._data.keys())[:to_remove]
for k in keys_to_remove:
del self._data[k]
logger.info("Cache Ollama : éviction LRU de %d entrées (restant : %d)",
to_remove, len(self._data))
def save(self) -> None:
"""Persiste le cache sur disque si modifié."""

View File

@@ -28,10 +28,14 @@ def _get_anthropic_client():
return None
try:
import anthropic
except ImportError:
logger.warning("Anthropic SDK non installé (pip install anthropic)")
return None
try:
_anthropic_client = anthropic.Anthropic(api_key=api_key)
return _anthropic_client
except Exception as e:
logger.warning("Anthropic SDK non disponible : %s", e)
logger.error("Anthropic SDK erreur d'initialisation (clé API invalide ?) : %s", e)
return None
@@ -165,20 +169,25 @@ def call_ollama(
"""
use_model = model or (get_model(role) if role else OLLAMA_MODEL)
use_timeout = timeout or OLLAMA_TIMEOUT
messages: list[dict] = [{"role": "user", "content": prompt}]
for attempt in range(3):
try:
payload: dict = {
"model": use_model,
"messages": messages,
"stream": False,
"format": "json",
"think": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
response = requests.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": use_model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"format": "json",
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
},
json=payload,
timeout=use_timeout,
)
# 429 rate limit → retry avec backoff exponentiel

View File

@@ -30,6 +30,55 @@ _loaded: dict[str, tuple] = {}
_loaded_lock = threading.Lock()
def check_faiss_ready() -> dict:
"""Vérifie que les index FAISS sont présents et valides.
Returns:
{"ok": bool, "ref": int, "proc": int, "bio": int, "legacy": int, "errors": [str]}
Les int = nombre de vecteurs chargés (0 si absent).
"""
result = {"ok": False, "ref": 0, "proc": 0, "bio": 0, "legacy": 0, "errors": []}
if not RAG_INDEX_DIR.exists():
result["errors"].append(f"Répertoire FAISS absent : {RAG_INDEX_DIR}")
return result
try:
import faiss
except ImportError:
result["errors"].append("Module faiss-cpu non installé")
return result
has_any = False
for kind in ("ref", "proc", "bio", "all"):
idx_path, meta_path = _paths(kind)
key = kind if kind != "all" else "legacy"
if idx_path.exists() and meta_path.exists():
try:
idx = faiss.read_index(str(idx_path))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
n_vectors = idx.ntotal
n_meta = len(meta)
if n_vectors == 0:
result["errors"].append(f"Index {kind} vide (0 vecteurs)")
elif n_vectors != n_meta:
result["errors"].append(
f"Index {kind} désynchronisé : {n_vectors} vecteurs vs {n_meta} métadonnées"
)
else:
has_any = True
result[key] = n_vectors
except Exception as e:
result["errors"].append(f"Index {kind} corrompu : {e}")
if not has_any:
result["errors"].append("Aucun index FAISS valide trouvé — lancez build_index()")
else:
result["ok"] = True
return result
@dataclass
class Chunk:
text: str
@@ -593,6 +642,16 @@ def build_index(force: bool = False) -> None:
# Invalider les singletons
reset_index()
# Invalider le cache LLM (les résultats ont été générés avec l'ancien index)
try:
from ..config import OLLAMA_CACHE_PATH
if OLLAMA_CACHE_PATH.exists():
backup = OLLAMA_CACHE_PATH.with_suffix(".pre_rebuild.json")
OLLAMA_CACHE_PATH.rename(backup)
logger.info("Cache LLM invalidé (sauvegardé → %s) — les résultats seront régénérés avec le nouvel index", backup)
except Exception:
logger.warning("Impossible d'invalider le cache LLM après rebuild", exc_info=True)
def get_index(kind: str = "ref") -> tuple | None:
"""Charge un index FAISS et ses métadonnées (singleton lazy-loaded).

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import os
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -56,7 +57,7 @@ def _get_embed_model():
raise RuntimeError("Modèle d'embedding indisponible (échec précédent)")
from sentence_transformers import SentenceTransformer
import torch
_device = "cuda" if torch.cuda.is_available() else "cpu"
_device = "cpu" if os.environ.get("T2A_EMBED_CPU") else ("cuda" if torch.cuda.is_available() else "cpu")
_model_kwargs = {"low_cpu_mem_usage": False}
try:
logger.info("Chargement du modèle d'embedding (%s)...", _device)

View File

@@ -50,7 +50,9 @@ _HEURISTIC_CMA_ROOTS: set[str] = {
# Hématologie / nutrition
"D64", # Anémie
"D65", # CIVD
"E46", # Dénutrition
"E43", # Dénutrition sévère (CMA niveau 3)
"E44", # Dénutrition modérée
"E46", # Dénutrition sans précision
"E87", # Troubles hydro-électrolytiques
"E86", # Déshydratation
# Métabolique

View File

@@ -15,7 +15,8 @@ Variables par template :
CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre,
arg_ucr, decision_ucr, codes_str, definitions_str,
codes_autorises_str, sources_text, extraction_str,
bio_confrontation_str, numero_ogc
bio_confrontation_str, numero_ogc,
strategie_type_str
CPAM_ADVERSARIAL : response_json, factual_section, normes_section,
dp_ucr_line, da_ucr_line
DP_RANKER_CONSTRAINED : candidates_str, ctx_str, n_candidates
@@ -119,6 +120,14 @@ RÈGLES IMPÉRATIVES :
- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte
- ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale.
DÉNUTRITION — CRITÈRES HAS/FFN 2021 :
- Diagnostic = 1 critère phénotypique + 1 critère étiologique
- Seuils IMC : adulte <18.5 modéré / ≤17 sévère ; ≥70 ans <22 modéré / <20 sévère
- Perte de poids : ≥5%/1mois ou ≥10%/6mois modéré ; ≥10%/1mois ou ≥15%/6mois sévère
- L'albumine est un critère de SÉVÉRITÉ uniquement : 30-35 g/L → E44.0 ; <30 g/L → E43
- Un patient OBÈSE peut être dénutri
- Codes : E44.0 (modéré), E43 (sévère), E46 seulement si sévérité non précisable
DIAGNOSTIC PRINCIPAL : {dp_texte}
DAS DÉJÀ CODÉS :
@@ -268,6 +277,8 @@ Objet : {titre}
Argument UCR : {arg_ucr}
Décision UCR : {decision_ucr}
{strategie_type_str}
CODES EN JEU : {codes_str}
{definitions_str}
{codes_autorises_str}
@@ -293,6 +304,10 @@ PASSE 2 — MOTIF D'HOSPITALISATION RÉEL :
- Pourquoi CE patient a été hospitalisé CE JOUR (événement déclencheur)
- Quel acte thérapeutique principal a été réalisé
- Le DP retenu est-il cohérent avec cet acte et la durée de séjour
- RÈGLES D1/D2 DU GUIDE MÉTHODOLOGIQUE :
D1 : Si seul un symptôme persiste sans cause identifiée dans le dossier, le symptôme reste DP légitime
D2 : Si une cause est identifiée (confirmée par examens), la cause doit devenir DP
Appliquer D1/D2 dans le raisonnement si le désaccord porte sur le DP
PASSE 3 — CONFRONTATION BIOLOGIE / DIAGNOSTIC (appliquer R1 et R3) :
Pour CHAQUE diagnostic contesté, comparer aux seuils ci-dessus.

529
src/quality/completude.py Normal file
View File

@@ -0,0 +1,529 @@
"""Checklist de complétude documentaire DIM.
Pour chaque code diagnostique (DP + DAS), vérifie la présence des éléments
cliniques nécessaires (biologie, imagerie, documents, données cliniques),
confronte les valeurs aux seuils diagnostiques, croise les preuves cliniques,
et calcule un score de défendabilité CPAM.
"""
from __future__ import annotations
import logging
import re
import unicodedata
from typing import Optional
from ..config import (
CheckCompletude,
CompletudeDossier,
Diagnostic,
DossierMedical,
ItemCompletude,
load_completude_rules,
)
logger = logging.getLogger(__name__)
# Poids par statut pour le scoring pondéré
_STATUT_WEIGHTS: dict[str, float] = {
"present_confirme": 1.0,
"present": 1.0,
"present_indirect": 0.5,
"present_non_confirme": 0.25,
"absent": 0.0,
}
def _normalize(text: str) -> str:
"""Minuscule + suppression accents pour matching souple."""
text = text.lower().strip()
return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
def _collect_doc_types(dossier: DossierMedical) -> set[str]:
"""Types de documents présents dans le dossier (même logique que VETO-29)."""
doc_types = set()
if dossier.document_type:
doc_types.add(dossier.document_type.lower())
for sf in dossier.source_files or []:
sf_up = sf.upper()
if "CRO" in sf_up:
doc_types.add("cro")
if "ANAPATH" in sf_up:
doc_types.add("anapath")
if "CRH" in sf_up:
doc_types.add("crh")
if "TRACKARE" in sf_up:
doc_types.add("trackare")
return doc_types
def _collect_codes(dossier: DossierMedical) -> list[tuple[str, str, str, Optional[Diagnostic]]]:
"""Retourne [(code, libellé, type_diag, diagnostic_obj)] pour DP + DAS actifs."""
codes = []
dp = dossier.diagnostic_principal
if dp:
code = dp.cim10_final or dp.cim10_suggestion
if code:
codes.append((code, dp.texte, "DP", dp))
for das in dossier.diagnostics_associes:
if das.status == "ruled_out":
continue
code = das.cim10_final or das.cim10_suggestion
if code:
codes.append((code, das.texte, "DAS", das))
return codes
def _match_bio(dossier: DossierMedical, match_keys: list[str]) -> tuple[Optional[str], Optional[float]]:
"""Cherche une valeur bio correspondant aux clés fournies.
Retourne (valeur_str, valeur_num) si trouvée.
"""
normalized_keys = [_normalize(k) for k in match_keys]
for bio in dossier.biologie_cle:
bio_norm = _normalize(bio.test)
for key in normalized_keys:
if key in bio_norm or bio_norm in key:
return (bio.valeur or "présent", bio.valeur_num)
return None, None
def _match_imagerie(dossier: DossierMedical, match_keys: list[str]) -> Optional[str]:
"""Cherche une imagerie correspondant aux mots-clés."""
normalized_keys = [_normalize(k) for k in match_keys]
for img in dossier.imagerie:
img_norm = _normalize(img.type)
for key in normalized_keys:
if key in img_norm or img_norm in key:
return img.conclusion or "présent"
return None
def _match_document(doc_types: set[str], match_keys: list[str]) -> bool:
"""Vérifie si un type de document est présent."""
normalized_types = {_normalize(dt) for dt in doc_types}
for key in match_keys:
key_norm = _normalize(key)
if any(key_norm in dt or dt in key_norm for dt in normalized_types):
return True
return False
def _match_clinique(dossier: DossierMedical, field: str) -> tuple[Optional[str], Optional[float]]:
"""Vérifie la présence d'un champ clinique (imc, poids, taille).
Retourne (valeur_str, valeur_num).
"""
val = getattr(dossier.sejour, field, None)
if val is not None:
return str(val), float(val)
return None, None
def _find_rules_for_code(code: str, rules_diag: dict) -> list[dict]:
"""Trouve toutes les règles applicables pour un code CIM-10 donné."""
matched = []
for _family_id, family in rules_diag.items():
prefixes = family.get("prefixes", [])
for prefix in prefixes:
if code.startswith(prefix):
matched.append(family)
break
return matched
def _find_acte_rules(dossier: DossierMedical, rules_actes: dict) -> list[dict]:
"""Trouve les règles CCAM applicables aux actes du dossier."""
matched = []
for _rule_id, rule in rules_actes.items():
prefixes = rule.get("prefixes", [])
for acte in dossier.actes_ccam:
acte_code = acte.code_ccam_suggestion or ""
if any(acte_code.startswith(p) for p in prefixes):
matched.append(rule)
break
return matched
def _evaluate_seuil(
seuil: dict,
valeur_num: Optional[float],
sexe: Optional[str] = None,
) -> tuple[Optional[bool], str]:
"""Évalue si une valeur numérique satisfait un seuil.
Returns:
(confirme, detail_message)
- confirme=True: la valeur confirme le diagnostic
- confirme=False: la valeur ne confirme pas
- confirme=None: pas de valeur numérique à comparer
"""
if valeur_num is None:
return None, ""
seuil_type = seuil.get("type", "")
message_ok = seuil.get("message_ok", "Seuil atteint")
message_ko = seuil.get("message_ko", "Seuil non atteint")
if seuil_type == "below":
# Seuil sex-dépendant ?
if "value_m" in seuil and "value_f" in seuil:
s = (sexe or "").upper()
if s in ("M", "MASCULIN", "HOMME"):
threshold = float(seuil["value_m"])
elif s in ("F", "FEMININ", "FÉMININ", "FEMME"):
threshold = float(seuil["value_f"])
else:
# sexe inconnu → utilise le seuil le plus bas (plus conservateur)
threshold = min(float(seuil["value_m"]), float(seuil["value_f"]))
else:
threshold = float(seuil["value"])
return (valeur_num < threshold, message_ok if valeur_num < threshold else message_ko)
elif seuil_type == "above":
if "value_m" in seuil and "value_f" in seuil:
s = (sexe or "").upper()
if s in ("M", "MASCULIN", "HOMME"):
threshold = float(seuil["value_m"])
elif s in ("F", "FEMININ", "FÉMININ", "FEMME"):
threshold = float(seuil["value_f"])
else:
threshold = max(float(seuil["value_m"]), float(seuil["value_f"]))
else:
threshold = float(seuil["value"])
return (valeur_num > threshold, message_ok if valeur_num > threshold else message_ko)
elif seuil_type == "range":
rmin = float(seuil.get("range_min", 0))
rmax = float(seuil.get("range_max", 999999))
in_range = rmin <= valeur_num <= rmax
return (in_range, message_ok if in_range else message_ko)
elif seuil_type == "outside_range":
rmin = float(seuil.get("range_min", 0))
rmax = float(seuil.get("range_max", 999999))
outside = valeur_num < rmin or valeur_num > rmax
return (outside, message_ok if outside else message_ko)
return None, ""
def _search_preuves_cliniques(
diag: Optional[Diagnostic],
categorie: str,
element: str,
) -> Optional[tuple[str, str]]:
"""Cherche dans les preuves_cliniques du diagnostic une correspondance.
Returns:
(valeur, detail) ou None
"""
if diag is None or not diag.preuves_cliniques:
return None
element_norm = _normalize(element)
# Mots-clés à chercher dans les preuves
element_words = set(element_norm.split())
for preuve in diag.preuves_cliniques:
preuve_type_norm = _normalize(preuve.type)
preuve_elem_norm = _normalize(preuve.element)
# Vérifier la catégorie
cat_match = False
if categorie == "biologie" and preuve_type_norm in ("biologie", "biologique", "bio"):
cat_match = True
elif categorie == "imagerie" and preuve_type_norm in ("imagerie", "radiologie", "radio"):
cat_match = True
elif categorie == "clinique" and preuve_type_norm in ("clinique", "examen"):
cat_match = True
elif categorie == "document" and preuve_type_norm in ("document", "compte-rendu", "rapport"):
cat_match = True
if not cat_match:
# Fallback: chercher les mots-clés de l'élément dans le texte de la preuve
if any(w in preuve_elem_norm for w in element_words if len(w) > 2):
cat_match = True
if cat_match:
# Vérifier que l'élément matche
if any(w in preuve_elem_norm for w in element_words if len(w) > 2):
detail = f"Mentionné dans les preuves cliniques : {preuve.interpretation}"
return preuve.element, detail
return None
def _check_item(
dossier: DossierMedical,
doc_types: set[str],
item_def: dict,
code: str,
diag: Optional[Diagnostic] = None,
) -> ItemCompletude:
"""Évalue un item de la checklist avec confrontation seuil et preuves."""
categorie = item_def["categorie"]
element = item_def["element"]
importance = item_def.get("importance", "recommande")
impact_cpam = item_def.get("impact_cpam", "")
seuil_def = item_def.get("seuil")
# Vérifier le code_filter si présent sur le seuil
seuil_applicable = None
if seuil_def:
code_filter = seuil_def.get("code_filter")
if code_filter is None or code.startswith(code_filter):
seuil_applicable = seuil_def
valeur = None
valeur_num = None
statut = "absent"
confirmation_detail = None
if categorie == "biologie":
val_str, val_num = _match_bio(dossier, item_def.get("match_bio", []))
if val_str:
statut = "present"
valeur = val_str
valeur_num = val_num
elif categorie == "imagerie":
val = _match_imagerie(dossier, item_def.get("match_imagerie", []))
if val:
statut = "present"
valeur = val
elif categorie == "document":
if _match_document(doc_types, item_def.get("match_document", [])):
statut = "present"
elif categorie == "clinique":
field = item_def.get("match_clinique", "")
val_str, val_num = _match_clinique(dossier, field)
if val_str:
statut = "present"
valeur = val_str
valeur_num = val_num
# --- Confrontation seuil (si valeur trouvée et seuil applicable) ---
if statut == "present" and seuil_applicable and valeur_num is not None:
confirme, detail = _evaluate_seuil(
seuil_applicable, valeur_num, dossier.sejour.sexe
)
if confirme is True:
statut = "present_confirme"
confirmation_detail = detail
elif confirme is False:
statut = "present_non_confirme"
confirmation_detail = detail
# confirme=None → reste "present"
# --- Croisement preuves_cliniques (si absent) ---
if statut == "absent" and diag is not None:
result = _search_preuves_cliniques(diag, categorie, element)
if result:
preuve_val, preuve_detail = result
statut = "present_indirect"
valeur = preuve_val
confirmation_detail = preuve_detail
return ItemCompletude(
categorie=categorie,
element=element,
statut=statut,
valeur=valeur,
importance=importance,
impact_cpam=impact_cpam,
confirmation_detail=confirmation_detail,
)
def _compute_check_score(items: list[ItemCompletude]) -> tuple[int, str, str]:
"""Calcule score, verdict et résumé pour un check donné.
Scoring pondéré par statut :
- present_confirme / present (sans seuil) → 1.0
- present_indirect → 0.5
- present_non_confirme → 0.25
- absent → 0.0
"""
obligatoires = [i for i in items if i.importance == "obligatoire"]
recommandes = [i for i in items if i.importance == "recommande"]
def _weight(item: ItemCompletude) -> float:
return _STATUT_WEIGHTS.get(item.statut, 0.0)
oblig_score = sum(_weight(i) for i in obligatoires)
oblig_total = len(obligatoires)
reco_score = sum(_weight(i) for i in recommandes)
reco_total = len(recommandes)
if oblig_total == 0:
if reco_total == 0:
return 100, "defendable", "Aucun élément requis"
pct = reco_score / reco_total
score = int(70 + 30 * pct)
verdict = "defendable" if pct >= 0.5 else "fragile"
resume = _build_resume(items)
return score, verdict, resume
pct_oblig = oblig_score / oblig_total
pct_reco = reco_score / reco_total if reco_total > 0 else 1.0
# Score : 70% basé sur obligatoires, 30% sur recommandés
score = int(70 * pct_oblig + 30 * pct_reco)
# Verdict
oblig_presents = sum(1 for i in obligatoires if i.statut != "absent")
if oblig_presents == 0:
verdict = "indefendable"
elif oblig_presents < oblig_total:
verdict = "fragile"
elif any(i.statut == "present_non_confirme" for i in obligatoires):
verdict = "fragile"
else:
verdict = "defendable"
resume = _build_resume(items)
return score, verdict, resume
def _build_resume(items: list[ItemCompletude]) -> str:
"""Construit le résumé texte du check."""
obligatoires = [i for i in items if i.importance == "obligatoire"]
recommandes = [i for i in items if i.importance == "recommande"]
oblig_ok = sum(1 for i in obligatoires if i.statut not in ("absent",))
oblig_confirmed = sum(1 for i in obligatoires if i.statut == "present_confirme")
reco_ok = sum(1 for i in recommandes if i.statut not in ("absent",))
parts = []
if obligatoires:
txt = f"{oblig_ok}/{len(obligatoires)} obligatoires"
if oblig_confirmed:
txt += f" ({oblig_confirmed} confirmé{'s' if oblig_confirmed > 1 else ''})"
parts.append(txt)
if recommandes:
parts.append(f"{reco_ok}/{len(recommandes)} recommandés")
return ", ".join(parts) if parts else "Aucun élément requis"
def build_completude_checklist(dossier: DossierMedical) -> CompletudeDossier:
"""Construit la checklist de complétude documentaire pour un dossier.
Pour chaque code (DP + DAS), cherche les règles applicables,
vérifie la présence de chaque élément requis, confronte les valeurs
aux seuils diagnostiques, et calcule les scores pondérés.
"""
try:
rules = load_completude_rules()
except Exception:
logger.warning("Complétude : impossible de charger les règles", exc_info=True)
return CompletudeDossier()
rules_diag = rules.get("diagnostics", {})
rules_actes = rules.get("actes", {})
doc_types = _collect_doc_types(dossier)
codes = _collect_codes(dossier)
checks: list[CheckCompletude] = []
# 1. Vérification par code diagnostique
for code, libelle, type_diag, diag_obj in codes:
families = _find_rules_for_code(code, rules_diag)
if not families:
continue
all_items: list[ItemCompletude] = []
seen_elements: set[str] = set()
for family in families:
for item_def in family.get("items", []):
# Filtrer par code_filter (seuils spécifiques E43 vs E44)
seuil = item_def.get("seuil")
if seuil and seuil.get("code_filter"):
if not code.startswith(seuil["code_filter"]):
continue
elem_key = f"{item_def['categorie']}:{item_def['element']}"
if elem_key in seen_elements:
continue
seen_elements.add(elem_key)
item = _check_item(dossier, doc_types, item_def, code, diag_obj)
all_items.append(item)
if not all_items:
continue
score, verdict, resume = _compute_check_score(all_items)
checks.append(CheckCompletude(
code=code,
libelle=libelle,
type_diag=type_diag,
items=all_items,
score=score,
verdict=verdict,
resume=resume,
))
# 2. Vérification des actes CCAM (CRO, etc.)
acte_families = _find_acte_rules(dossier, rules_actes)
for family in acte_families:
desc = family.get("description", "Acte")
all_items = []
for item_def in family.get("items", []):
item = _check_item(dossier, doc_types, item_def, "")
if not any(existing.element == item.element for existing in all_items):
all_items.append(item)
if all_items:
score, verdict, resume = _compute_check_score(all_items)
# Un seul check pour l'ensemble des actes chirurgicaux
acte_codes = [a.code_ccam_suggestion or "?" for a in dossier.actes_ccam
if a.code_ccam_suggestion and any(a.code_ccam_suggestion.startswith(p) for p in family.get("prefixes", []))]
code_label = ", ".join(acte_codes[:3]) or "CCAM"
if not any(c.code == code_label for c in checks):
checks.append(CheckCompletude(
code=code_label,
libelle=desc,
type_diag="Acte",
items=all_items,
score=score,
verdict=verdict,
resume=resume,
))
# 3. Score global
if not checks:
return CompletudeDossier(
documents_presents=sorted(doc_types),
)
scores = [c.score for c in checks]
score_global = sum(scores) // len(scores)
verdicts = [c.verdict for c in checks]
if "indefendable" in verdicts:
verdict_global = "indefendable"
elif "fragile" in verdicts:
verdict_global = "fragile"
else:
verdict_global = "defendable"
# Documents manquants
docs_manquants = set()
for check in checks:
for item in check.items:
if item.categorie == "document" and item.statut == "absent" and item.importance == "obligatoire":
docs_manquants.add(item.element)
return CompletudeDossier(
checks=checks,
score_global=score_global,
verdict_global=verdict_global,
documents_presents=sorted(doc_types),
documents_manquants=sorted(docs_manquants),
)

View File

@@ -252,17 +252,17 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
# Trackare = codage établissement, source d'autorité : pas de VETO-02
logger.debug("VETO-02 skip: DP %s issu de Trackare (source d'autorité)", dp.cim10_suggestion)
elif not _has_evidence(dp):
add("VETO-02", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} sans preuve exploitable")
add("VETO-02", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} sans preuve exploitable", citation="Principe de preuve : tout diagnostic/acte doit être étayé par une trace dans le dossier médical (Guide Méthodologique MCO)")
for i, das in enumerate(dossier.diagnostics_associes):
if _is_ruled_out(das):
continue
if das.cim10_suggestion and not _has_evidence(das):
add("VETO-02", "MEDIUM", f"diagnostics_associes[{i}]", f"DAS {das.cim10_suggestion} sans preuve exploitable")
add("VETO-02", "MEDIUM", f"diagnostics_associes[{i}]", f"DAS {das.cim10_suggestion} sans preuve exploitable", citation="Principe de preuve : tout diagnostic/acte doit être étayé par une trace dans le dossier médical (Guide Méthodologique MCO)")
for i, acte in enumerate(dossier.actes_ccam):
if acte.code_ccam_suggestion and not _has_evidence(acte):
add("VETO-02", "HARD", f"actes_ccam[{i}]", f"Acte {acte.code_ccam_suggestion} sans preuve exploitable")
add("VETO-02", "HARD", f"actes_ccam[{i}]", f"Acte {acte.code_ccam_suggestion} sans preuve exploitable", citation="Principe de preuve : tout diagnostic/acte doit être étayé par une trace dans le dossier médical (Guide Méthodologique MCO)")
# -------------------------------------------------
# VETO-03 : négation / conditionnel DANS LES PREUVES
@@ -272,11 +272,11 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
excerpts = _evidence_excerpts(dp)
neg, cond, contra, pos = _analyze_neg_cond(excerpts, dp.texte or dp.cim10_suggestion)
if neg and not pos:
add("VETO-03", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} contredit par la preuve (négation)")
add("VETO-03", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} contredit par la preuve (négation)", citation="Guide Méthodologique MCO : Un diagnostic ne peut être retenu si le compte-rendu le contredit explicitement")
elif contra:
add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} preuves contradictoires (positif vs négatif)")
add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} preuves contradictoires (positif vs négatif)", citation="Guide Méthodologique MCO : En cas de preuves contradictoires, le diagnostic doit être confirmé par le médecin")
elif cond and dp.cim10_confidence == "high":
add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} basé sur du conditionnel")
add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} basé sur du conditionnel", citation="Guide Méthodologique MCO : Un diagnostic conditionnel (suspecté, à éliminer) ne doit pas être codé comme confirmé")
for i, das in enumerate(dossier.diagnostics_associes):
if _is_ruled_out(das):
@@ -289,11 +289,11 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
if neg and not pos:
# En contrôle CPAM : une négation explicite = bloquant, surtout si le modèle est « high ».
severity = "HARD" if das.cim10_confidence == "high" else "MEDIUM"
add("VETO-03", severity, where, f"DAS {das.cim10_suggestion} contredit par la preuve (négation)")
add("VETO-03", severity, where, f"DAS {das.cim10_suggestion} contredit par la preuve (négation)", citation="Guide Méthodologique MCO : Un diagnostic ne peut être retenu si le compte-rendu le contredit explicitement")
elif contra:
add("VETO-03", "MEDIUM", where, f"DAS {das.cim10_suggestion} preuves contradictoires")
add("VETO-03", "MEDIUM", where, f"DAS {das.cim10_suggestion} preuves contradictoires", citation="Guide Méthodologique MCO : En cas de preuves contradictoires, le diagnostic doit être confirmé par le médecin")
elif cond and das.cim10_confidence == "high":
add("VETO-03", "LOW", where, f"DAS {das.cim10_suggestion} potentiellement conditionnel")
add("VETO-03", "LOW", where, f"DAS {das.cim10_suggestion} potentiellement conditionnel", citation="Guide Méthodologique MCO : Un diagnostic conditionnel (suspecté, à éliminer) ne doit pas être codé comme confirmé")
# -------------------------------------------------
# VETO-15 : preuve de type "score/test" (risque élevé de sur-codage)

View File

@@ -1,4 +1,4 @@
"""Point d'entrée : python -m src.viewer [--host 127.0.0.1] [--port 5000] [--debug]."""
"""Point d'entrée : python -m src.viewer [--host 127.0.0.1] [--port 7500] [--debug]."""
import argparse
@@ -8,7 +8,7 @@ from .app import create_app
def main():
parser = argparse.ArgumentParser(description="Viewer CIM-10 T2A")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=5000)
parser.add_argument("--port", type=int, default=7500)
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()

View File

@@ -4,11 +4,14 @@ from __future__ import annotations
import json
import logging
import os
import re
import time
from pathlib import Path
import requests
from flask import Flask, Response, abort, render_template, request, jsonify
from flask_httpauth import HTTPBasicAuth
from markupsafe import Markup
from werkzeug.utils import secure_filename
@@ -23,6 +26,7 @@ from ..config import (
)
from .. import config as cfg
from ..control.cpam_context import _assess_dossier_strength
from ..medical.bio_normals import BIO_NORMALS
from .referentiels import ReferentielManager
from .validation import ValidationManager
@@ -143,32 +147,239 @@ def compute_dashboard_stats(groups: dict[str, list[dict]]) -> dict:
}
def compute_dim_synthesis(groups: dict[str, list[dict]]) -> dict:
"""Calcule les indicateurs de synthèse pour la vue médecin DIM."""
# --- DP Arbitrage ---
dp_total = 0
dp_confirmed = 0
dp_review = 0
dp_modified = 0 # finalizer a changé le DP
dp_conf_dist: Counter = Counter() # high/medium/low
dp_source_dist: Counter = Counter() # trackare/crh/override
# --- DAS Qualité ---
das_total = 0
das_kept = 0
das_downgraded = 0
das_removed = 0
das_ruled_out = 0
das_cma = 0
das_no_code = 0
# --- Contestabilité (Veto) ---
veto_dist: Counter = Counter() # PASS/NEED_INFO/FAIL
veto_scores: list[int] = []
top_vetos: Counter = Counter()
# --- Complétude ---
completude_dist: Counter = Counter() # defendable/fragile/indefendable
completude_scores: list[int] = []
# --- CPAM ---
cpam_total = 0
cpam_impact_total = 0
cpam_by_priority: Counter = Counter()
cpam_by_status: Counter = Counter()
# --- Alertes prioritaires ---
dossiers_review: list[dict] = []
dossiers_fail: list[dict] = []
dossiers_indefendable: list[dict] = []
for group_name, items in groups.items():
for item in items:
d = item["dossier"]
dname = format_dossier_name(group_name)
dpath = item["path_rel"]
# DP
dp_final = d.dp_final
dp_track = d.dp_trackare
if dp_final:
dp_total += 1
dp_conf_dist[dp_final.confidence or "none"] += 1
if dp_final.verdict == "CONFIRMED":
dp_confirmed += 1
else:
dp_review += 1
dossiers_review.append({"name": dname, "path": dpath,
"reason": dp_final.reason or "DP à valider",
"code": dp_final.chosen_code or "?"})
# Modification DP
if dp_track and dp_final.chosen_code and dp_track.chosen_code:
if dp_final.chosen_code != dp_track.chosen_code:
dp_modified += 1
# Source
flags = d.quality_flags or {}
if flags.get("trackare_only_mode"):
dp_source_dist["trackare"] += 1
elif flags.get("crh_only_mode"):
dp_source_dist["crh"] += 1
elif flags.get("override_trackare_by_crh_confirmed") or flags.get("trackare_symptom_overridden"):
dp_source_dist["override_crh"] += 1
elif flags.get("trackare_confirmed_by_crh"):
dp_source_dist["confirmé"] += 1
else:
dp_source_dist["autre"] += 1
elif d.diagnostic_principal:
dp_total += 1
dp_conf_dist[d.diagnostic_principal.cim10_confidence or "none"] += 1
# DAS
for das in d.diagnostics_associes:
das_total += 1
dec = das.cim10_decision
if dec:
action = dec.action
if action == "KEEP":
das_kept += 1
elif action == "DOWNGRADE":
das_downgraded += 1
elif action == "REMOVE":
das_removed += 1
elif action == "RULED_OUT":
das_ruled_out += 1
else:
das_kept += 1
else:
das_kept += 1
if das.est_cma:
das_cma += 1
if not das.cim10_final and not das.cim10_suggestion:
das_no_code += 1
# Veto
vr = d.veto_report
if vr:
veto_dist[vr.verdict] += 1
veto_scores.append(vr.score_contestabilite)
for issue in (vr.issues or []):
top_vetos[issue.veto] += 1
if vr.verdict == "FAIL":
dossiers_fail.append({"name": dname, "path": dpath,
"score": vr.score_contestabilite,
"issues": len(vr.issues or [])})
# Complétude
comp = d.completude
if comp:
completude_dist[comp.verdict_global] += 1
completude_scores.append(comp.score_global)
if comp.verdict_global == "indefendable":
dossiers_indefendable.append({"name": dname, "path": dpath,
"score": comp.score_global,
"manquants": len(comp.documents_manquants or [])})
# CPAM
for ctrl in d.controles_cpam:
cpam_total += 1
fi = ctrl.financial_impact
if fi:
cpam_impact_total += fi.impact_estime_euros or 0
cpam_by_priority[fi.priorite or "normale"] += 1
cpam_by_status[ctrl.validation_dim or "non_valide"] += 1
avg_veto = round(sum(veto_scores) / len(veto_scores)) if veto_scores else 0
avg_completude = round(sum(completude_scores) / len(completude_scores)) if completude_scores else 0
return {
"dp": {
"total": dp_total,
"confirmed": dp_confirmed,
"review": dp_review,
"modified": dp_modified,
"confidence": dict(dp_conf_dist),
"source": dict(dp_source_dist),
},
"das": {
"total": das_total,
"kept": das_kept,
"downgraded": das_downgraded,
"removed": das_removed,
"ruled_out": das_ruled_out,
"cma": das_cma,
"no_code": das_no_code,
"taux_modification": round((das_downgraded + das_removed + das_ruled_out) / das_total * 100, 1) if das_total else 0,
},
"veto": {
"distribution": dict(veto_dist),
"avg_score": avg_veto,
"top_issues": top_vetos.most_common(10),
},
"completude": {
"distribution": dict(completude_dist),
"avg_score": avg_completude,
},
"cpam": {
"total": cpam_total,
"impact_total": cpam_impact_total,
"by_priority": dict(cpam_by_priority),
"by_status": dict(cpam_by_status),
},
"alertes": {
"review": dossiers_review[:20],
"fail": dossiers_fail[:20],
"indefendable": dossiers_indefendable[:20],
},
}
def _compute_jours_restants(ctrl) -> int | None:
"""Calcule les jours restants avant la date limite de réponse."""
if not ctrl.date_limite_reponse:
return None
from datetime import datetime
try:
limite = datetime.strptime(ctrl.date_limite_reponse, "%d/%m/%Y")
return (limite - datetime.now()).days
except (ValueError, TypeError):
return None
def collect_cpam_controls(groups: dict[str, list[dict]]) -> list[dict]:
"""Collecte tous les contrôles CPAM de tous les dossiers."""
"""Collecte tous les contrôles CPAM de tous les dossiers, avec impact financier."""
from ..medical.ghm import estimate_financial_impact
_PRIORITE_ORDER = {"critique": 0, "haute": 1, "normale": 2, "faible": 3}
controls = []
for group_name, items in groups.items():
for item in items:
d = item["dossier"]
dp_code = d.diagnostic_principal.cim10_suggestion if d.diagnostic_principal else None
for ctrl in d.controles_cpam:
# Calculer l'impact financier si absent
if ctrl.financial_impact is None and d.ghm_estimation:
ctrl.financial_impact = estimate_financial_impact(d.ghm_estimation)
controls.append({
"group_name": group_name,
"filepath": item["path_rel"],
"ctrl": ctrl,
"dp_code": dp_code,
"jours_restants": _compute_jours_restants(ctrl),
})
controls.sort(key=lambda c: c["ctrl"].numero_ogc)
# Tri : 1) priorité financière, 2) désaccords (confirme) avant accords (retient), 3) OGC
controls.sort(key=lambda c: (
_PRIORITE_ORDER.get(
c["ctrl"].financial_impact.priorite if c["ctrl"].financial_impact else "normale",
2,
),
0 if "confirme" in (c["ctrl"].decision_ucr or "").lower() else 1,
c["ctrl"].numero_ogc,
))
return controls
def get_builtin_referentiels() -> list[dict]:
"""Retourne les infos sur les référentiels intégrés (PDFs + dicts)."""
rag_index_meta = Path(STRUCTURED_DIR).parent / "data" / "rag_index" / "metadata.json"
from ..config import BASE_DIR
rag_index_dir = BASE_DIR / "data" / "rag_index"
# Charger les chunks depuis TOUS les metadata (ref, proc, bio, legacy)
chunks_by_doc: dict[str, int] = {}
if rag_index_meta.exists():
for meta_file in rag_index_dir.glob("metadata*.json"):
try:
import json as _json
meta = _json.loads(rag_index_meta.read_text(encoding="utf-8"))
meta = json.loads(meta_file.read_text(encoding="utf-8"))
for m in meta:
doc = m.get("document", "")
chunks_by_doc[doc] = chunks_by_doc.get(doc, 0) + 1
@@ -176,16 +387,27 @@ def get_builtin_referentiels() -> list[dict]:
pass
refs = []
# (nom, path, ext, doc_keys pour compter les chunks, edition, validité)
builtin_sources = [
("CIM-10 FR 2026", CIM10_PDF, ".pdf", ["cim10", "cim10_alpha"]),
("Guide Méthodologique MCO 2026", GUIDE_METHODO_PDF, ".pdf", ["guide_methodo"]),
("CCAM 2025", CCAM_PDF, ".pdf", ["ccam"]),
("Dictionnaire CIM-10", CIM10_DICT_PATH, ".json", []),
("Suppléments CIM-10", CIM10_SUPPLEMENTS_PATH, ".json", []),
("Dictionnaire CCAM", CCAM_DICT_PATH, ".json", []),
("CIM-10 FR 2026", CIM10_PDF, ".pdf", ["cim10", "cim10_alpha"],
"11/12/2025", "2026 (provisoire)"),
("Guide Méthodologique MCO 2026", GUIDE_METHODO_PDF, ".pdf", ["guide_methodo"],
"2025", "2026 (provisoire)"),
("CCAM descriptive PMSI V4", CCAM_PDF, ".pdf", ["ccam"],
"2025", "V4 2025"),
("Dictionnaire CIM-10", CIM10_DICT_PATH, ".json", [],
"", ""),
("Suppléments CIM-10", CIM10_SUPPLEMENTS_PATH, ".json", [],
"", ""),
("Dictionnaire CCAM", CCAM_DICT_PATH, ".json", [],
"", ""),
]
for name, path, ext, doc_keys in builtin_sources:
for name, path, ext, doc_keys, edition, validite in builtin_sources:
size_mb = path.stat().st_size / (1024 * 1024) if path.exists() else 0
mtime = ""
if path.exists():
import datetime as _dt
mtime = _dt.datetime.fromtimestamp(path.stat().st_mtime).strftime("%d/%m/%Y")
chunks = sum(chunks_by_doc.get(k, 0) for k in doc_keys)
refs.append({
"name": name,
@@ -194,10 +416,73 @@ def get_builtin_referentiels() -> list[dict]:
"size_mb": size_mb,
"chunks": chunks,
"exists": path.exists(),
"edition": edition,
"validite": validite,
"file_date": mtime,
})
# Référentiels supplémentaires indexés (ref:*.pdf dans les metadata)
from ..config import REFERENTIELS_DIR
pdfs_dir = REFERENTIELS_DIR / "pdfs"
for doc_name, count in sorted(chunks_by_doc.items()):
if doc_name.startswith("ref:") or doc_name.startswith("proc:"):
prefix, fname = doc_name.split(":", 1)
pdf_path = pdfs_dir / fname
size_mb = pdf_path.stat().st_size / (1024 * 1024) if pdf_path.exists() else 0
mtime = ""
if pdf_path.exists():
import datetime as _dt
mtime = _dt.datetime.fromtimestamp(pdf_path.stat().st_mtime).strftime("%d/%m/%Y")
refs.append({
"name": fname.replace("_", " ").replace(".pdf", ""),
"filename": fname,
"extension": ".pdf",
"size_mb": size_mb,
"chunks": count,
"exists": pdf_path.exists(),
"edition": "",
"validite": "",
"file_date": mtime,
"category": prefix,
})
return refs
def get_faiss_index_info() -> dict:
"""Retourne les informations détaillées sur les index FAISS."""
from ..config import BASE_DIR
from ..medical.rag_index import check_faiss_ready
rag_dir = BASE_DIR / "data" / "rag_index"
info = {"ok": False, "indexes": [], "total_vectors": 0, "last_build": ""}
status = check_faiss_ready()
info["ok"] = status["ok"]
info["total_vectors"] = status["ref"] + status["proc"] + status["bio"] + status["legacy"]
for kind, label in [("ref", "Référentiels CIM-10"), ("proc", "Procédures/Guides"),
("bio", "Biologie"), ("all", "Legacy (combiné)")]:
idx_file = rag_dir / f"faiss_{kind}.index" if kind != "all" else rag_dir / "faiss.index"
meta_file = rag_dir / f"metadata_{kind}.json" if kind != "all" else rag_dir / "metadata.json"
count = status.get(kind, status.get("legacy", 0)) if kind == "all" else status.get(kind, 0)
mtime = ""
size_mb = 0
if idx_file.exists():
import datetime as _dt
mtime = _dt.datetime.fromtimestamp(idx_file.stat().st_mtime).strftime("%d/%m/%Y %H:%M")
size_mb = idx_file.stat().st_size / (1024 * 1024)
info["indexes"].append({
"kind": kind, "label": label,
"vectors": count, "size_mb": round(size_mb, 1),
"last_build": mtime, "exists": idx_file.exists(),
})
if mtime and (not info["last_build"] or mtime > info["last_build"]):
info["last_build"] = mtime
return info
def load_ccam_dict() -> dict[str, dict]:
"""Charge le dictionnaire CCAM pour les regroupements."""
if CCAM_DICT_PATH.exists():
@@ -209,13 +494,23 @@ def load_ccam_dict() -> dict[str, dict]:
return {}
_scan_cache: dict[str, object] = {"data": None, "ts": 0.0}
_SCAN_TTL = 30 # secondes
def scan_dossiers() -> dict[str, list[dict]]:
"""Scanne output/structured/ et retourne les fichiers groupés par sous-dossier.
Résultat mis en cache pendant 30s pour éviter de re-scanner le FS à chaque requête.
Returns:
{"racine": [{name, path_rel, dossier}, ...], "sous-dossier": [...]}
Chaque groupe contient aussi une clé "stats" avec les compteurs agrégés.
"""
now = time.monotonic()
if _scan_cache["data"] is not None and (now - _scan_cache["ts"]) < _SCAN_TTL:
return _scan_cache["data"]
groups: dict[str, list[dict]] = {}
for json_path in sorted(STRUCTURED_DIR.rglob("*.json")):
@@ -240,6 +535,8 @@ def scan_dossiers() -> dict[str, list[dict]]:
"dossier": dossier,
})
_scan_cache["data"] = groups
_scan_cache["ts"] = now
return groups
@@ -349,7 +646,7 @@ def cma_level_badge(value: int | None) -> Markup:
title = {1: "Pas CMA", 2: "CMA niveau 2", 3: "CMA niveau 3", 4: "CMA niveau 4"}.get(level, "")
return Markup(
f'<span title="{title}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">'
f'font-size:0.75rem;font-weight:600;white-space:nowrap;color:{fg};background:{bg}">'
f'CMA {label}</span>'
)
@@ -452,9 +749,152 @@ def human_where(value: str | None) -> str:
return value
def _date_to_iso(date_fr: str) -> str:
"""Convertit JJ/MM/AAAA → YYYY-MM-DD pour les inputs HTML date."""
try:
parts = date_fr.strip().split("/")
if len(parts) == 3:
return f"{parts[2]}-{parts[1]}-{parts[0]}"
except Exception:
pass
return ""
_status_cache: dict[str, object] = {"data": None, "ts": 0.0}
_STATUS_TTL = 120 # secondes
def _get_system_status() -> list[dict]:
"""Détecte l'état des composants du pipeline T2A (cache 120s)."""
now = time.monotonic()
if _status_cache["data"] is not None and (now - _status_cache["ts"]) < _STATUS_TTL:
return _status_cache["data"]
from ..config import OLLAMA_URL, OLLAMA_MODELS
components = []
# 1. Moteur de règles (VetoEngine)
components.append({"name": "Moteur de règles (VetoEngine)", "status": True, "detail": "Actif"})
# 2. LLM Ollama
ollama_ok = False
ollama_detail = "Non disponible"
try:
r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=3)
if r.status_code == 200:
ollama_ok = True
models_info = ", ".join(f"{role}={model}" for role, model in OLLAMA_MODELS.items())
ollama_detail = models_info
except Exception:
pass
components.append({"name": "LLM Ollama", "status": ollama_ok, "detail": ollama_detail})
# 3. Fallback Anthropic
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
components.append({
"name": "Fallback Anthropic (Haiku)",
"status": bool(api_key),
"detail": "Clé configurée" if api_key else "Clé absente",
})
# 4. Index FAISS (RAG)
try:
from ..medical.rag_index import check_faiss_ready
faiss_check = check_faiss_ready()
if faiss_check["ok"]:
total = faiss_check["ref"] + faiss_check["proc"] + faiss_check["bio"] + faiss_check["legacy"]
parts = []
if faiss_check["ref"]:
parts.append(f"ref={faiss_check['ref']}")
if faiss_check["proc"]:
parts.append(f"proc={faiss_check['proc']}")
if faiss_check["bio"]:
parts.append(f"bio={faiss_check['bio']}")
detail = f"{total} vecteurs ({', '.join(parts)})"
else:
detail = "; ".join(faiss_check["errors"][:2])
components.append({
"name": "Index FAISS (RAG)",
"status": faiss_check["ok"],
"detail": detail,
})
except Exception as e:
components.append({
"name": "Index FAISS (RAG)",
"status": False,
"detail": f"Erreur vérification : {e}",
})
# 5. Extraction PDF
components.append({"name": "Extraction PDF (pdfplumber)", "status": True, "detail": "Actif"})
# 6. Anonymisation NER
ner_ok = False
try:
from transformers import AutoTokenizer
AutoTokenizer.from_pretrained("Jean-Baptiste/camembert-ner", local_files_only=True)
ner_ok = True
except Exception:
pass
components.append({
"name": "Anonymisation NER (CamemBERT)",
"status": ner_ok,
"detail": "Modèle en cache" if ner_ok else "Modèle non trouvé",
})
# 7. Embeddings — vérifier le cache HuggingFace sans charger le modèle
emb_ok = False
try:
from huggingface_hub import try_to_load_from_cache
result = try_to_load_from_cache("dangvantuan/sentence-camembert-large", "config.json")
emb_ok = result is not None and isinstance(result, str)
except Exception:
pass
components.append({
"name": "Embeddings (sentence-camembert-large)",
"status": emb_ok,
"detail": "Modèle en cache" if emb_ok else "Modèle non trouvé",
})
_status_cache["data"] = components
_status_cache["ts"] = now
return components
def _sort_qc_alerts(alerts: list[str]) -> list[str]:
"""Trie les alertes QC : DP d'abord, puis critiques, puis le reste."""
def _key(a: str) -> tuple[int, int]:
text = a.lower()
# DP en premier
dp = 0 if " dp " in text or text.startswith("dp ") or "diagnostic principal" in text else 1
# Critiques ensuite
critical = 0 if any(k in text for k in ("high→low", "high → low", "à reconsidérer", "reconsider")) else 1
return (dp, critical)
return sorted(alerts, key=_key)
def create_app() -> Flask:
app = Flask(__name__)
# --- Authentification HTTP Basic (optionnelle, activée via env) ---
auth = HTTPBasicAuth()
demo_user = os.environ.get("T2A_DEMO_USER", "")
demo_pass = os.environ.get("T2A_DEMO_PASS", "")
@auth.verify_password
def verify_password(username, password):
if not demo_user:
return True # Auth désactivée si pas de user configuré
if username == demo_user and password == demo_pass:
return True
return False
@app.before_request
def require_auth():
if demo_user:
return auth.login_required(lambda: None)()
app.jinja_env.filters["confidence_badge"] = confidence_badge
app.jinja_env.filters["confidence_label"] = confidence_label
app.jinja_env.filters["severity_badge"] = severity_badge
@@ -465,14 +905,46 @@ def create_app() -> Flask:
app.jinja_env.filters["format_cpam_text"] = format_cpam_text
app.jinja_env.filters["decision_badge"] = decision_badge
app.jinja_env.filters["human_where"] = human_where
app.jinja_env.filters["date_to_iso"] = _date_to_iso
app.jinja_env.filters["sort_qc_alerts"] = _sort_qc_alerts
ccam_dict = load_ccam_dict()
# Vérification FAISS au démarrage du viewer
try:
from ..medical.rag_index import check_faiss_ready
_faiss_status = check_faiss_ready()
if _faiss_status["ok"]:
total = _faiss_status["ref"] + _faiss_status["proc"] + _faiss_status["bio"] + _faiss_status["legacy"]
logger.info("FAISS OK : %d vecteurs chargés", total)
else:
for err in _faiss_status["errors"]:
logger.error("FAISS : %s", err)
except Exception as e:
logger.error("Vérification FAISS échouée : %s", e)
ref_manager = ReferentielManager()
@app.context_processor
def inject_dossier_list():
"""Injecte la liste des dossiers pour l'autocomplétion sidebar."""
groups = scan_dossiers()
dossier_list = []
for group_name, items in groups.items():
rep = items[0]
for item in items:
if "fusionne" in item["name"]:
rep = item
break
dossier_list.append({"name": format_dossier_name(group_name), "path": rep["path_rel"]})
return {"dossier_list": dossier_list}
@app.route("/")
def index():
groups = scan_dossiers()
group_stats = {name: compute_group_stats(items) for name, items in groups.items()}
return render_template("index.html", groups=groups, group_stats=group_stats)
stats = compute_dashboard_stats(groups) if groups else {}
return render_template("index.html", groups=groups, group_stats=group_stats, stats=stats)
@app.route("/dossier/<path:filepath>")
def detail(filepath: str):
@@ -495,13 +967,30 @@ def create_app() -> Flask:
siblings=siblings,
current_group=current_group,
dossier_strength=dossier_strength,
groups=groups,
bio_normals=BIO_NORMALS,
)
@app.route("/dashboard")
def dashboard():
groups = scan_dossiers()
stats = compute_dashboard_stats(groups)
return render_template("dashboard.html", stats=stats, groups=groups)
system_status = _get_system_status()
all_refs = get_builtin_referentiels()
core_refs = [r for r in all_refs if "category" not in r]
ref_refs = [r for r in all_refs if r.get("category") == "ref"]
proc_refs = [r for r in all_refs if r.get("category") == "proc"]
faiss_info = get_faiss_index_info()
return render_template("dashboard.html", stats=stats, groups=groups,
system_status=system_status,
core_refs=core_refs, ref_refs=ref_refs, proc_refs=proc_refs,
total_refs=len(all_refs), faiss_info=faiss_info)
@app.route("/dim")
def dim_synthesis():
groups = scan_dossiers()
dim = compute_dim_synthesis(groups)
return render_template("dim.html", dim=dim)
@app.route("/cpam")
def cpam_list():
@@ -509,6 +998,116 @@ def create_app() -> Flask:
controls = collect_cpam_controls(groups)
return render_template("cpam.html", controls=controls, total=len(controls), groups=groups)
@app.route("/api/cpam/<path:dossier_id>/<int:ogc>/versions")
def cpam_versions(dossier_id: str, ogc: int):
"""Retourne la liste des versions précédentes d'un argumentaire."""
# dossier_id est le path relatif du JSON ; extraire le répertoire parent
parts = Path(dossier_id).parts
if len(parts) > 1:
subdir = str(Path(*parts[:-1]))
else:
return jsonify({"versions": []})
versions_dir = STRUCTURED_DIR / subdir / "_cpam_versions"
if not versions_dir.is_dir():
return jsonify({"versions": []})
versions = []
for f in sorted(versions_dir.glob(f"{ogc}_*.json"), reverse=True):
try:
data = json.loads(f.read_text(encoding="utf-8"))
versions.append({
"filename": f.name,
"version": data.get("version", 0),
"timestamp": data.get("timestamp", ""),
"quality_tier": data.get("quality_tier"),
"validation_dim": data.get("validation_dim"),
"contre_argumentation": data.get("contre_argumentation", "")[:200],
})
except Exception:
pass
return jsonify({"versions": versions})
@app.route("/api/cpam/<path:dossier_id>/<int:ogc>/deadline", methods=["POST"])
def cpam_deadline(dossier_id: str, ogc: int):
"""Saisie manuelle de la date de notification pour un contrôle."""
from datetime import datetime as dt, timedelta
data = request.get_json(silent=True) or {}
date_notif = data.get("date_notification", "").strip()
if not date_notif:
return jsonify({"error": "date_notification requis (JJ/MM/AAAA)"}), 400
safe_path = (STRUCTURED_DIR / dossier_id).resolve()
if not safe_path.is_relative_to(STRUCTURED_DIR.resolve()):
abort(403)
if not safe_path.exists():
abort(404)
dossier_data = json.loads(safe_path.read_text(encoding="utf-8"))
dossier = DossierMedical.model_validate(dossier_data)
found = False
for ctrl in dossier.controles_cpam:
if ctrl.numero_ogc == ogc:
ctrl.date_notification = date_notif
try:
notif_dt = dt.strptime(date_notif, "%d/%m/%Y")
ctrl.date_limite_reponse = (notif_dt + timedelta(days=30)).strftime("%d/%m/%Y")
except ValueError:
ctrl.date_limite_reponse = None
found = True
break
if not found:
return jsonify({"error": f"OGC {ogc} non trouvé"}), 404
safe_path.write_text(
dossier.model_dump_json(indent=2, exclude_none=True),
encoding="utf-8",
)
return jsonify({"ok": True, "date_limite": ctrl.date_limite_reponse})
@app.route("/api/cpam/<path:dossier_id>/<int:ogc>/validate", methods=["POST"])
def cpam_validate(dossier_id: str, ogc: int):
"""Valide ou rejette un argumentaire CPAM (workflow DIM)."""
from datetime import datetime
data = request.get_json(silent=True) or {}
statut = data.get("statut", "")
if statut not in ("valide", "rejete", "en_revision", "non_valide"):
return jsonify({"error": "Statut invalide"}), 400
# Charger le JSON du dossier
safe_path = (STRUCTURED_DIR / dossier_id).resolve()
if not safe_path.is_relative_to(STRUCTURED_DIR.resolve()):
abort(403)
if not safe_path.exists():
abort(404)
dossier_data = json.loads(safe_path.read_text(encoding="utf-8"))
dossier = DossierMedical.model_validate(dossier_data)
# Trouver le contrôle par OGC
found = False
for ctrl in dossier.controles_cpam:
if ctrl.numero_ogc == ogc:
ctrl.validation_dim = statut
ctrl.commentaire_dim = data.get("commentaire") or None
ctrl.date_validation = datetime.now().strftime("%d/%m/%Y %H:%M")
found = True
break
if not found:
return jsonify({"error": f"OGC {ogc} non trouvé"}), 404
# Sauvegarder
safe_path.write_text(
dossier.model_dump_json(indent=2, exclude_none=True),
encoding="utf-8",
)
return jsonify({"ok": True, "statut": statut})
@app.route("/admin/models", methods=["GET"])
def list_models():
models = fetch_ollama_models()
@@ -535,14 +1134,14 @@ def create_app() -> Flask:
logger.info("Modèle Ollama global changé : %s", new_model)
return jsonify({"ok": True, "model": cfg.OLLAMA_MODEL})
@app.route("/reprocess/<path:filepath>", methods=["POST"])
@app.route("/admin/reprocess/<path:filepath>", methods=["POST"])
def reprocess(filepath: str):
"""Relance le pipeline complet : process PDFs + fusion + GHM + CPAM."""
from ..main import process_pdf, write_outputs
from ..medical.ghm import estimate_ghm
dossier = load_dossier(filepath)
input_dir = Path(__file__).parent.parent.parent / "input"
input_dir = INPUT_DIR
# Collecter les PDFs sources (fusionné → source_files, simple → source_file)
source_names = []
@@ -559,10 +1158,20 @@ def create_app() -> Flask:
missing = []
for name in source_names:
found = None
# Essai 1 : nom exact
for p in input_dir.rglob(name):
if p.is_file():
found = p
break
# Essai 2 : retirer le préfixe "{num}_{nip}_" ajouté par la réorg
if not found:
import re
stripped = re.sub(r"^\d+_\d+_", "", name)
if stripped != name:
for p in input_dir.rglob(stripped):
if p.is_file():
found = p
break
if found:
pdf_paths.append(found)
else:
@@ -635,12 +1244,18 @@ def create_app() -> Flask:
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",
)
merged_json = merged.model_dump_json(indent=2, exclude_none=True)
merged_path.write_text(merged_json, encoding="utf-8")
logger.info("Dossier fusionné réécrit : %s", merged_path)
# Sync vers le répertoire du viewer si différent
viewer_dir = STRUCTURED_DIR / Path(filepath).parts[0]
if viewer_dir.resolve() != struct_dir.resolve():
viewer_dir.mkdir(parents=True, exist_ok=True)
viewer_fusionne = viewer_dir / Path(filepath).name
viewer_fusionne.write_text(merged_json, encoding="utf-8")
logger.info("Fusionné copié vers viewer : %s", viewer_fusionne)
msg = f"Traitement terminé ({len(group_dossiers)} dossier(s)"
if merged:
msg += ", fusionné"
@@ -654,6 +1269,44 @@ def create_app() -> Flask:
logger.exception("Erreur lors du retraitement")
return jsonify({"error": str(e)}), 500
@app.route("/admin/upload-document/<path:filepath>", methods=["POST"])
def upload_document(filepath: str):
"""Upload un PDF dans input/<sous-dossier>/ puis relance le retraitement."""
if "file" not in request.files:
return jsonify({"error": "Aucun fichier fourni"}), 400
f = request.files["file"]
if not f.filename or not f.filename.lower().endswith(".pdf"):
return jsonify({"error": "Seuls les fichiers PDF sont acceptés"}), 400
# Déterminer le sous-dossier input
dossier = load_dossier(filepath)
input_dir = INPUT_DIR
rel_parts = Path(filepath).parts
subdir = str(Path(*rel_parts[:-1])) if len(rel_parts) > 1 else None
target_dir = input_dir / subdir if subdir else input_dir
target_dir.mkdir(parents=True, exist_ok=True)
# Sauvegarder le PDF
safe_name = secure_filename(f.filename)
dest = target_dir / safe_name
f.save(str(dest))
logger.info("Document uploadé : %s", dest)
# Relancer le retraitement via la route existante
try:
with app.test_request_context():
resp = reprocess(filepath)
if hasattr(resp, "get_json"):
data = resp.get_json()
elif isinstance(resp, tuple):
data = resp[0].get_json()
else:
data = {"ok": True}
return jsonify({"ok": True, "message": f"PDF '{safe_name}' ajouté. {data.get('message', '')}"})
except Exception as e:
logger.exception("Erreur après upload + reprocess")
return jsonify({"ok": True, "message": f"PDF '{safe_name}' ajouté mais erreur retraitement : {e}"})
# ------------------------------------------------------------------
# API texte source anonymisé
# ------------------------------------------------------------------
@@ -717,13 +1370,14 @@ def create_app() -> Flask:
# Routes admin référentiels
# ------------------------------------------------------------------
ref_manager = ReferentielManager()
@app.route("/admin/referentiels")
def admin_referentiels():
refs = ref_manager.list_all()
builtin = get_builtin_referentiels()
return render_template("admin_referentiels.html", referentiels=refs, builtin_refs=builtin, max_size=UPLOAD_MAX_SIZE_MB)
faiss_info = get_faiss_index_info()
return render_template("admin_referentiels.html",
referentiels=refs, builtin_refs=builtin,
faiss_info=faiss_info, max_size=UPLOAD_MAX_SIZE_MB)
@app.route("/admin/referentiels/upload", methods=["POST"])
def upload_referentiel():

View File

@@ -22,7 +22,8 @@ class ReferentielManager:
"""
def __init__(self, referentiels_dir: Path | None = None):
self._dir = referentiels_dir or REFERENTIELS_DIR
self._base = referentiels_dir or REFERENTIELS_DIR
self._dir = self._base / "user"
self._dir.mkdir(parents=True, exist_ok=True)
self._index_path = self._dir / "index.json"
self._index: list[dict] = self._load_index()
@@ -75,14 +76,14 @@ class ReferentielManager:
ref_id = uuid.uuid4().hex[:12]
safe_name = f"{ref_id}_{Path(filename).stem}{ext}"
file_path = self._dir / safe_name
file_path = self._dir / safe_name # user/ subdirectory
file_path.write_bytes(file_data)
ref = {
"id": ref_id,
"filename": filename,
"stored_name": safe_name,
"stored_name": f"user/{safe_name}",
"extension": ext,
"size_bytes": len(file_data),
"date_added": datetime.now().isoformat(),
@@ -105,7 +106,7 @@ class ReferentielManager:
if not ref:
return False
file_path = self._dir / ref["stored_name"]
file_path = self._base / ref["stored_name"]
if file_path.exists():
file_path.unlink()
@@ -131,7 +132,7 @@ class ReferentielManager:
if not ref:
raise ValueError(f"Référentiel {ref_id} introuvable")
file_path = self._dir / ref["stored_name"]
file_path = self._base / ref["stored_name"]
if not file_path.exists():
raise ValueError(f"Fichier {ref['stored_name']} introuvable")

View File

@@ -1,22 +1,212 @@
{% extends "base.html" %}
{% block title %}Référentiels RAG{% endblock %}
{% block title %}Referentiels RAG{% endblock %}
{% block sidebar %}
<div class="group-title">Admin</div>
<a href="/admin/referentiels" style="color:#60a5fa;font-weight:600;border-left-color:#3b82f6;">Référentiels RAG</a>
<a href="/admin/referentiels" style="color:#60a5fa;font-weight:600;border-left-color:#3b82f6;">Referentiels RAG</a>
<a href="/dashboard">Dashboard</a>
<a href="/">Retour aux dossiers</a>
{% endblock %}
{% block content %}
<h2>Référentiels RAG</h2>
<p style="font-size:0.85rem;color:#64748b;margin-bottom:1.5rem;">
Ajoutez des documents de référence (PDF, CSV, Excel, TXT) pour enrichir la base de connaissances du RAG.
</p>
<a class="back" href="/dashboard">&larr; Dashboard</a>
<h2 style="margin-top:1rem;">Referentiels &amp; Index RAG</h2>
<!-- Zone upload -->
{# ---- Cartes FAISS synthese ---- #}
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem;margin:1rem 0 1.5rem;">
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Statut FAISS</div>
<div style="font-size:1.5rem;font-weight:700;margin-top:0.25rem;">
{% if faiss_info.ok %}
<span style="color:#16a34a;">Actif</span>
{% else %}
<span style="color:#dc2626;">Inactif</span>
{% endif %}
</div>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Vecteurs totaux</div>
<div style="font-size:1.5rem;font-weight:700;color:#3b82f6;margin-top:0.25rem;">{{ "{:,}".format(faiss_info.total_vectors).replace(",", " ") }}</div>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Derniere indexation</div>
<div style="font-size:0.95rem;font-weight:600;color:#0f172a;margin-top:0.35rem;">{{ faiss_info.last_build or '—' }}</div>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Documents indexes</div>
<div style="font-size:1.5rem;font-weight:700;color:#6366f1;margin-top:0.25rem;">{{ builtin_refs | length }}</div>
</div>
</div>
{# ---- Detail index FAISS ---- #}
<div class="card" style="margin-bottom:1.5rem;">
<h3>Ajouter un référentiel</h3>
<h3>Index FAISS</h3>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem;">
Detail des index vectoriels par type. Chaque index sert un role different dans le pipeline RAG.
</p>
<table>
<thead>
<tr>
<th>Index</th>
<th>Role</th>
<th>Vecteurs</th>
<th>Taille</th>
<th>Derniere MAJ</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{% for idx in faiss_info.indexes %}
<tr>
<td style="font-weight:600;">{{ idx.label }}</td>
<td style="font-size:0.8rem;color:#64748b;">
{% if idx.kind == 'ref' %}Codage CIM-10, index alphabetique
{% elif idx.kind == 'proc' %}Guide methodo, procedures, regles ATIH
{% elif idx.kind == 'bio' %}Normes biologiques, seuils
{% else %}Index combine (compat)
{% endif %}
</td>
<td><strong>{{ "{:,}".format(idx.vectors).replace(",", " ") }}</strong></td>
<td>{{ idx.size_mb }} Mo</td>
<td style="font-size:0.8rem;">{{ idx.last_build or '—' }}</td>
<td>
{% if not idx.exists %}
<span class="badge" style="background:#fee2e2;color:#dc2626;">Absent</span>
{% elif idx.vectors == 0 %}
<span class="badge" style="background:#fef9c3;color:#ca8a04;">Vide</span>
{% else %}
<span class="badge" style="background:#dcfce7;color:#16a34a;">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# ---- Referentiels integres ---- #}
<div class="card" style="margin-bottom:1.5rem;">
<h3>Referentiels integres</h3>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem;">
Sources officielles ATIH/DGOS integrees automatiquement dans l'index FAISS.
Les dates de validite indiquent la campagne tarifaire couverte.
</p>
<table>
<thead>
<tr>
<th>Nom</th>
<th>Fichier</th>
<th>Edition</th>
<th>Validite</th>
<th>Taille</th>
<th>Chunks</th>
<th>Fichier du</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{% for ref in builtin_refs %}
{% if not ref.get('category') %}
<tr>
<td style="font-weight:600;">{{ ref.name }}</td>
<td style="font-size:0.75rem;color:#64748b;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
<code>{{ ref.filename }}</code>
</td>
<td style="font-size:0.8rem;">{{ ref.edition or '—' }}</td>
<td>
{% if ref.validite %}
{% if 'provisoire' in ref.validite %}
<span class="badge" style="background:#fef9c3;color:#ca8a04;">{{ ref.validite }}</span>
{% else %}
<span class="badge" style="background:#dcfce7;color:#16a34a;">{{ ref.validite }}</span>
{% endif %}
{% else %}
<span style="color:#94a3b8;"></span>
{% endif %}
</td>
<td>{{ "%.1f"|format(ref.size_mb) }} Mo</td>
<td>
{% if ref.chunks %}
<strong>{{ ref.chunks }}</strong>
{% else %}
<span style="color:#94a3b8;"></span>
{% endif %}
</td>
<td style="font-size:0.8rem;">{{ ref.file_date or '—' }}</td>
<td>
{% if not ref.exists %}
<span class="badge" style="background:#fee2e2;color:#dc2626;">Fichier absent</span>
{% elif ref.chunks %}
<span class="badge" style="background:#dcfce7;color:#16a34a;">Indexe</span>
{% else %}
<span class="badge" style="background:#f1f5f9;color:#64748b;">Dictionnaire</span>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{# ---- Referentiels supplementaires (ref:*.pdf indexes) ---- #}
{% set extra_refs = builtin_refs | selectattr('category', 'defined') | list %}
{% if extra_refs %}
<div class="card" style="margin-bottom:1.5rem;">
<h3>Referentiels supplementaires indexes</h3>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem;">
Documents de reference supplementaires (annexes CIM-10, consignes ATIH, manuels GHM)
automatiquement detectes et indexes dans FAISS.
</p>
<table>
<thead>
<tr>
<th>Document</th>
<th>Type</th>
<th>Taille</th>
<th>Chunks</th>
<th>Fichier du</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{% for ref in extra_refs %}
<tr>
<td style="font-weight:500;">{{ ref.name }}</td>
<td>
{% if ref.category == 'proc' %}
<span class="badge" style="background:#e0e7ff;color:#3730a3;">Procedure</span>
{% else %}
<span class="badge" style="background:#f1f5f9;color:#334155;">Reference</span>
{% endif %}
</td>
<td>{{ "%.1f"|format(ref.size_mb) }} Mo</td>
<td><strong>{{ ref.chunks }}</strong></td>
<td style="font-size:0.8rem;">{{ ref.file_date or '—' }}</td>
<td>
{% if ref.exists %}
<span class="badge" style="background:#dcfce7;color:#16a34a;">Indexe</span>
{% elif ref.chunks %}
<span class="badge" style="background:#dbeafe;color:#2563eb;">Indexe (PDF supprime)</span>
{% else %}
<span class="badge" style="background:#fee2e2;color:#dc2626;">Non indexe</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# ---- Zone upload ---- #}
<div class="card" style="margin-bottom:1.5rem;">
<h3>Ajouter un referentiel</h3>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.5rem;">
Uploadez un document de reference (PDF, CSV, Excel, TXT) pour enrichir la base RAG.
Le document sera indexe dans FAISS et utilisable immediatement pour le codage.
</p>
<form id="upload-form" style="display:flex;gap:0.75rem;align-items:end;flex-wrap:wrap;margin-top:0.75rem;">
<div>
<label style="display:block;font-size:0.7rem;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Fichier</label>
@@ -34,59 +224,13 @@
</p>
</div>
<!-- Référentiels intégrés (built-in) -->
{# ---- Referentiels utilisateur ---- #}
<div class="card" style="margin-bottom:1.5rem;">
<h3>Référentiels intégrés</h3>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem;">
Sources intégrées automatiquement dans l'index FAISS au build.
</p>
<table>
<thead>
<tr>
<th>Nom</th>
<th>Fichier</th>
<th>Type</th>
<th>Taille</th>
<th>Chunks</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{% for ref in builtin_refs %}
<tr>
<td style="font-weight:600;">{{ ref.name }}</td>
<td style="font-size:0.8rem;color:#64748b;"><code>{{ ref.filename }}</code></td>
<td><span class="badge" style="background:#f1f5f9;color:#334155;">{{ ref.extension }}</span></td>
<td>{{ "%.1f"|format(ref.size_mb) }} Mo</td>
<td>
{% if ref.chunks %}
<strong>{{ ref.chunks }}</strong>
{% else %}
<span style="color:#94a3b8;"></span>
{% endif %}
</td>
<td>
{% if not ref.exists %}
<span class="badge" style="background:#fee2e2;color:#dc2626;">Fichier absent</span>
{% elif ref.chunks %}
<span class="badge" style="background:#dcfce7;color:#16a34a;">Indexé</span>
{% else %}
<span class="badge" style="background:#f1f5f9;color:#64748b;">Dictionnaire</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Tableau référentiels utilisateur -->
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
<h3>Référentiels utilisateur</h3>
<h3>Referentiels utilisateur</h3>
<button id="rebuild-btn"
style="padding:0.35rem 0.75rem;border-radius:6px;border:1px solid #e2e8f0;background:#fff;font-size:0.75rem;cursor:pointer;">
Rebuild complet
style="padding:0.4rem 1rem;border-radius:6px;border:1px solid #e2e8f0;background:#fff;font-size:0.8rem;cursor:pointer;font-weight:600;">
Reconstruire l'index FAISS
</button>
</div>
@@ -96,7 +240,7 @@
<th>Nom</th>
<th>Type</th>
<th>Taille</th>
<th>Date</th>
<th>Date ajout</th>
<th>Chunks</th>
<th>Statut</th>
<th>Actions</th>
@@ -112,11 +256,11 @@
<td>{{ ref.chunks_count }}</td>
<td>
{% if ref.status == 'indexed' %}
<span class="badge" style="background:#dcfce7;color:#16a34a;">Indexé</span>
<span class="badge" style="background:#dcfce7;color:#16a34a;">Indexe</span>
{% elif ref.status == 'empty' %}
<span class="badge" style="background:#fef9c3;color:#ca8a04;">Vide</span>
{% else %}
<span class="badge" style="background:#f1f5f9;color:#64748b;">Uploadé</span>
<span class="badge" style="background:#f1f5f9;color:#64748b;">Uploade</span>
{% endif %}
</td>
<td>
@@ -133,72 +277,127 @@
{% endfor %}
{% if not referentiels %}
<tr id="empty-row">
<td colspan="7" style="text-align:center;color:#94a3b8;padding:2rem;">Aucun référentiel</td>
<td colspan="7" style="text-align:center;color:#94a3b8;padding:2rem;">Aucun referentiel utilisateur</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{# ---- Aide referentiels ATIH ---- #}
<div class="card" style="margin-bottom:1.5rem;background:#f8fafc;border:1px dashed #cbd5e1;">
<h3 style="color:#475569;">Referentiels ATIH — Mises a jour</h3>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem;">
Les referentiels T2A sont publies par l'ATIH selon le calendrier suivant :
</p>
<table style="font-size:0.8rem;">
<thead>
<tr><th>Referentiel</th><th>Frequence</th><th>Publication</th><th>Source</th></tr>
</thead>
<tbody>
<tr>
<td>CIM-10 FR a usage PMSI</td>
<td>Annuelle</td>
<td>Decembre N-1 (provisoire), Mars N (definitif)</td>
<td style="font-size:0.75rem;"><code>atih.sante.fr</code></td>
</tr>
<tr>
<td>Guide methodologique MCO</td>
<td>Annuelle</td>
<td>Decembre N-1 (provisoire), Mars N (definitif)</td>
<td style="font-size:0.75rem;"><code>atih.sante.fr</code></td>
</tr>
<tr>
<td>CCAM descriptive a usage PMSI</td>
<td>~2/an</td>
<td>Mars et Septembre</td>
<td style="font-size:0.75rem;"><code>atih.sante.fr</code></td>
</tr>
<tr>
<td>Manuel des GHM</td>
<td>Annuelle</td>
<td>Mars</td>
<td style="font-size:0.75rem;"><code>atih.sante.fr</code></td>
</tr>
<tr>
<td>Consignes de codage (COVID, sepsis...)</td>
<td>Variable</td>
<td>Au fil des publications ATIH/DGOS</td>
<td style="font-size:0.75rem;"><code>atih.sante.fr/consignes</code></td>
</tr>
<tr>
<td>Instructions controle T2A (campagnes)</td>
<td>Annuelle</td>
<td>Avril-Mai (debut campagne)</td>
<td style="font-size:0.75rem;"><code>ameli.fr</code></td>
</tr>
</tbody>
</table>
<p style="font-size:0.75rem;color:#94a3b8;margin-top:0.75rem;">
Pour mettre a jour : uploadez le nouveau PDF ci-dessus, puis cliquez "Indexer".
Reconstruisez l'index FAISS complet apres mise a jour des referentiels principaux (CIM-10, guide methodo).
</p>
</div>
<div id="global-status" style="margin-top:1rem;font-size:0.8rem;"></div>
{% endblock %}
{% block scripts %}
<script>
(function() {
const uploadForm = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const uploadBtn = document.getElementById('upload-btn');
const uploadStatus = document.getElementById('upload-status');
const globalStatus = document.getElementById('global-status');
const rebuildBtn = document.getElementById('rebuild-btn');
var uploadForm = document.getElementById('upload-form');
var fileInput = document.getElementById('file-input');
var uploadBtn = document.getElementById('upload-btn');
var uploadStatus = document.getElementById('upload-status');
var globalStatus = document.getElementById('global-status');
var rebuildBtn = document.getElementById('rebuild-btn');
uploadForm.addEventListener('submit', function(e) {
e.preventDefault();
const file = fileInput.files[0];
if (!file) { uploadStatus.textContent = 'Sélectionnez un fichier'; return; }
var file = fileInput.files[0];
if (!file) { uploadStatus.textContent = 'Selectionnez un fichier'; return; }
const fd = new FormData();
var fd = new FormData();
fd.append('file', file);
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<span class="spinner"></span>';
uploadBtn.textContent = 'Upload...';
uploadStatus.textContent = '';
fetch('/admin/referentiels/upload', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
fetch('/admin/referentiels/upload', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
uploadBtn.disabled = false;
uploadBtn.textContent = 'Uploader';
if (d.ok) {
uploadStatus.style.color = '#16a34a';
uploadStatus.textContent = 'Uploadé';
setTimeout(() => location.reload(), 800);
uploadStatus.textContent = 'Uploade avec succes';
setTimeout(function() { location.reload(); }, 800);
} else {
uploadStatus.style.color = '#dc2626';
uploadStatus.textContent = d.error || 'Erreur';
}
})
.catch(() => {
.catch(function() {
uploadBtn.disabled = false;
uploadBtn.textContent = 'Uploader';
uploadStatus.style.color = '#dc2626';
uploadStatus.textContent = 'Erreur réseau';
uploadStatus.textContent = 'Erreur reseau';
});
});
window.indexRef = function(id) {
const btn = event.target;
var btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="border-color:rgba(37,99,235,0.3);border-top-color:#2563eb;width:10px;height:10px;"></span>';
btn.textContent = 'Indexation...';
fetch('/admin/referentiels/' + id + '/index', { method: 'POST' })
.then(r => r.json())
.then(d => {
fetch('/admin/referentiels/' + id + '/index', { method: 'POST', credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
globalStatus.style.color = '#16a34a';
globalStatus.textContent = d.chunks + ' chunks indexés';
setTimeout(() => location.reload(), 800);
globalStatus.textContent = d.chunks + ' chunks indexes';
setTimeout(function() { location.reload(); }, 800);
} else {
btn.disabled = false;
btn.textContent = 'Indexer';
@@ -206,59 +405,60 @@
globalStatus.textContent = d.error || 'Erreur';
}
})
.catch(() => {
.catch(function() {
btn.disabled = false;
btn.textContent = 'Indexer';
globalStatus.style.color = '#dc2626';
globalStatus.textContent = 'Erreur réseau';
globalStatus.textContent = 'Erreur reseau';
});
};
window.deleteRef = function(id) {
if (!confirm('Supprimer ce référentiel ?')) return;
if (!confirm('Supprimer ce referentiel ?')) return;
fetch('/admin/referentiels/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(d => {
fetch('/admin/referentiels/' + id, { method: 'DELETE', credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
const row = document.getElementById('row-' + id);
var row = document.getElementById('row-' + id);
if (row) row.remove();
globalStatus.style.color = '#16a34a';
globalStatus.textContent = 'Supprimé';
globalStatus.textContent = 'Supprime';
} else {
globalStatus.style.color = '#dc2626';
globalStatus.textContent = d.error || 'Erreur';
}
})
.catch(() => {
.catch(function() {
globalStatus.style.color = '#dc2626';
globalStatus.textContent = 'Erreur réseau';
globalStatus.textContent = 'Erreur reseau';
});
};
rebuildBtn.addEventListener('click', function() {
if (!confirm('Reconstruire l\'index FAISS complet ? Cela peut prendre plusieurs minutes.')) return;
rebuildBtn.disabled = true;
rebuildBtn.innerHTML = '<span class="spinner" style="border-color:rgba(0,0,0,0.2);border-top-color:#333;width:10px;height:10px;"></span> Rebuild…';
rebuildBtn.textContent = 'Reconstruction...';
fetch('/admin/referentiels/rebuild-index', { method: 'POST' })
.then(r => r.json())
.then(d => {
fetch('/admin/referentiels/rebuild-index', { method: 'POST', credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
rebuildBtn.disabled = false;
rebuildBtn.textContent = 'Rebuild complet';
rebuildBtn.textContent = 'Reconstruire l\'index FAISS';
if (d.ok) {
globalStatus.style.color = '#16a34a';
globalStatus.textContent = 'Index reconstruit (' + d.reindexed + ' référentiels réindexés)';
globalStatus.textContent = 'Index reconstruit (' + d.reindexed + ' referentiels reindexes)';
setTimeout(function() { location.reload(); }, 1000);
} else {
globalStatus.style.color = '#dc2626';
globalStatus.textContent = d.error || 'Erreur';
}
})
.catch(() => {
.catch(function() {
rebuildBtn.disabled = false;
rebuildBtn.textContent = 'Rebuild complet';
rebuildBtn.textContent = 'Reconstruire l\'index FAISS';
globalStatus.style.color = '#dc2626';
globalStatus.textContent = 'Erreur réseau';
globalStatus.textContent = 'Erreur reseau';
});
});
})();

View File

@@ -78,8 +78,26 @@
font-weight: 700;
}
/* Main nav links */
.sidebar-link {
display: block;
padding: 0.4rem 1rem;
color: #cbd5e1;
text-decoration: none;
font-size: 0.8rem;
font-weight: 600;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.sidebar-link:hover {
color: #f8fafc;
background: #334155;
border-left-color: #3b82f6;
}
/* Search */
.sidebar-search {
position: relative;
padding: 0.75rem 1rem 0.5rem;
border-bottom: 1px solid #334155;
}
@@ -162,6 +180,7 @@
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
transition: all 0.15s;
}
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e8f0; }
@@ -206,6 +225,7 @@
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
transition: all 0.15s;
}
.badge-das { background: #dbeafe; color: #1d4ed8; }
.badge-actes { background: #e0e7ff; color: #3730a3; }
@@ -335,45 +355,70 @@
}
.src-file-btn:hover { background: #e2e8f0; border-color: #3b82f6; }
.src-file-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
/* Tableau dossiers (index) */
.table-dossiers { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.table-dossiers th {
text-align: left; padding: 0.6rem 0.75rem; border-bottom: 2px solid #e2e8f0;
font-weight: 600; color: #475569; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.05em; background: #f8fafc;
}
.table-dossiers td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
.row-clickable { cursor: pointer; transition: background 0.15s; }
.row-clickable:hover { background: #f0f9ff; }
/* Hamburger toggle (mobile) */
.sidebar-toggle {
display: none;
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 10001;
background: #1e293b;
color: #e2e8f0;
border: none;
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 1.2rem;
cursor: pointer;
line-height: 1;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar-toggle { display: block; }
.sidebar {
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 10000;
}
.sidebar.open { transform: translateX(0); }
.main { margin-left: 0; padding: 1rem; padding-top: 3.5rem; }
}
</style>
</head>
<body>
<!-- Hamburger (mobile) -->
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Menu">&#9776;</button>
<!-- Sidebar -->
<aside class="sidebar">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h1>T2A Viewer</h1>
<img src="{{ url_for('static', filename='logo_aivanov.png') }}" alt="Aivanov" style="height:32px;margin-bottom:0.5rem;background:#fff;border-radius:6px;padding:2px 8px;">
<h1>T2A Viewer <span style="font-size:0.65rem;font-weight:400;color:#94a3b8;">v1.0</span></h1>
<p>Visualisation CIM-10</p>
</div>
<nav class="sidebar-main-nav" style="padding:0.5rem 0;border-bottom:1px solid #334155;">
<a href="/" class="sidebar-link">&#128193; Dossiers</a>
<a href="/dashboard" class="sidebar-link">&#128202; Dashboard</a>
<a href="/dim" class="sidebar-link">&#127973; Synth&egrave;se DIM</a>
<a href="/cpam" class="sidebar-link">&#9888; Contr&ocirc;les UCR</a>
<a href="/admin/referentiels" class="sidebar-link">&#128218; R&eacute;f&eacute;rentiels</a>
</nav>
<div class="sidebar-search">
<input type="text" id="sidebar-search" placeholder="Rechercher un dossier…" autocomplete="off">
</div>
<nav class="sidebar-nav" id="sidebar-nav">
{% block sidebar %}{% endblock %}
</nav>
<div class="sidebar-admin" style="border-top:1px solid #334155;padding:0.5rem 1rem;">
<a href="/dashboard" style="display:block;color:#cbd5e1;text-decoration:none;font-size:0.8rem;font-weight:600;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#f8fafc'" onmouseout="this.style.color='#cbd5e1'">
Dashboard
</a>
<a href="/cpam" style="display:block;color:#cbd5e1;text-decoration:none;font-size:0.8rem;font-weight:600;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#f8fafc'" onmouseout="this.style.color='#cbd5e1'">
Contrôles CPAM
</a>
<a href="/admin/referentiels" style="display:block;color:#cbd5e1;text-decoration:none;font-size:0.8rem;font-weight:600;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#f8fafc'" onmouseout="this.style.color='#cbd5e1'">
Référentiels RAG
</a>
<a href="/validation" style="display:block;color:#fbbf24;text-decoration:none;font-size:0.8rem;font-weight:600;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#fde68a'" onmouseout="this.style.color='#fbbf24'">
Validation DIM
</a>
</div>
<div class="sidebar-admin">
<label for="model-select">Modèle Ollama</label>
<select id="model-select"><option>Chargement…</option></select>
<button id="model-apply">Appliquer</button>
<div class="status-msg" id="model-status"></div>
<div id="sidebar-autocomplete" style="display:none;position:absolute;left:0;right:0;background:#0f172a;border:1px solid #475569;border-radius:0 0 6px 6px;max-height:250px;overflow-y:auto;z-index:100;"></div>
</div>
</aside>
@@ -383,96 +428,53 @@
</div>
<script>
// Sidebar toggle (mobile)
(function() {
const sel = document.getElementById('model-select');
const btn = document.getElementById('model-apply');
const status = document.getElementById('model-status');
function loadModels() {
fetch('/admin/models')
.then(r => r.json())
.then(d => {
sel.innerHTML = '';
if (d.models && d.models.length) {
d.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
if (m === d.current) opt.selected = true;
sel.appendChild(opt);
});
} else {
sel.innerHTML = '<option>Aucun modèle</option>';
}
})
.catch(() => { sel.innerHTML = '<option>Erreur</option>'; });
}
btn.addEventListener('click', function() {
const model = sel.value;
if (!model || model === 'Aucun modèle' || model === 'Erreur') return;
status.textContent = '…';
status.style.color = '#94a3b8';
fetch('/admin/models', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: model})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
status.textContent = 'Modèle appliqué';
status.style.color = '#16a34a';
} else {
status.textContent = d.error || 'Erreur';
status.style.color = '#dc2626';
}
})
.catch(() => {
status.textContent = 'Erreur réseau';
status.style.color = '#dc2626';
var btn = document.getElementById('sidebar-toggle');
var sb = document.getElementById('sidebar');
if (btn && sb) {
btn.addEventListener('click', function() { sb.classList.toggle('open'); });
sb.addEventListener('click', function(e) {
if (e.target.tagName === 'A') sb.classList.remove('open');
});
});
loadModels();
}
})();
// Sidebar search filter
// Sidebar autocomplete search
(function() {
const input = document.getElementById('sidebar-search');
const nav = document.getElementById('sidebar-nav');
if (!input || !nav) return;
var input = document.getElementById('sidebar-search');
var dropdown = document.getElementById('sidebar-autocomplete');
if (!input || !dropdown) return;
var dossiers = {{ dossier_list | tojson }};
input.addEventListener('input', function() {
const q = this.value.toLowerCase().trim();
const groups = nav.querySelectorAll('.group-title');
groups.forEach(function(groupEl) {
// Collect all sibling links until next group-title
const links = [];
let next = groupEl.nextElementSibling;
while (next && !next.classList.contains('group-title')) {
if (next.tagName === 'A') links.push(next);
next = next.nextElementSibling;
}
if (!q) {
groupEl.style.display = '';
links.forEach(function(a) { a.style.display = ''; });
return;
}
const groupMatch = groupEl.textContent.toLowerCase().includes(q);
let anyLinkMatch = false;
links.forEach(function(a) {
const match = groupMatch || a.textContent.toLowerCase().includes(q);
a.style.display = match ? '' : 'none';
if (match) anyLinkMatch = true;
});
groupEl.style.display = (groupMatch || anyLinkMatch) ? '' : 'none';
var q = this.value.toLowerCase().trim();
dropdown.innerHTML = '';
if (!q) { dropdown.style.display = 'none'; return; }
var matches = dossiers.filter(function(d) { return d.name.toLowerCase().includes(q); }).slice(0, 15);
if (!matches.length) { dropdown.style.display = 'none'; return; }
matches.forEach(function(d) {
var a = document.createElement('a');
a.href = '/dossier/' + d.path;
a.textContent = d.name;
a.style.cssText = 'display:block;padding:0.35rem 0.6rem;color:#e2e8f0;text-decoration:none;font-size:0.8rem;border-bottom:1px solid #1e293b;';
a.addEventListener('mouseenter', function() { this.style.background = '#334155'; });
a.addEventListener('mouseleave', function() { this.style.background = ''; });
dropdown.appendChild(a);
});
dropdown.style.display = 'block';
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
var first = dropdown.querySelector('a');
if (first) { window.location = first.href; e.preventDefault(); }
}
});
document.addEventListener('click', function(e) {
if (!e.target.closest('.sidebar-search')) dropdown.style.display = 'none';
});
})();
</script>

View File

@@ -1,48 +1,52 @@
{% extends "base.html" %}
{% block title %}Contrôles CPAM{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block title %}Contrôles UCR{% endblock %}
{% block content %}
<a class="back" href="/">&larr; Retour à la liste</a>
<div style="display:flex;align-items:center;gap:0.75rem;margin-top:1rem;margin-bottom:1rem;">
<h2 style="margin:0;">Contrôles CPAM</h2>
<h2 style="margin:0;">Contrôles UCR</h2>
<span class="badge" style="background:#fef3c7;color:#b45309;font-size:0.85rem;padding:4px 12px;">{{ total }}</span>
</div>
{% if not controls %}
<div class="card">
<p>Aucun contrôle CPAM trouvé dans les dossiers.</p>
<p>Aucun contrôle UCR trouvé dans les dossiers.</p>
</div>
{% else %}
<div class="card" style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Priorité</th>
<th>Dossier</th>
<th>OGC</th>
<th>Qualité</th>
<th>Titre</th>
<th>Décision</th>
<th>Décision UCR</th>
<th>Codes contestés</th>
<th>Délai</th>
<th>Validation</th>
<th>Contre-argumentation</th>
</tr>
</thead>
<tbody>
{% for c in controls %}
<tr>
<td style="text-align:center;">
{% set fi = c.ctrl.financial_impact %}
{% if fi and fi.priorite == 'critique' %}
<span class="badge" style="background:#dc2626;color:#fff;font-weight:700;font-size:0.75rem;padding:3px 10px;">Critique</span>
<div style="font-size:0.65rem;color:#dc2626;margin-top:2px;">~{{ fi.impact_estime_euros }}€</div>
{% elif fi and fi.priorite == 'haute' %}
<span class="badge" style="background:#f59e0b;color:#fff;font-weight:700;font-size:0.75rem;padding:3px 10px;">Haute</span>
<div style="font-size:0.65rem;color:#b45309;margin-top:2px;">~{{ fi.impact_estime_euros }}€</div>
{% elif fi and fi.priorite == 'faible' %}
<span class="badge" style="background:#94a3b8;color:#fff;font-size:0.75rem;padding:3px 8px;">Faible</span>
{% else %}
<span class="badge" style="background:#d1fae5;color:#065f46;font-size:0.75rem;padding:3px 8px;">Normale</span>
{% endif %}
</td>
<td>
<a href="/dossier/{{ c.filepath }}" style="color:#3b82f6;text-decoration:none;font-weight:600;">
{{ c.group_name | format_dossier_name }}
@@ -79,22 +83,105 @@
{% if c.ctrl.da_ucr %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.65rem;">DA: {{ c.ctrl.da_ucr }}</span>{% endif %}
{% if c.ctrl.dr_ucr %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.65rem;">DR: {{ c.ctrl.dr_ucr }}</span>{% endif %}
{% if c.ctrl.actes_ucr %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.65rem;">Actes: {{ c.ctrl.actes_ucr }}</span>{% endif %}
{% if not c.ctrl.dp_ucr and not c.ctrl.da_ucr and not c.ctrl.dr_ucr and not c.ctrl.actes_ucr %}
{% if c.ctrl.contre_argumentation %}
<button class="btn-toggle-arg" data-row="{{ loop.index }}" style="background:none;border:1px solid #3b82f6;color:#3b82f6;border-radius:4px;padding:2px 8px;font-size:0.7rem;font-weight:600;cursor:pointer;">Voir analyse</button>
{% else %}
<span style="color:#94a3b8;font-size:0.7rem;"></span>
{% endif %}
{% endif %}
</div>
</td>
<td style="max-width:300px;">
<td style="text-align:center;white-space:nowrap;">
{% if c.jours_restants is not none %}
{% if c.jours_restants < 0 %}
<span class="badge" style="background:#dc2626;color:#fff;font-weight:700;font-size:0.75rem;">Hors délai</span>
{% elif c.jours_restants < 7 %}
<span class="badge" style="background:#dc2626;color:#fff;font-size:0.75rem;">{{ c.jours_restants }}j</span>
{% elif c.jours_restants < 15 %}
<span class="badge" style="background:#f59e0b;color:#fff;font-size:0.75rem;">{{ c.jours_restants }}j</span>
{% else %}
<span class="badge" style="background:#d1fae5;color:#065f46;font-size:0.75rem;">{{ c.jours_restants }}j</span>
{% endif %}
{% if c.ctrl.date_limite_reponse %}
<div style="font-size:0.6rem;color:#94a3b8;">{{ c.ctrl.date_limite_reponse }}</div>
{% endif %}
{% else %}
<span style="color:#94a3b8;font-size:0.7rem;"></span>
{% endif %}
</td>
<td style="text-align:center;">
{% if c.ctrl.validation_dim == 'valide' %}
<span class="badge" style="background:#d1fae5;color:#065f46;font-weight:700;font-size:0.75rem;">Validé</span>
{% elif c.ctrl.validation_dim == 'rejete' %}
<span class="badge" style="background:#fee2e2;color:#dc2626;font-weight:700;font-size:0.75rem;">Rejeté</span>
{% elif c.ctrl.validation_dim == 'en_revision' %}
<span class="badge" style="background:#fef3c7;color:#b45309;font-weight:700;font-size:0.75rem;">En révision</span>
{% else %}
<span style="color:#94a3b8;font-size:0.7rem;"></span>
{% endif %}
</td>
<td style="text-align:center;">
{% if c.ctrl.contre_argumentation %}
<details>
<summary>{{ c.ctrl.contre_argumentation[:80] }}{% if c.ctrl.contre_argumentation|length > 80 %}…{% endif %}</summary>
<pre>{{ c.ctrl.contre_argumentation }}</pre>
</details>
<button class="btn-toggle-arg" data-row="{{ loop.index }}" style="background:none;border:1px solid #3b82f6;color:#3b82f6;border-radius:4px;padding:2px 8px;font-size:0.7rem;font-weight:600;cursor:pointer;">Voir analyse</button>
{% else %}
<span style="color:#94a3b8;font-size:0.8rem;"></span>
{% endif %}
</td>
</tr>
{% if c.ctrl.contre_argumentation %}
<tr class="arg-row" id="arg-row-{{ loop.index }}" style="display:none;">
<td colspan="10" style="padding:1rem 1.25rem;background:#f8fafc;border-bottom:2px solid #e2e8f0;">
<div class="arg-content" data-raw="{{ c.ctrl.contre_argumentation|e }}"></div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
// Parse contre-argumentation: conclusion first, then rest
document.querySelectorAll('.arg-content').forEach(function(el) {
var raw = el.getAttribute('data-raw');
if (!raw) return;
// Find CONCLUSION section
var conclusionMatch = raw.match(/(?:^|\n)(CONCLUSION[^\n]*\n)([\s\S]*?)(?=\n[A-ZÉÈÊÀÂ]{3,}[^\n]*\n|$)/i);
var conclusion = '';
var rest = raw;
if (conclusionMatch) {
conclusion = (conclusionMatch[1] + conclusionMatch[2]).trim();
rest = raw.replace(conclusionMatch[0], '').trim();
}
var html = '';
if (conclusion) {
html += '<div style="background:#dbeafe;border:1px solid #93c5fd;border-radius:8px;padding:0.75rem 1rem;margin-bottom:1rem;">';
html += '<strong style="color:#1d4ed8;font-size:0.85rem;">CONCLUSION</strong>';
html += '<div style="font-size:0.85rem;color:#1e3a8a;margin-top:0.25rem;white-space:pre-wrap;">' + conclusion.replace(/^CONCLUSION[^\n]*\n?/i, '').trim() + '</div>';
html += '</div>';
}
if (rest) {
html += '<div style="font-size:0.8rem;color:#334155;white-space:pre-wrap;line-height:1.6;">' + rest + '</div>';
}
el.innerHTML = html;
});
// Toggle arg rows
document.querySelectorAll('.btn-toggle-arg').forEach(function(btn) {
btn.addEventListener('click', function() {
var rowId = 'arg-row-' + this.getAttribute('data-row');
var row = document.getElementById(rowId);
if (!row) return;
var isVisible = row.style.display !== 'none';
row.style.display = isVisible ? 'none' : 'table-row';
this.textContent = isVisible ? 'Voir analyse' : 'Masquer';
});
});
})();
</script>
{% endblock %}

View File

@@ -1,18 +1,6 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<a class="back" href="/">&larr; Retour à la liste</a>
@@ -142,4 +130,106 @@
</div>
{% endif %}
{# ---- État du système ---- #}
{% if system_status %}
<div class="card section">
<h3>État du système</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.5rem;margin-top:0.5rem;">
{% for comp in system_status %}
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;border-radius:6px;border:1px solid {% if comp.status %}#bbf7d0{% else %}#fecaca{% endif %};background:{% if comp.status %}#f0fdf4{% else %}#fef2f2{% endif %};">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:{% if comp.status %}#16a34a{% else %}#dc2626{% endif %};flex-shrink:0;"></span>
<div>
<div style="font-size:0.8rem;font-weight:600;color:#0f172a;">{{ comp.name }}</div>
<div style="font-size:0.65rem;color:#64748b;">{{ comp.detail }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# ---- Référentiels ---- #}
{% if core_refs or ref_refs or proc_refs %}
<div class="card section">
<h3>Référentiels ({{ total_refs }} documents)</h3>
{% if faiss_info %}
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;margin-bottom:0.75rem;padding:0.5rem 0.75rem;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;">
<span style="font-size:0.8rem;font-weight:600;color:#0369a1;">Index FAISS</span>
<span style="font-size:0.75rem;color:#0c4a6e;">{{ faiss_info.total_vectors }} vecteurs</span>
{% for idx in faiss_info.indexes %}
{% if idx.exists %}
<span style="font-size:0.65rem;color:#64748b;">{{ idx.label }}: {{ idx.vectors }} ({{ idx.size_mb }} Mo)</span>
{% endif %}
{% endfor %}
{% if faiss_info.last_build %}
<span style="font-size:0.65rem;color:#94a3b8;margin-left:auto;">MAJ : {{ faiss_info.last_build }}</span>
{% endif %}
</div>
{% endif %}
<table style="width:100%;border-collapse:collapse;font-size:0.75rem;">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left;">
<th style="padding:0.4rem 0.5rem;color:#64748b;font-weight:600;">Document</th>
<th style="padding:0.4rem 0.5rem;color:#64748b;font-weight:600;text-align:center;">Date</th>
<th style="padding:0.4rem 0.5rem;color:#64748b;font-weight:600;text-align:center;">Validité</th>
<th style="padding:0.4rem 0.5rem;color:#64748b;font-weight:600;text-align:right;">Taille</th>
<th style="padding:0.4rem 0.5rem;color:#64748b;font-weight:600;text-align:right;">Chunks</th>
<th style="padding:0.4rem 0.5rem;color:#64748b;font-weight:600;text-align:center;">Statut</th>
</tr>
</thead>
<tbody>
{# --- Groupe : Base de données --- #}
<tr><td colspan="6" style="padding:0.5rem 0.5rem 0.25rem;font-size:0.7rem;font-weight:700;color:#3b82f6;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #dbeafe;">Base de données</td></tr>
{% for ref in core_refs %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:0.3rem 0.5rem;font-weight:500;color:#0f172a;">{{ ref.name }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;color:#475569;">{{ ref.edition or ref.file_date or '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;color:#475569;">{{ ref.validite or '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:right;color:#64748b;">{{ '%.1f Mo' | format(ref.size_mb) if ref.size_mb else '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:right;color:#64748b;">{{ ref.chunks if ref.chunks else '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;">
{% if ref.exists %}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#16a34a;" title="Présent"></span>
{% else %}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#dc2626;" title="Absent"></span>{% endif %}
</td>
</tr>
{% endfor %}
{# --- Groupe : Fascicules & Consignes CIM-10 --- #}
{% if ref_refs %}
<tr><td colspan="6" style="padding:0.5rem 0.5rem 0.25rem;font-size:0.7rem;font-weight:700;color:#16a34a;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #bbf7d0;">Fascicules, consignes & référentiels CIM-10</td></tr>
{% for ref in ref_refs %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:0.3rem 0.5rem;font-weight:500;color:#0f172a;">{{ ref.name }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;color:#475569;">{{ ref.file_date or '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;color:#475569;">-</td>
<td style="padding:0.3rem 0.5rem;text-align:right;color:#64748b;">{{ '%.1f Mo' | format(ref.size_mb) if ref.size_mb else '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:right;color:#64748b;">{{ ref.chunks if ref.chunks else '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;">
{% if ref.exists %}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#16a34a;" title="Présent"></span>
{% else %}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#dc2626;" title="Absent"></span>{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
{# --- Groupe : Guides & Procédures --- #}
{% if proc_refs %}
<tr><td colspan="6" style="padding:0.5rem 0.5rem 0.25rem;font-size:0.7rem;font-weight:700;color:#b45309;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #fef3c7;">Guides & procédures</td></tr>
{% for ref in proc_refs %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:0.3rem 0.5rem;font-weight:500;color:#0f172a;">{{ ref.name }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;color:#475569;">{{ ref.file_date or '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;color:#475569;">-</td>
<td style="padding:0.3rem 0.5rem;text-align:right;color:#64748b;">{{ '%.1f Mo' | format(ref.size_mb) if ref.size_mb else '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:right;color:#64748b;">{{ ref.chunks if ref.chunks else '-' }}</td>
<td style="padding:0.3rem 0.5rem;text-align:center;">
{% if ref.exists %}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#16a34a;" title="Présent"></span>
{% else %}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#dc2626;" title="Absent"></span>{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,243 @@
{% extends "base.html" %}
{% block title %}Synthese DIM{% endblock %}
{% block content %}
<a class="back" href="/">&larr; Retour</a>
<h2 style="margin-top:1rem;">Synth&egrave;se DIM</h2>
{# ============================================================ #}
{# SECTION 1 : Vue d'ensemble DP + DAS #}
{# ============================================================ #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem;">
{# --- Carte DP --- #}
<div class="card section">
<h3>Diagnostic Principal</h3>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.5rem;margin-bottom:0.75rem;">
<div style="text-align:center;padding:0.5rem;background:#f0fdf4;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#16a34a;">{{ dim.dp.confirmed }}</div>
<div style="font-size:0.65rem;color:#64748b;">CONFIRMED</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#fef3c7;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#b45309;">{{ dim.dp.review }}</div>
<div style="font-size:0.65rem;color:#64748b;">REVIEW</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#dbeafe;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#1d4ed8;">{{ dim.dp.modified }}</div>
<div style="font-size:0.65rem;color:#64748b;">Modifi&eacute;s</div>
</div>
</div>
{% if dim.dp.total %}
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.5rem;">
Taux de modification DP : <strong style="color:#0f172a;">{{ ((dim.dp.modified / dim.dp.total) * 100) | round(1) }}%</strong>
({{ dim.dp.modified }}/{{ dim.dp.total }})
</div>
{% endif %}
{# Confiance DP #}
<div style="font-size:0.7rem;font-weight:600;color:#64748b;margin-bottom:0.25rem;">Confiance DP</div>
<div style="display:flex;height:20px;border-radius:4px;overflow:hidden;margin-bottom:0.5rem;">
{% set dp_tot = dim.dp.total or 1 %}
{% if dim.dp.confidence.get('high', 0) %}
<div style="width:{{ (dim.dp.confidence.get('high', 0) / dp_tot * 100)|round(1) }}%;background:#16a34a;" title="Haute: {{ dim.dp.confidence.get('high', 0) }}"></div>
{% endif %}
{% if dim.dp.confidence.get('medium', 0) %}
<div style="width:{{ (dim.dp.confidence.get('medium', 0) / dp_tot * 100)|round(1) }}%;background:#ca8a04;" title="Moyenne: {{ dim.dp.confidence.get('medium', 0) }}"></div>
{% endif %}
{% if dim.dp.confidence.get('low', 0) %}
<div style="width:{{ (dim.dp.confidence.get('low', 0) / dp_tot * 100)|round(1) }}%;background:#dc2626;" title="Basse: {{ dim.dp.confidence.get('low', 0) }}"></div>
{% endif %}
{% if dim.dp.confidence.get('none', 0) %}
<div style="width:{{ (dim.dp.confidence.get('none', 0) / dp_tot * 100)|round(1) }}%;background:#94a3b8;" title="Aucune: {{ dim.dp.confidence.get('none', 0) }}"></div>
{% endif %}
</div>
{# Source DP #}
<div style="font-size:0.7rem;font-weight:600;color:#64748b;margin-bottom:0.25rem;">Source DP</div>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;font-size:0.7rem;">
{% for src, cnt in dim.dp.source.items() %}
<span style="padding:2px 8px;border-radius:4px;background:#f1f5f9;color:#334155;">{{ src }}: {{ cnt }}</span>
{% endfor %}
</div>
</div>
{# --- Carte DAS --- #}
<div class="card section">
<h3>Diagnostics Associ&eacute;s</h3>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-bottom:0.75rem;">
<div style="text-align:center;padding:0.5rem;background:#f0fdf4;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#16a34a;">{{ dim.das.kept }}</div>
<div style="font-size:0.6rem;color:#64748b;">Conserv&eacute;s</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#fef3c7;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#b45309;">{{ dim.das.downgraded }}</div>
<div style="font-size:0.6rem;color:#64748b;">D&eacute;grad&eacute;s</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#fee2e2;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#dc2626;">{{ dim.das.removed }}</div>
<div style="font-size:0.6rem;color:#64748b;">Supprim&eacute;s</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#f1f5f9;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#64748b;">{{ dim.das.ruled_out }}</div>
<div style="font-size:0.6rem;color:#64748b;">Exclus</div>
</div>
</div>
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.5rem;">
Total : <strong>{{ dim.das.total }}</strong> DAS |
Taux de modification : <strong style="color:{% if dim.das.taux_modification > 20 %}#dc2626{% elif dim.das.taux_modification > 10 %}#b45309{% else %}#16a34a{% endif %};">{{ dim.das.taux_modification }}%</strong>
</div>
{# Barre DAS #}
{% set das_tot = dim.das.total or 1 %}
<div style="display:flex;height:20px;border-radius:4px;overflow:hidden;margin-bottom:0.5rem;">
<div style="width:{{ (dim.das.kept / das_tot * 100)|round(1) }}%;background:#16a34a;" title="Conservés: {{ dim.das.kept }}"></div>
{% if dim.das.downgraded %}
<div style="width:{{ (dim.das.downgraded / das_tot * 100)|round(1) }}%;background:#ca8a04;" title="Dégradés: {{ dim.das.downgraded }}"></div>
{% endif %}
{% if dim.das.removed %}
<div style="width:{{ (dim.das.removed / das_tot * 100)|round(1) }}%;background:#dc2626;" title="Supprimés: {{ dim.das.removed }}"></div>
{% endif %}
{% if dim.das.ruled_out %}
<div style="width:{{ (dim.das.ruled_out / das_tot * 100)|round(1) }}%;background:#94a3b8;" title="Exclus: {{ dim.das.ruled_out }}"></div>
{% endif %}
</div>
<div style="display:flex;gap:1rem;font-size:0.7rem;">
<span style="padding:2px 8px;border-radius:4px;background:#dcfce7;color:#166534;">CMA : {{ dim.das.cma }}</span>
{% if dim.das.no_code %}
<span style="padding:2px 8px;border-radius:4px;background:#fee2e2;color:#991b1b;">Sans code : {{ dim.das.no_code }}</span>
{% endif %}
</div>
</div>
</div>
{# ============================================================ #}
{# SECTION 2 : Qualité & Contestabilité #}
{# ============================================================ #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem;">
{# --- Contestabilité (Vetos) --- #}
<div class="card section">
<h3>Contestabilit&eacute; (Veto Engine)</h3>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.5rem;margin-bottom:0.75rem;">
<div style="text-align:center;padding:0.5rem;background:#f0fdf4;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#16a34a;">{{ dim.veto.distribution.get('PASS', 0) }}</div>
<div style="font-size:0.65rem;color:#64748b;">PASS</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#fef3c7;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#b45309;">{{ dim.veto.distribution.get('NEED_INFO', 0) }}</div>
<div style="font-size:0.65rem;color:#64748b;">NEED_INFO</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#fee2e2;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#dc2626;">{{ dim.veto.distribution.get('FAIL', 0) }}</div>
<div style="font-size:0.65rem;color:#64748b;">FAIL</div>
</div>
</div>
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.75rem;">
Score moyen de d&eacute;fendabilit&eacute; : <strong style="color:{% if dim.veto.avg_score >= 70 %}#16a34a{% elif dim.veto.avg_score >= 40 %}#b45309{% else %}#dc2626{% endif %};">{{ dim.veto.avg_score }}/100</strong>
</div>
{% if dim.veto.top_issues %}
<div style="font-size:0.7rem;font-weight:600;color:#64748b;margin-bottom:0.25rem;">Alertes les plus fr&eacute;quentes</div>
{% for veto_id, count in dim.veto.top_issues %}
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;">
<code style="font-size:0.65rem;font-weight:600;min-width:60px;">{{ veto_id }}</code>
<div style="flex:1;height:12px;background:#f1f5f9;border-radius:3px;overflow:hidden;">
<div style="width:{{ (count / dim.veto.top_issues[0][1] * 100)|round(1) }}%;height:100%;background:#f97316;border-radius:3px;"></div>
</div>
<span style="font-size:0.65rem;color:#64748b;min-width:25px;text-align:right;">{{ count }}</span>
</div>
{% endfor %}
{% endif %}
</div>
{# --- Complétude documentaire --- #}
<div class="card section">
<h3>Compl&eacute;tude documentaire</h3>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.5rem;margin-bottom:0.75rem;">
<div style="text-align:center;padding:0.5rem;background:#f0fdf4;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#16a34a;">{{ dim.completude.distribution.get('defendable', 0) }}</div>
<div style="font-size:0.6rem;color:#64748b;">D&eacute;fendable</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#fef3c7;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#b45309;">{{ dim.completude.distribution.get('fragile', 0) }}</div>
<div style="font-size:0.6rem;color:#64748b;">Fragile</div>
</div>
<div style="text-align:center;padding:0.5rem;background:#fee2e2;border-radius:6px;">
<div style="font-size:1.4rem;font-weight:700;color:#dc2626;">{{ dim.completude.distribution.get('indefendable', 0) }}</div>
<div style="font-size:0.6rem;color:#64748b;">Ind&eacute;fendable</div>
</div>
</div>
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.75rem;">
Score moyen : <strong style="color:{% if dim.completude.avg_score >= 70 %}#16a34a{% elif dim.completude.avg_score >= 40 %}#b45309{% else %}#dc2626{% endif %};">{{ dim.completude.avg_score }}/100</strong>
</div>
{# --- Synthèse CPAM --- #}
{% if dim.cpam.total %}
<div style="border-top:1px solid #e2e8f0;padding-top:0.75rem;margin-top:0.5rem;">
<div style="font-size:0.7rem;font-weight:600;color:#64748b;margin-bottom:0.5rem;">Contr&ocirc;les CPAM</div>
<div style="display:flex;gap:1rem;font-size:0.75rem;margin-bottom:0.5rem;">
<span><strong>{{ dim.cpam.total }}</strong> contr&ocirc;les</span>
{% if dim.cpam.impact_total %}
<span>Impact estim&eacute; : <strong style="color:#dc2626;">{{ "{:,.0f}".format(dim.cpam.impact_total).replace(",", " ") }} &euro;</strong></span>
{% endif %}
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem;font-size:0.65rem;">
{% for prio, cnt in dim.cpam.by_priority.items() %}
<span style="padding:1px 6px;border-radius:3px;background:{% if prio == 'critique' %}#fee2e2{% elif prio == 'haute' %}#fef3c7{% else %}#f1f5f9{% endif %};color:{% if prio == 'critique' %}#991b1b{% elif prio == 'haute' %}#92400e{% else %}#475569{% endif %};">{{ prio }}: {{ cnt }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{# ============================================================ #}
{# SECTION 3 : Alertes prioritaires DIM #}
{# ============================================================ #}
{% if dim.alertes.review or dim.alertes.fail or dim.alertes.indefendable %}
<div class="card section">
<h3>Alertes prioritaires</h3>
{% if dim.alertes.fail %}
<div style="margin-bottom:1rem;">
<div style="font-size:0.7rem;font-weight:700;color:#dc2626;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.35rem;padding-bottom:0.2rem;border-bottom:1px solid #fecaca;">
Veto FAIL &mdash; Codage contestable ({{ dim.alertes.fail | length }})
</div>
{% for d in dim.alertes.fail %}
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;font-size:0.75rem;border-bottom:1px solid #f8fafc;">
<a href="/dossier/{{ d.path }}" style="color:#1d4ed8;font-weight:500;min-width:180px;">{{ d.name }}</a>
<span style="color:#64748b;">Score {{ d.score }}/100</span>
<span style="color:#94a3b8;">{{ d.issues }} issues</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if dim.alertes.review %}
<div style="margin-bottom:1rem;">
<div style="font-size:0.7rem;font-weight:700;color:#b45309;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.35rem;padding-bottom:0.2rem;border-bottom:1px solid #fef3c7;">
DP en REVIEW &mdash; &Agrave; valider ({{ dim.alertes.review | length }})
</div>
{% for d in dim.alertes.review %}
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;font-size:0.75rem;border-bottom:1px solid #f8fafc;">
<a href="/dossier/{{ d.path }}" style="color:#1d4ed8;font-weight:500;min-width:180px;">{{ d.name }}</a>
<code style="font-size:0.65rem;background:#f1f5f9;padding:1px 4px;border-radius:3px;">{{ d.code }}</code>
<span style="color:#64748b;font-size:0.7rem;">{{ d.reason | truncate(80) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if dim.alertes.indefendable %}
<div>
<div style="font-size:0.7rem;font-weight:700;color:#dc2626;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.35rem;padding-bottom:0.2rem;border-bottom:1px solid #fecaca;">
Compl&eacute;tude ind&eacute;fendable ({{ dim.alertes.indefendable | length }})
</div>
{% for d in dim.alertes.indefendable %}
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;font-size:0.75rem;border-bottom:1px solid #f8fafc;">
<a href="/dossier/{{ d.path }}" style="color:#1d4ed8;font-weight:500;min-width:180px;">{{ d.name }}</a>
<span style="color:#64748b;">Score {{ d.score }}/100</span>
<span style="color:#94a3b8;">{{ d.manquants }} docs manquants</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,22 +1,24 @@
{% extends "base.html" %}
{% block title %}Accueil{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<h2>Dossiers médicaux traités</h2>
{% if stats %}
<div class="card" style="margin-bottom:1.5rem;padding:0.75rem 1.25rem;display:flex;flex-wrap:wrap;gap:1rem;align-items:center;">
<span style="font-size:0.8rem;font-weight:700;color:#475569;">Vue globale</span>
<span class="badge-count badge-das">{{ stats.total_dossiers }} dossiers</span>
<span class="badge-count badge-das">{{ stats.total_das }} DAS</span>
<span class="badge-count badge-actes">{{ stats.total_actes }} actes</span>
{% if stats.total_alertes %}<span class="badge-count badge-alertes">{{ stats.total_alertes }} alertes</span>{% endif %}
{% if stats.total_cma %}<span class="badge-count badge-cma">{{ stats.total_cma }} CMA</span>{% endif %}
{% if stats.total_cpam %}<span class="badge-count" style="background:#fef3c7;color:#92400e;">{{ stats.total_cpam }} CPAM</span>{% endif %}
{% if stats.processing_time_total %}
<span style="font-size:0.75rem;color:#64748b;">Total : {{ stats.processing_time_total|format_duration }}</span>
{% endif %}
</div>
{% endif %}
{% if not groups %}
<div class="card">
<p>Aucun dossier trouvé dans <code>output/structured/</code>.</p>
@@ -24,77 +26,98 @@
Lancez le pipeline avec <code>python -m src.main</code> pour générer des fichiers.
</p>
</div>
{% endif %}
{% for group_name, items in groups.items() %}
<div class="section">
{% set ns = namespace(total=0.0, count=0) %}
{% for item in items %}
{% if item.dossier.processing_time_s is not none %}
{% set ns.total = ns.total + item.dossier.processing_time_s %}
{% set ns.count = ns.count + 1 %}
{% endif %}
{% endfor %}
{% set stats = group_stats.get(group_name, {}) %}
<h3 style="display:flex;align-items:baseline;gap:0.75rem;flex-wrap:wrap;">
{{ group_name | format_dossier_name }}
<span style="font-size:0.75rem;font-weight:400;color:#64748b;">
{{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|format_duration }}{% 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;">
<div class="card" style="cursor:pointer;transition:box-shadow 0.15s;">
<div style="font-weight:600;font-size:0.9rem;margin-bottom:0.4rem;color:#0f172a;">
{{ item.name | format_doc_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 %}
</div>
{% if item.dossier.diagnostic_principal.cim10_suggestion %}
<div style="margin-top:0.25rem;">
<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ item.dossier.diagnostic_principal.cim10_suggestion }}</span>
{{ item.dossier.diagnostic_principal.cim10_confidence | confidence_badge }}
</div>
{% else %}
<div class="card" style="padding:0;overflow:hidden;">
<table class="table-dossiers">
<thead>
<tr>
<th>Patient</th>
<th>DP</th>
<th>DAS</th>
<th>Actes</th>
<th>Sévérité</th>
<th>Alertes</th>
<th>CPAM</th>
</tr>
</thead>
<tbody>
{% for group_name, items in groups.items() %}
{# Sélection du dossier représentatif : fusionné en priorité, sinon premier #}
{% set ns = namespace(rep=none) %}
{% for item in items %}
{% if 'fusionne' in item.name %}
{% set ns.rep = item %}
{% endif %}
{% endfor %}
{% if ns.rep is none %}
{% set ns.rep = items[0] %}
{% endif %}
{% if item.dossier.processing_time_s is not none %}
<div style="margin-top:0.5rem;font-size:0.75rem;color:#64748b;">
Traitement : {{ item.dossier.processing_time_s|format_duration }}
</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% set d = ns.rep.dossier %}
{% set dp = d.diagnostic_principal %}
{% set ghm = d.ghm_estimation %}
{% set gstats = group_stats.get(group_name, {}) %}
<tr class="row-clickable" onclick="window.location='/dossier/{{ ns.rep.path_rel }}'">
<td>
<span style="font-weight:600;color:#0f172a;">{{ group_name | format_dossier_name }}</span>
{% if d.document_type %}
<span class="badge" style="background:#e0e7ff;color:#3730a3;margin-left:0.4rem;">{{ d.document_type }}</span>
{% endif %}
{% if items|length > 1 %}
<span style="font-size:0.7rem;color:#94a3b8;margin-left:0.3rem;">{{ items|length }} docs</span>
{% endif %}
</td>
<td>
{% if dp and dp.cim10_suggestion %}
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-weight:700;">{{ dp.cim10_suggestion }}</span>
{{ dp.cim10_confidence | confidence_badge }}
<div style="font-size:0.75rem;color:#475569;margin-top:0.15rem;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ dp.texte[:60] }}{% if dp.texte|length > 60 %}…{% endif %}
</div>
{% elif dp %}
<span style="font-size:0.8rem;color:#64748b;">{{ dp.texte[:40] }}…</span>
{% else %}
<span style="color:#cbd5e1;"></span>
{% endif %}
</td>
<td>
{% if gstats.das_count is defined and gstats.das_count > 0 %}
<span class="badge-count badge-das">{{ gstats.das_count }}</span>
{% else %}
<span style="color:#cbd5e1;">0</span>
{% endif %}
</td>
<td>
{% if gstats.actes_count is defined and gstats.actes_count > 0 %}
<span class="badge-count badge-actes">{{ gstats.actes_count }}</span>
{% else %}
<span style="color:#cbd5e1;">0</span>
{% endif %}
</td>
<td>
{% if ghm and ghm.severite %}
{{ ghm.severite | string | severity_badge }}
{% else %}
<span style="color:#cbd5e1;"></span>
{% endif %}
</td>
<td>
{% if gstats.alertes_count is defined and gstats.alertes_count > 0 %}
<span class="badge-count badge-alertes">{{ gstats.alertes_count }}</span>
{% else %}
<span style="color:#cbd5e1;">0</span>
{% endif %}
</td>
<td>
{% if d.controles_cpam %}
<span class="badge-count" style="background:#fef3c7;color:#92400e;">{{ d.controles_cpam|length }}</span>
{% else %}
<span style="color:#cbd5e1;"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -1,18 +1,6 @@
{% extends "base.html" %}
{% block title %}Validation — {{ group_name }}{% endblock %}
{% block sidebar %}
{% for gn, items in groups.items() %}
<div class="group-title">{{ gn | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<style>

View File

@@ -1,18 +1,6 @@
{% extends "base.html" %}
{% block title %}Validation DIM{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">

View File

@@ -1,18 +1,6 @@
{% extends "base.html" %}
{% block title %}Métriques Validation DIM{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">