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:
340
src/config.py
340
src/config.py
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .document_router import SUPPORTED_EXTENSIONS, extract_document_with_pages
|
||||
from .pdf_extractor import ExtractionMethod, ExtractionStats
|
||||
|
||||
62
src/extraction/document_router.py
Normal file
62
src/extraction/document_router.py
Normal 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))}"
|
||||
)
|
||||
106
src/extraction/docx_extractor.py
Normal file
106
src/extraction/docx_extractor.py
Normal 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
|
||||
56
src/extraction/image_extractor.py
Normal file
56
src/extraction/image_extractor.py
Normal 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
|
||||
54
src/extraction/ocr_engine.py
Normal file
54
src/extraction/ocr_engine.py
Normal 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)
|
||||
@@ -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 []
|
||||
|
||||
@@ -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:
|
||||
|
||||
141
src/main.py
141
src/main.py
@@ -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é.")
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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é."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
529
src/quality/completude.py
Normal 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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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">← Dashboard</a>
|
||||
<h2 style="margin-top:1rem;">Referentiels & 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';
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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">☰</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">📁 Dossiers</a>
|
||||
<a href="/dashboard" class="sidebar-link">📊 Dashboard</a>
|
||||
<a href="/dim" class="sidebar-link">🏥 Synthèse DIM</a>
|
||||
<a href="/cpam" class="sidebar-link">⚠ Contrôles UCR</a>
|
||||
<a href="/admin/referentiels" class="sidebar-link">📚 Réfé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>
|
||||
|
||||
@@ -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">★ 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="/">← 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 %}
|
||||
|
||||
@@ -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">★ 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="/">← 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
243
src/viewer/templates/dim.html
Normal file
243
src/viewer/templates/dim.html
Normal file
@@ -0,0 +1,243 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Synthese DIM{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a class="back" href="/">← Retour</a>
|
||||
<h2 style="margin-top:1rem;">Synthè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é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é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é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égradé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é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é (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éfendabilité : <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é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é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é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é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ôles CPAM</div>
|
||||
<div style="display:flex;gap:1rem;font-size:0.75rem;margin-bottom:0.5rem;">
|
||||
<span><strong>{{ dim.cpam.total }}</strong> contrôles</span>
|
||||
{% if dim.cpam.impact_total %}
|
||||
<span>Impact estimé : <strong style="color:#dc2626;">{{ "{:,.0f}".format(dim.cpam.impact_total).replace(",", " ") }} €</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 — 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 — À 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étude indé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 %}
|
||||
@@ -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">★ 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 %}
|
||||
|
||||
@@ -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">★ Fusionné</a>
|
||||
{% else %}
|
||||
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
|
||||
@@ -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">★ 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;">
|
||||
|
||||
@@ -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">★ 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;">
|
||||
|
||||
Reference in New Issue
Block a user