feat: méthode TIM experte CPAM + moteur de règles étendu
CPAM — Méthode TIM (mémoire en défense) : - Réécriture CPAM_ARGUMENTATION avec raisonnement 5 passes TIM (contexte admin → motif réel → confrontation bio → hiérarchie → validation défensive) - _BIO_THRESHOLDS (19 entrées) + _build_bio_confrontation() pour confrontation biologie/diagnostic avec seuils chiffrés et verdicts - _format_response() dual format : nouveau TIM (moyens numérotés, tableau bio, codes non défendables, conclusion dispositive) + rétrocompat legacy - CPAM_ADVERSARIAL mis à jour pour vérifier honnêteté intellectuelle - Tests adaptés + 12 nouveaux tests (bio confrontation, format TIM) Moteur de règles : - Nouvelles règles YAML : demographic, diagnostic_conflicts, procedure_diagnosis, temporal, parcours - Bio extraction FAISS (synonymes vectoriels) - Veto engine enrichi (citations, Trackare skip, règles démographiques) - Decision engine : _apply_bio_rules_gen() + matchers analytiques Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
.env.example
16
.env.example
@@ -5,10 +5,24 @@
|
|||||||
|
|
||||||
# === Ollama ===
|
# === Ollama ===
|
||||||
# OLLAMA_URL=http://localhost:11434
|
# OLLAMA_URL=http://localhost:11434
|
||||||
# OLLAMA_MODEL=gemma3:12b
|
# OLLAMA_MODEL=gemma3:27b-cloud
|
||||||
# OLLAMA_TIMEOUT=120
|
# OLLAMA_TIMEOUT=120
|
||||||
# OLLAMA_MAX_PARALLEL=2
|
# OLLAMA_MAX_PARALLEL=2
|
||||||
|
|
||||||
|
# === Modèles par rôle LLM ===
|
||||||
|
# T2A_MODEL_CODING=gemma3:27b-cloud # Codage CIM-10/CCAM, extraction DAS
|
||||||
|
# T2A_MODEL_CPAM=deepseek-v3.2:cloud # CPAM passe 1 + passe 2
|
||||||
|
# T2A_MODEL_VALIDATION=deepseek-v3.2:cloud # Validation adversariale
|
||||||
|
# T2A_MODEL_QC=gemma3:12b # QC batch justifications
|
||||||
|
#
|
||||||
|
# IMPORTANT : T2A_MODEL_CPAM et T2A_MODEL_VALIDATION DOIVENT être différents
|
||||||
|
# en production pour que la validation adversariale soit réellement indépendante.
|
||||||
|
# Si identiques, la validation adversariale est automatiquement dégradée (LOGIC-3).
|
||||||
|
|
||||||
|
# === Sélecteur DP (NUKE-3) ===
|
||||||
|
# T2A_DP_RANKER_LLM=1 # 1/true/yes = LLM tiebreaker actif, 0/false/no = pré-ranker déterministe uniquement
|
||||||
|
# Note : l'ancien nom DP_RANKER_LLM_ENABLED est accepté mais déprécié.
|
||||||
|
|
||||||
# === Modèles IA ===
|
# === Modèles IA ===
|
||||||
# T2A_EMBEDDING_MODEL=dangvantuan/sentence-camembert-large
|
# T2A_EMBEDDING_MODEL=dangvantuan/sentence-camembert-large
|
||||||
# T2A_NER_MODEL=Jean-Baptiste/camembert-ner
|
# T2A_NER_MODEL=Jean-Baptiste/camembert-ner
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
# Règles biologiques (contradiction bio ⇒ ruled_out)
|
# Règles biologiques (contradiction bio ⇒ ruled_out)
|
||||||
# + garde-fou "preuve manquante" (diag d'ionogramme sans valeur extraite ⇒ NEED_INFO)
|
# ------------------------------------------------
|
||||||
#
|
# Ces règles permettent d'écarter un diagnostic (status: ruled_out)
|
||||||
# Objectif: éviter des FAIL "bêtes" quand la biologie contredit clairement un diagnostic,
|
# si les preuves biologiques extraites le contredisent formellement.
|
||||||
# et éviter des PASS "trop optimistes" quand on n'a même pas la valeur biologique.
|
# Elles génèrent aussi des alertes VETO-17 si la preuve est manquante.
|
||||||
#
|
|
||||||
# Hiérarchie des seuils:
|
|
||||||
# - Priorité aux normes du document (ex: [N: 135-145])
|
|
||||||
# - Sinon fallback config/reference_ranges.yaml
|
|
||||||
# - Si âge inconnu/enfant: safe zones conservatrices (reference_ranges.yaml)
|
|
||||||
|
|
||||||
missing_evidence:
|
missing_evidence:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -18,17 +13,78 @@ missing_evidence:
|
|||||||
score_penalty: 2
|
score_penalty: 2
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
|
# --- IONOGRAMME ---
|
||||||
hyponatremia:
|
hyponatremia:
|
||||||
enabled: true
|
enabled: true
|
||||||
codes: ["E87.1"] # hyponatrémie
|
codes: ["E87.1"]
|
||||||
analyte: sodium
|
analyte: sodium
|
||||||
|
threshold_type: low
|
||||||
|
message: "natrémie normale"
|
||||||
|
|
||||||
hyperkalemia:
|
hyperkalemia:
|
||||||
enabled: true
|
enabled: true
|
||||||
codes: ["E87.5"] # hyperkaliémie
|
codes: ["E87.5"]
|
||||||
analyte: potassium
|
analyte: potassium
|
||||||
|
threshold_type: high
|
||||||
|
message: "kaliémie normale"
|
||||||
|
|
||||||
hypokalemia:
|
hypokalemia:
|
||||||
enabled: true
|
enabled: true
|
||||||
codes: ["E87.6"] # hypokaliémie
|
codes: ["E87.6"]
|
||||||
analyte: potassium
|
analyte: potassium
|
||||||
|
threshold_type: low
|
||||||
|
message: "kaliémie normale"
|
||||||
|
|
||||||
|
# --- HÉMATOLOGIE ---
|
||||||
|
anemia:
|
||||||
|
enabled: true
|
||||||
|
codes: ["D50", "D50.0", "D50.1", "D50.8", "D50.9", "D51", "D52", "D53", "D55", "D56", "D57", "D58", "D59", "D60", "D61", "D62", "D63", "D64"]
|
||||||
|
analyte: hemoglobin
|
||||||
|
threshold_type: low
|
||||||
|
message: "taux d'hémoglobine normal"
|
||||||
|
|
||||||
|
thrombopenia:
|
||||||
|
enabled: true
|
||||||
|
codes: ["D69.4", "D69.5", "D69.6"]
|
||||||
|
analyte: platelets
|
||||||
|
threshold_type: low
|
||||||
|
message: "taux de plaquettes normal"
|
||||||
|
|
||||||
|
# --- RÉNAL ---
|
||||||
|
acute_kidney_injury:
|
||||||
|
enabled: true
|
||||||
|
codes: ["N17", "N17.0", "N17.1", "N17.2", "N17.8", "N17.9", "N19"]
|
||||||
|
analyte: creatinine
|
||||||
|
threshold_type: high
|
||||||
|
message: "créatininémie normale"
|
||||||
|
|
||||||
|
# --- MÉTABOLISME ---
|
||||||
|
hypoglycemia:
|
||||||
|
enabled: true
|
||||||
|
codes: ["E16.0", "E16.1", "E16.2"]
|
||||||
|
analyte: glucose
|
||||||
|
threshold_type: low
|
||||||
|
message: "glycémie normale"
|
||||||
|
|
||||||
|
diabetes_uncontrolled:
|
||||||
|
enabled: true
|
||||||
|
codes: ["E10.1", "E11.1"] # Avec complications aiguës (acidocétose, etc.)
|
||||||
|
analyte: hba1c
|
||||||
|
threshold_type: high
|
||||||
|
threshold_value: 9.0
|
||||||
|
message: "HbA1c < 9% (diabète non considéré comme déséquilibré selon HbA1c)"
|
||||||
|
|
||||||
|
# --- THYROÏDE ---
|
||||||
|
hypothyroidism:
|
||||||
|
enabled: true
|
||||||
|
codes: ["E02", "E03", "E03.0", "E03.1", "E03.2", "E03.8", "E03.9"]
|
||||||
|
analyte: tsh
|
||||||
|
threshold_type: high # TSH élevée = Hypo
|
||||||
|
message: "TSH normale ou basse"
|
||||||
|
|
||||||
|
hyperthyroidism:
|
||||||
|
enabled: true
|
||||||
|
codes: ["E05", "E05.0", "E05.1", "E05.2", "E05.8", "E05.9"]
|
||||||
|
analyte: tsh
|
||||||
|
threshold_type: low # TSH basse = Hyper
|
||||||
|
message: "TSH normale ou élevée"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
pdfplumber>=0.10.0
|
pdfplumber>=0.10.0
|
||||||
transformers>=4.35.0,<5.0.0
|
transformers>=4.35.0,<6.0.0
|
||||||
torch>=2.1.0
|
torch>=2.1.0
|
||||||
protobuf>=3.20.0,<4.0.0
|
protobuf>=3.20.0,<7.0.0
|
||||||
regex>=2023.0
|
regex>=2023.0
|
||||||
pydantic>=2.5.0
|
pydantic>=2.5.0
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
sentencepiece>=0.1.99,<0.2.0
|
sentencepiece>=0.1.99,<0.3.0
|
||||||
edsnlp[ml]>=0.17.0
|
edsnlp[ml]>=0.17.0
|
||||||
faiss-cpu>=1.7.0
|
faiss-cpu>=1.7.0
|
||||||
sentence-transformers>=2.2.0
|
sentence-transformers>=2.2.0
|
||||||
|
|||||||
125
src/config.py
125
src/config.py
@@ -28,6 +28,11 @@ CONFIG_DIR = BASE_DIR / "config"
|
|||||||
REFERENCE_RANGES_PATH = CONFIG_DIR / "reference_ranges.yaml"
|
REFERENCE_RANGES_PATH = CONFIG_DIR / "reference_ranges.yaml"
|
||||||
BIO_RULES_PATH = CONFIG_DIR / "bio_rules.yaml"
|
BIO_RULES_PATH = CONFIG_DIR / "bio_rules.yaml"
|
||||||
LAB_SANITY_PATH = CONFIG_DIR / "lab_value_sanity.yaml"
|
LAB_SANITY_PATH = CONFIG_DIR / "lab_value_sanity.yaml"
|
||||||
|
DEMOGRAPHIC_RULES_PATH = CONFIG_DIR / "demographic_rules.yaml"
|
||||||
|
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"
|
||||||
RULES_DIR = CONFIG_DIR / "rules"
|
RULES_DIR = CONFIG_DIR / "rules"
|
||||||
RULES_BASE_PATH = RULES_DIR / "base.yaml"
|
RULES_BASE_PATH = RULES_DIR / "base.yaml"
|
||||||
RULES_ENABLED_PATH = RULES_DIR / "enabled.yaml"
|
RULES_ENABLED_PATH = RULES_DIR / "enabled.yaml"
|
||||||
@@ -128,6 +133,7 @@ UPLOAD_MAX_SIZE_MB = 50
|
|||||||
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
|
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
|
||||||
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
|
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
|
||||||
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json"
|
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json"
|
||||||
|
BIO_CONCEPTS_PATH = BASE_DIR / "data" / "bio_concepts.json"
|
||||||
CMA_LEVELS_PATH = BASE_DIR / "data" / "cma_levels.json"
|
CMA_LEVELS_PATH = BASE_DIR / "data" / "cma_levels.json"
|
||||||
CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.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"))
|
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"))
|
||||||
@@ -247,6 +253,124 @@ def load_bio_rules() -> Dict[str, Any]:
|
|||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
# --- Garde-fous de parsing des valeurs biologiques (anti-OCR) ---
|
# --- Garde-fous de parsing des valeurs biologiques (anti-OCR) ---
|
||||||
|
|
||||||
|
|
||||||
@@ -827,6 +951,7 @@ class VetoIssue(BaseModel):
|
|||||||
severity: str # HARD | MEDIUM | LOW
|
severity: str # HARD | MEDIUM | LOW
|
||||||
where: str
|
where: str
|
||||||
message: str
|
message: str
|
||||||
|
citation: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class VetoReport(BaseModel):
|
class VetoReport(BaseModel):
|
||||||
|
|||||||
@@ -195,6 +195,118 @@ def _build_tagged_context(dossier: DossierMedical) -> tuple[str, dict[str, str]]
|
|||||||
return text, tag_map
|
return text, tag_map
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seuils biologiques par code CIM-10 (Table 8 du référentiel TIM)
|
||||||
|
# Utilisé pour la confrontation biologie/diagnostic dans le mémoire de défense.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_BIO_THRESHOLDS: dict[str, dict] = {
|
||||||
|
"D50": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)", "also": ["Ferritine < 30"]},
|
||||||
|
"D62": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)"},
|
||||||
|
"D64": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)"},
|
||||||
|
"D69.6": {"test": "Plaquettes", "condition": "< 150 G/L"},
|
||||||
|
"E03": {"test": "TSH", "condition": "> 4 mUI/L"},
|
||||||
|
"E05": {"test": "TSH", "condition": "< 0.4 mUI/L"},
|
||||||
|
"E10": {"test": "Glycémie", "condition": "> 7 mmol/L ou HbA1c > 6.5%"},
|
||||||
|
"E11": {"test": "Glycémie", "condition": "> 7 mmol/L ou HbA1c > 6.5%"},
|
||||||
|
"E87.1": {"test": "Sodium", "condition": "< 135 mmol/L"},
|
||||||
|
"E87.0": {"test": "Sodium", "condition": "> 145 mmol/L"},
|
||||||
|
"E87.5": {"test": "Potassium", "condition": "> 5.0 mmol/L"},
|
||||||
|
"E87.6": {"test": "Potassium", "condition": "< 3.5 mmol/L"},
|
||||||
|
"K72": {"test": "ASAT", "condition": "> 120 UI/L (3x norme)", "also": ["ALAT > 120"]},
|
||||||
|
"K85": {"test": "Lipasémie", "condition": "> 180 UI/L (3x norme)"},
|
||||||
|
"N17": {"test": "Créatinine", "condition": "> 130 (H) / > 110 (F)", "note": "avec élévation aiguë"},
|
||||||
|
"N18": {"test": "Créatinine", "condition": "DFG calculé"},
|
||||||
|
"I50": {"test": "BNP", "condition": "> 100 pg/mL ou NT-proBNP > 300"},
|
||||||
|
"I21": {"test": "Troponine", "condition": "> 0.04 ng/mL"},
|
||||||
|
"I26": {"test": "D-dimères", "condition": "> 500 ng/mL"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_bio_confrontation(dossier: DossierMedical, controle: ControleCPAM) -> str:
|
||||||
|
"""Construit le tableau de confrontation biologie/diagnostic pour les codes contestés.
|
||||||
|
|
||||||
|
Utilise _BIO_THRESHOLDS (Table 8 TIM) et les valeurs de dossier.biologie_cle
|
||||||
|
pour produire un verdict par diagnostic : CONFIRMÉ / NON CONFIRMÉ / NON DISPONIBLE.
|
||||||
|
"""
|
||||||
|
# Collecter tous les codes en jeu (3 premiers chars pour match préfixe)
|
||||||
|
codes_in_play: list[str] = []
|
||||||
|
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
|
||||||
|
codes_in_play.append(normalize_code(dossier.diagnostic_principal.cim10_suggestion))
|
||||||
|
for das in dossier.diagnostics_associes:
|
||||||
|
if das.cim10_suggestion:
|
||||||
|
codes_in_play.append(normalize_code(das.cim10_suggestion))
|
||||||
|
for field in (controle.dp_ucr, controle.da_ucr, controle.dr_ucr):
|
||||||
|
if not field:
|
||||||
|
continue
|
||||||
|
for raw in re.split(r"[,;\s]+", field.strip()):
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw:
|
||||||
|
codes_in_play.append(normalize_code(raw))
|
||||||
|
|
||||||
|
if not codes_in_play:
|
||||||
|
return "(Aucun code en jeu pour la confrontation biologique)"
|
||||||
|
|
||||||
|
# Indexer les valeurs bio disponibles
|
||||||
|
bio_values: dict[str, float] = {}
|
||||||
|
for b in dossier.biologie_cle:
|
||||||
|
if b.test and b.valeur:
|
||||||
|
try:
|
||||||
|
bio_values[b.test] = float(b.valeur.replace(",", ".").split()[0])
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
matched_any = False
|
||||||
|
for code in codes_in_play:
|
||||||
|
prefix = code[:3] if len(code) >= 3 else code
|
||||||
|
threshold = _BIO_THRESHOLDS.get(prefix)
|
||||||
|
if not threshold:
|
||||||
|
# Essayer avec code complet (ex: E87.1)
|
||||||
|
threshold = _BIO_THRESHOLDS.get(code[:5] if len(code) >= 5 else code)
|
||||||
|
if not threshold:
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched_any = True
|
||||||
|
test_name = threshold["test"]
|
||||||
|
condition = threshold["condition"]
|
||||||
|
is_valid, label = validate_code(code)
|
||||||
|
label_str = f" ({label})" if is_valid and label else ""
|
||||||
|
|
||||||
|
if test_name in bio_values:
|
||||||
|
val = bio_values[test_name]
|
||||||
|
# Vérifier si la valeur est dans les normes
|
||||||
|
if test_name in BIO_NORMALS:
|
||||||
|
lo, hi = BIO_NORMALS[test_name]
|
||||||
|
is_normal = lo <= val <= hi
|
||||||
|
verdict = "NON CONFIRMÉ (valeur NORMALE)" if is_normal else "CONFIRMÉ"
|
||||||
|
else:
|
||||||
|
verdict = "À VÉRIFIER"
|
||||||
|
lines.append(
|
||||||
|
f" {code}{label_str} : {test_name} requis {condition} → "
|
||||||
|
f"valeur dossier = {val} → {verdict}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f" {code}{label_str} : {test_name} requis {condition} → "
|
||||||
|
f"NON DISPONIBLE dans le dossier"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tests complémentaires (also)
|
||||||
|
for also_test in threshold.get("also", []):
|
||||||
|
parts = also_test.split()
|
||||||
|
if len(parts) >= 1:
|
||||||
|
also_name = parts[0]
|
||||||
|
if also_name in bio_values:
|
||||||
|
lines.append(f" + {also_test} → valeur dossier = {bio_values[also_name]}")
|
||||||
|
else:
|
||||||
|
lines.append(f" + {also_test} → NON DISPONIBLE")
|
||||||
|
|
||||||
|
if not matched_any:
|
||||||
|
return "(Aucun seuil biologique applicable aux codes en jeu)"
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# Interprétations cliniques pour le résumé bio déterministe
|
# Interprétations cliniques pour le résumé bio déterministe
|
||||||
_BIO_INTERPRETATION: dict[str, dict[str, str]] = {
|
_BIO_INTERPRETATION: dict[str, dict[str, str]] = {
|
||||||
# --- Hépatique / digestif ---
|
# --- Hépatique / digestif ---
|
||||||
@@ -648,6 +760,9 @@ def _build_cpam_prompt(
|
|||||||
"le manque de données au lieu d'inventer des preuves."
|
"le manque de données au lieu d'inventer des preuves."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Confrontation biologie / diagnostic (méthode TIM)
|
||||||
|
bio_confrontation = _build_bio_confrontation(dossier, controle)
|
||||||
|
|
||||||
# Résumé biologique déterministe (interprétations non modifiables par le LLM)
|
# Résumé biologique déterministe (interprétations non modifiables par le LLM)
|
||||||
bio_summary = _build_bio_summary(dossier)
|
bio_summary = _build_bio_summary(dossier)
|
||||||
if bio_summary:
|
if bio_summary:
|
||||||
@@ -736,5 +851,7 @@ def _build_cpam_prompt(
|
|||||||
codes_autorises_str=codes_autorises_str,
|
codes_autorises_str=codes_autorises_str,
|
||||||
sources_text=sources_text,
|
sources_text=sources_text,
|
||||||
extraction_str=extraction_str,
|
extraction_str=extraction_str,
|
||||||
|
bio_confrontation_str=bio_confrontation,
|
||||||
|
numero_ogc=controle.numero_ogc,
|
||||||
)
|
)
|
||||||
return prompt, tag_map
|
return prompt, tag_map
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ from .cpam_context import ( # noqa: F401
|
|||||||
_get_code_label,
|
_get_code_label,
|
||||||
_get_cim10_definitions,
|
_get_cim10_definitions,
|
||||||
_BIO_INTERPRETATION,
|
_BIO_INTERPRETATION,
|
||||||
|
_BIO_THRESHOLDS,
|
||||||
_assess_dossier_strength,
|
_assess_dossier_strength,
|
||||||
_build_bio_summary,
|
_build_bio_summary,
|
||||||
|
_build_bio_confrontation,
|
||||||
_check_das_bio_coherence,
|
_check_das_bio_coherence,
|
||||||
)
|
)
|
||||||
from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial, _assess_quality_tier as _assess_quality_tier, _fuzzy_match_ref as _fuzzy_match_ref, _sanitize_unauthorized_codes as _sanitize_unauthorized_codes # noqa: F401
|
from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial, _assess_quality_tier as _assess_quality_tier, _fuzzy_match_ref as _fuzzy_match_ref, _sanitize_unauthorized_codes as _sanitize_unauthorized_codes # noqa: F401
|
||||||
@@ -120,6 +122,11 @@ def generate_cpam_response(
|
|||||||
|
|
||||||
# 1. Passe 1 — Extraction structurée (compréhension avant argumentation)
|
# 1. Passe 1 — Extraction structurée (compréhension avant argumentation)
|
||||||
extraction = _extraction_pass(dossier, controle)
|
extraction = _extraction_pass(dossier, controle)
|
||||||
|
degraded_pass1 = extraction is None
|
||||||
|
if degraded_pass1:
|
||||||
|
dossier.alertes_codage.append(
|
||||||
|
"CPAM: passe 1 (extraction structurée) échouée → mode dégradé"
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Recherche RAG ciblée
|
# 2. Recherche RAG ciblée
|
||||||
sources = _search_rag_for_control(controle, dossier)
|
sources = _search_rag_for_control(controle, dossier)
|
||||||
@@ -153,6 +160,12 @@ def generate_cpam_response(
|
|||||||
logger.warning(" LLM non disponible — contre-argumentation non générée")
|
logger.warning(" LLM non disponible — contre-argumentation non générée")
|
||||||
return "", None, rag_sources
|
return "", None, rag_sources
|
||||||
|
|
||||||
|
# 5b. LOGIC-2 — Marquer le mode dégradé dans le résultat
|
||||||
|
if degraded_pass1:
|
||||||
|
result.setdefault("quality_flags", {})
|
||||||
|
result["quality_flags"]["cpam_pass1_failed"] = True
|
||||||
|
result["quality_flags"]["degraded_mode"] = True
|
||||||
|
|
||||||
# 6. Sanitisation déterministe — supprime les codes CIM-10 hors périmètre
|
# 6. Sanitisation déterministe — supprime les codes CIM-10 hors périmètre
|
||||||
sanitized = _sanitize_unauthorized_codes(result, dossier, controle)
|
sanitized = _sanitize_unauthorized_codes(result, dossier, controle)
|
||||||
if sanitized:
|
if sanitized:
|
||||||
@@ -175,6 +188,16 @@ def generate_cpam_response(
|
|||||||
logger.warning(" CPAM : %d code(s) hors périmètre", len(code_warnings))
|
logger.warning(" CPAM : %d code(s) hors périmètre", len(code_warnings))
|
||||||
|
|
||||||
# 8. Validation adversariale (cohérence factuelle)
|
# 8. Validation adversariale (cohérence factuelle)
|
||||||
|
# LOGIC-3 : détecter si modèles identiques AVANT l'appel
|
||||||
|
from ..config import check_adversarial_model_config
|
||||||
|
same_model, model_msg = check_adversarial_model_config()
|
||||||
|
if same_model:
|
||||||
|
result.setdefault("quality_flags", {})
|
||||||
|
result["quality_flags"]["adversarial_disabled_same_model"] = True
|
||||||
|
dossier.alertes_codage.append(
|
||||||
|
"Validation adversariale désactivée (modèles identiques)"
|
||||||
|
)
|
||||||
|
|
||||||
adversarial_warnings: list[str] = []
|
adversarial_warnings: list[str] = []
|
||||||
validation = _validate_adversarial(result, tag_map, controle)
|
validation = _validate_adversarial(result, tag_map, controle)
|
||||||
if validation and not validation.get("coherent", True):
|
if validation and not validation.get("coherent", True):
|
||||||
@@ -186,48 +209,51 @@ def generate_cpam_response(
|
|||||||
if adversarial_warnings:
|
if adversarial_warnings:
|
||||||
adversarial_warnings.append(f"Score de confiance : {score}/10")
|
adversarial_warnings.append(f"Score de confiance : {score}/10")
|
||||||
|
|
||||||
# 8b. Boucle de correction (max 1 retry)
|
# 8b. Boucle de correction (max 2 retries)
|
||||||
if (validation
|
max_corrections = 2
|
||||||
and not validation.get("coherent", True)
|
for attempt in range(max_corrections):
|
||||||
and validation.get("score_confiance", 10) <= 5
|
if not (validation
|
||||||
and rule_enabled("RULE-CPAM-CORRECTION-LOOP")):
|
and not validation.get("coherent", True)
|
||||||
|
and validation.get("score_confiance", 10) <= 5
|
||||||
|
and rule_enabled("RULE-CPAM-CORRECTION-LOOP")):
|
||||||
|
break
|
||||||
|
|
||||||
erreurs_v = validation.get("erreurs", [])
|
erreurs_v = validation.get("erreurs", [])
|
||||||
logger.warning(" Score adversarial %s/10 — correction en cours (%d erreur(s))",
|
logger.warning(" Score adversarial %s/10 — correction %d/%d (%d erreur(s))",
|
||||||
validation.get("score_confiance"), len(erreurs_v))
|
validation.get("score_confiance"), attempt + 1, max_corrections, len(erreurs_v))
|
||||||
|
|
||||||
correction_prompt = _build_correction_prompt(prompt, result, validation)
|
correction_prompt = _build_correction_prompt(prompt, result, validation)
|
||||||
corrected = call_ollama(correction_prompt, temperature=0.0, max_tokens=16000, role="cpam")
|
corrected = call_ollama(correction_prompt, temperature=0.0, max_tokens=16000, role="cpam")
|
||||||
if corrected is None:
|
if corrected is None:
|
||||||
corrected = call_anthropic(correction_prompt, temperature=0.0, max_tokens=16000)
|
corrected = call_anthropic(correction_prompt, temperature=0.0, max_tokens=16000)
|
||||||
|
|
||||||
if corrected:
|
if not corrected:
|
||||||
# Re-valider la correction
|
break
|
||||||
validation2 = _validate_adversarial(corrected, tag_map, controle)
|
|
||||||
score2 = validation2.get("score_confiance", 0) if validation2 else 0
|
|
||||||
score1 = validation.get("score_confiance", 0)
|
|
||||||
|
|
||||||
if score2 > score1:
|
validation2 = _validate_adversarial(corrected, tag_map, controle)
|
||||||
logger.info(" Correction acceptée (score %s → %s)", score1, score2)
|
score2 = validation2.get("score_confiance", 0) if validation2 else 0
|
||||||
result = corrected
|
score1 = validation.get("score_confiance", 0)
|
||||||
validation = validation2
|
|
||||||
# Sanitiser + recalculer les warnings
|
if score2 > score1:
|
||||||
_sanitize_unauthorized_codes(result, dossier, controle)
|
logger.info(" Correction %d acceptée (score %s → %s)", attempt + 1, score1, score2)
|
||||||
ref_warnings = _validate_references(result, sources)
|
result = corrected
|
||||||
grounding_warnings = _validate_grounding(result, tag_map)
|
validation = validation2
|
||||||
code_warnings = _validate_codes_in_response(result, dossier, controle)
|
_sanitize_unauthorized_codes(result, dossier, controle)
|
||||||
adversarial_warnings = []
|
ref_warnings = _validate_references(result, sources)
|
||||||
if validation and not validation.get("coherent", True):
|
grounding_warnings = _validate_grounding(result, tag_map)
|
||||||
for e in validation.get("erreurs", []):
|
code_warnings = _validate_codes_in_response(result, dossier, controle)
|
||||||
if isinstance(e, str) and e.strip():
|
adversarial_warnings = []
|
||||||
adversarial_warnings.append(f"Incohérence détectée : {e}")
|
if validation and not validation.get("coherent", True):
|
||||||
if adversarial_warnings:
|
for e in validation.get("erreurs", []):
|
||||||
adversarial_warnings.append(
|
if isinstance(e, str) and e.strip():
|
||||||
f"Score de confiance : {validation.get('score_confiance', '?')}/10"
|
adversarial_warnings.append(f"Incohérence détectée : {e}")
|
||||||
)
|
if adversarial_warnings:
|
||||||
else:
|
adversarial_warnings.append(
|
||||||
logger.warning(" Correction rejetée (score %s → %s) — conserve l'original",
|
f"Score de confiance : {validation.get('score_confiance', '?')}/10"
|
||||||
score1, score2)
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(" Correction %d rejetée (score %s → %s)", attempt + 1, score1, score2)
|
||||||
|
break
|
||||||
|
|
||||||
all_warnings = ref_warnings + grounding_warnings + code_warnings + adversarial_warnings
|
all_warnings = ref_warnings + grounding_warnings + code_warnings + adversarial_warnings
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ def _fuzzy_match_ref(ref: str, tag_map: dict[str, str]) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[str]:
|
def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[str]:
|
||||||
"""Vérifie que les références dans preuves_dossier correspondent à des tags existants.
|
"""Vérifie que les références dans preuves correspondent à des tags existants.
|
||||||
|
|
||||||
|
Supporte les deux formats :
|
||||||
|
- Ancien : response_data["preuves_dossier"][].ref
|
||||||
|
- Nouveau TIM : response_data["moyens_defense"][].preuves[].ref
|
||||||
|
|
||||||
Applique un fuzzy matching par code CIM-10 avant de flaguer un warning.
|
Applique un fuzzy matching par code CIM-10 avant de flaguer un warning.
|
||||||
|
|
||||||
@@ -44,24 +48,40 @@ def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[st
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
preuves = response_data.get("preuves_dossier")
|
|
||||||
if not preuves or not isinstance(preuves, list):
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
for p in preuves:
|
def _check_ref(ref: str, context: str) -> None:
|
||||||
if not isinstance(p, dict):
|
|
||||||
continue
|
|
||||||
ref = p.get("ref", "")
|
|
||||||
if not ref:
|
if not ref:
|
||||||
continue
|
return
|
||||||
if ref not in tag_map:
|
# Nettoyer les crochets si présents (nouveau format utilise "[BIO-1]")
|
||||||
matched_tag = _fuzzy_match_ref(ref, tag_map)
|
clean_ref = ref.strip("[]")
|
||||||
if matched_tag:
|
if clean_ref in tag_map or ref in tag_map:
|
||||||
logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag)
|
return
|
||||||
continue # pas de warning
|
matched_tag = _fuzzy_match_ref(clean_ref, tag_map)
|
||||||
valeur = p.get("valeur", "?")
|
if matched_tag:
|
||||||
warnings.append(f"Preuve [{ref}] non traçable (« {valeur} »)")
|
logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag)
|
||||||
logger.warning("Grounding : preuve [%s] introuvable dans les tags du dossier", ref)
|
return
|
||||||
|
warnings.append(f"Preuve [{ref}] non traçable (« {context} »)")
|
||||||
|
logger.warning("Grounding : preuve [%s] introuvable dans les tags du dossier", ref)
|
||||||
|
|
||||||
|
# Ancien format : preuves_dossier
|
||||||
|
preuves = response_data.get("preuves_dossier")
|
||||||
|
if preuves and isinstance(preuves, list):
|
||||||
|
for p in preuves:
|
||||||
|
if isinstance(p, dict):
|
||||||
|
_check_ref(p.get("ref", ""), p.get("valeur", "?"))
|
||||||
|
|
||||||
|
# Nouveau format TIM : moyens_defense[].preuves
|
||||||
|
moyens = response_data.get("moyens_defense")
|
||||||
|
if moyens and isinstance(moyens, list):
|
||||||
|
for moyen in moyens:
|
||||||
|
if not isinstance(moyen, dict):
|
||||||
|
continue
|
||||||
|
moyen_preuves = moyen.get("preuves")
|
||||||
|
if not moyen_preuves or not isinstance(moyen_preuves, list):
|
||||||
|
continue
|
||||||
|
for p in moyen_preuves:
|
||||||
|
if isinstance(p, dict):
|
||||||
|
_check_ref(p.get("ref", ""), p.get("fait", "?"))
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
@@ -111,12 +131,19 @@ def _validate_references(parsed: dict, sources: list[dict]) -> list[str]:
|
|||||||
_CIM10_CODE_RE = re.compile(r"\b([A-Z]\d{2}\.?\d{0,2})\b")
|
_CIM10_CODE_RE = re.compile(r"\b([A-Z]\d{2}\.?\d{0,2})\b")
|
||||||
|
|
||||||
# Champs textuels de la réponse LLM à scanner pour les codes CIM-10
|
# Champs textuels de la réponse LLM à scanner pour les codes CIM-10
|
||||||
|
# Supporte les deux formats : ancien (contre_arguments_*) et nouveau (moyens_defense TIM)
|
||||||
_TEXT_FIELDS = (
|
_TEXT_FIELDS = (
|
||||||
|
# Ancien format
|
||||||
"analyse_contestation",
|
"analyse_contestation",
|
||||||
"contre_arguments_medicaux",
|
"contre_arguments_medicaux",
|
||||||
"contre_arguments_asymetrie",
|
"contre_arguments_asymetrie",
|
||||||
"contre_arguments_reglementaires",
|
"contre_arguments_reglementaires",
|
||||||
"conclusion",
|
"conclusion",
|
||||||
|
# Nouveau format TIM
|
||||||
|
"rappel_faits",
|
||||||
|
"asymetrie_information",
|
||||||
|
"reponse_points_cpam",
|
||||||
|
"conclusion_dispositive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -220,7 +247,7 @@ def _sanitize_unauthorized_codes(
|
|||||||
if new_val != val:
|
if new_val != val:
|
||||||
parsed[key] = new_val
|
parsed[key] = new_val
|
||||||
|
|
||||||
# Sanitiser aussi les preuves_dossier.valeur
|
# Sanitiser aussi les preuves_dossier.valeur (ancien format)
|
||||||
preuves = parsed.get("preuves_dossier")
|
preuves = parsed.get("preuves_dossier")
|
||||||
if preuves and isinstance(preuves, list):
|
if preuves and isinstance(preuves, list):
|
||||||
for p in preuves:
|
for p in preuves:
|
||||||
@@ -240,6 +267,27 @@ def _sanitize_unauthorized_codes(
|
|||||||
if new_v != v:
|
if new_v != v:
|
||||||
p["valeur"] = new_v
|
p["valeur"] = new_v
|
||||||
|
|
||||||
|
# Sanitiser les moyens_defense[].argument (nouveau format TIM)
|
||||||
|
moyens = parsed.get("moyens_defense")
|
||||||
|
if moyens and isinstance(moyens, list):
|
||||||
|
for moyen in moyens:
|
||||||
|
if not isinstance(moyen, dict):
|
||||||
|
continue
|
||||||
|
for field_key in ("argument", "titre"):
|
||||||
|
val = moyen.get(field_key, "")
|
||||||
|
if not val or not isinstance(val, str):
|
||||||
|
continue
|
||||||
|
new_val = val
|
||||||
|
for pattern in _SANITIZE_PATTERNS:
|
||||||
|
new_val = pattern.sub(
|
||||||
|
lambda m, _p=pattern: _replace_code(m),
|
||||||
|
new_val,
|
||||||
|
)
|
||||||
|
new_val = re.sub(r"\(\s*\)", "", new_val)
|
||||||
|
new_val = re.sub(r" +", " ", new_val).strip()
|
||||||
|
if new_val != val:
|
||||||
|
moyen[field_key] = new_val
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
for code in removed:
|
for code in removed:
|
||||||
norm = normalize_code(code)
|
norm = normalize_code(code)
|
||||||
@@ -275,7 +323,7 @@ def _validate_codes_in_response(
|
|||||||
if val and isinstance(val, str):
|
if val and isinstance(val, str):
|
||||||
text_fields.append(val)
|
text_fields.append(val)
|
||||||
|
|
||||||
# Preuves du dossier — valeurs
|
# Preuves du dossier — valeurs (ancien format)
|
||||||
preuves = parsed.get("preuves_dossier")
|
preuves = parsed.get("preuves_dossier")
|
||||||
if preuves and isinstance(preuves, list):
|
if preuves and isinstance(preuves, list):
|
||||||
for p in preuves:
|
for p in preuves:
|
||||||
@@ -284,6 +332,16 @@ def _validate_codes_in_response(
|
|||||||
if v and isinstance(v, str):
|
if v and isinstance(v, str):
|
||||||
text_fields.append(v)
|
text_fields.append(v)
|
||||||
|
|
||||||
|
# Moyens de défense (nouveau format TIM)
|
||||||
|
moyens = parsed.get("moyens_defense")
|
||||||
|
if moyens and isinstance(moyens, list):
|
||||||
|
for moyen in moyens:
|
||||||
|
if isinstance(moyen, dict):
|
||||||
|
for mkey in ("argument", "titre"):
|
||||||
|
v = moyen.get(mkey, "")
|
||||||
|
if v and isinstance(v, str):
|
||||||
|
text_fields.append(v)
|
||||||
|
|
||||||
combined_text = "\n".join(text_fields)
|
combined_text = "\n".join(text_fields)
|
||||||
found_codes = _CIM10_CODE_RE.findall(combined_text)
|
found_codes = _CIM10_CODE_RE.findall(combined_text)
|
||||||
|
|
||||||
@@ -330,6 +388,18 @@ def _validate_adversarial(
|
|||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
|
# LOGIC-3 — Vérifier si les modèles CPAM et validation sont identiques
|
||||||
|
from ..config import check_adversarial_model_config
|
||||||
|
|
||||||
|
same_model, model_msg = check_adversarial_model_config()
|
||||||
|
if same_model:
|
||||||
|
logger.warning("LOGIC-3: %s", model_msg)
|
||||||
|
return {
|
||||||
|
"coherent": True,
|
||||||
|
"erreurs": [f"Validation adversariale dégradée : {model_msg}"],
|
||||||
|
"score_confiance": 0,
|
||||||
|
}
|
||||||
|
|
||||||
# Construire le résumé des éléments factuels disponibles
|
# Construire le résumé des éléments factuels disponibles
|
||||||
if tag_map:
|
if tag_map:
|
||||||
factual_lines = "\n".join(f" [{tag}] {content}" for tag, content in tag_map.items())
|
factual_lines = "\n".join(f" [{tag}] {content}" for tag, content in tag_map.items())
|
||||||
@@ -341,8 +411,8 @@ def _validate_adversarial(
|
|||||||
try:
|
try:
|
||||||
response_json = _json.dumps(response_data, ensure_ascii=False, indent=None)
|
response_json = _json.dumps(response_data, ensure_ascii=False, indent=None)
|
||||||
# Tronquer si trop long pour le prompt de validation
|
# Tronquer si trop long pour le prompt de validation
|
||||||
if len(response_json) > 3000:
|
if len(response_json) > 10000:
|
||||||
response_json = response_json[:3000] + "..."
|
response_json = response_json[:10000] + "..."
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logger.warning("Validation adversariale : impossible de sérialiser la réponse")
|
logger.warning("Validation adversariale : impossible de sérialiser la réponse")
|
||||||
return None
|
return None
|
||||||
@@ -365,9 +435,9 @@ def _validate_adversarial(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(" Validation adversariale")
|
logger.debug(" Validation adversariale")
|
||||||
result = call_ollama(prompt, temperature=0.0, max_tokens=3000, role="validation")
|
result = call_ollama(prompt, temperature=0.0, max_tokens=6000, role="validation")
|
||||||
if result is None:
|
if result is None:
|
||||||
result = call_anthropic(prompt, temperature=0.0, max_tokens=3000)
|
result = call_anthropic(prompt, temperature=0.0, max_tokens=6000)
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.warning(" Validation adversariale échouée — LLM indisponible")
|
logger.warning(" Validation adversariale échouée — LLM indisponible")
|
||||||
return None
|
return None
|
||||||
@@ -407,14 +477,20 @@ def _build_correction_prompt(
|
|||||||
erreurs = adversarial_result.get("erreurs", [])
|
erreurs = adversarial_result.get("erreurs", [])
|
||||||
erreurs_text = "\n".join(f" {i}. {e}" for i, e in enumerate(erreurs, 1))
|
erreurs_text = "\n".join(f" {i}. {e}" for i, e in enumerate(erreurs, 1))
|
||||||
|
|
||||||
# Résumé compact de la réponse problématique
|
# Résumé compact de la réponse problématique (supporte les deux formats)
|
||||||
summary_fields = {}
|
summary_fields = {}
|
||||||
|
# Ancien format
|
||||||
for key in ("analyse_contestation", "contre_arguments_medicaux",
|
for key in ("analyse_contestation", "contre_arguments_medicaux",
|
||||||
"contre_arguments_asymetrie", "contre_arguments_reglementaires",
|
"contre_arguments_asymetrie", "contre_arguments_reglementaires",
|
||||||
"conclusion"):
|
"conclusion"):
|
||||||
val = original_response.get(key)
|
val = original_response.get(key)
|
||||||
if val and isinstance(val, str):
|
if val and isinstance(val, str):
|
||||||
# Tronquer chaque champ à 400 chars
|
summary_fields[key] = val[:400] + ("..." if len(val) > 400 else "")
|
||||||
|
# Nouveau format TIM
|
||||||
|
for key in ("rappel_faits", "asymetrie_information", "reponse_points_cpam",
|
||||||
|
"conclusion_dispositive"):
|
||||||
|
val = original_response.get(key)
|
||||||
|
if val and isinstance(val, str):
|
||||||
summary_fields[key] = val[:400] + ("..." if len(val) > 400 else "")
|
summary_fields[key] = val[:400] + ("..." if len(val) > 400 else "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -522,13 +598,182 @@ def _assess_quality_tier(
|
|||||||
return tier, requires_review, categorized
|
return tier, requires_review, categorized
|
||||||
|
|
||||||
|
|
||||||
|
def _is_new_tim_format(parsed: dict) -> bool:
|
||||||
|
"""Détecte si la réponse LLM utilise le nouveau format TIM (moyens_defense)."""
|
||||||
|
return "moyens_defense" in parsed
|
||||||
|
|
||||||
|
|
||||||
def _format_response(
|
def _format_response(
|
||||||
parsed: dict,
|
parsed: dict,
|
||||||
ref_warnings: list[str] | None = None,
|
ref_warnings: list[str] | None = None,
|
||||||
quality_tier: str | None = None,
|
quality_tier: str | None = None,
|
||||||
categorized_warnings: list[str] | None = None,
|
categorized_warnings: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Formate la réponse LLM en texte lisible."""
|
"""Formate la réponse LLM en texte lisible.
|
||||||
|
|
||||||
|
Supporte deux formats via duck-typing :
|
||||||
|
- Nouveau TIM : moyens_defense, confrontation_bio, conclusion_dispositive
|
||||||
|
- Ancien : contre_arguments_medicaux, points_accord, conclusion
|
||||||
|
"""
|
||||||
|
if _is_new_tim_format(parsed):
|
||||||
|
return _format_response_tim(parsed, ref_warnings, quality_tier, categorized_warnings)
|
||||||
|
return _format_response_legacy(parsed, ref_warnings, quality_tier, categorized_warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_response_tim(
|
||||||
|
parsed: dict,
|
||||||
|
ref_warnings: list[str] | None = None,
|
||||||
|
quality_tier: str | None = None,
|
||||||
|
categorized_warnings: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Formate la réponse LLM au format mémoire en défense TIM."""
|
||||||
|
sections: list[str] = []
|
||||||
|
sep = "───────────────────────────────────────────────────────"
|
||||||
|
sep_heavy = "═══════════════════════════════════════════════════════"
|
||||||
|
|
||||||
|
# En-tête
|
||||||
|
objet = parsed.get("objet", "Mémoire en défense")
|
||||||
|
sections.append(f"{sep_heavy}\nMÉMOIRE EN DÉFENSE — {objet}\n{sep_heavy}")
|
||||||
|
|
||||||
|
# Bandeau qualité si tier C
|
||||||
|
if quality_tier == "C":
|
||||||
|
sections.append("⚠ REVUE MANUELLE REQUISE (Qualité : C)")
|
||||||
|
|
||||||
|
# Rappel des faits
|
||||||
|
rappel = parsed.get("rappel_faits")
|
||||||
|
if rappel:
|
||||||
|
sections.append(f"RAPPEL DES FAITS\n{rappel}")
|
||||||
|
|
||||||
|
sections.append(sep)
|
||||||
|
|
||||||
|
# Moyens de défense numérotés
|
||||||
|
moyens = parsed.get("moyens_defense")
|
||||||
|
if moyens and isinstance(moyens, list):
|
||||||
|
for moyen in moyens:
|
||||||
|
if not isinstance(moyen, dict):
|
||||||
|
continue
|
||||||
|
num = moyen.get("numero", "?")
|
||||||
|
titre = moyen.get("titre", "")
|
||||||
|
argument = moyen.get("argument", "")
|
||||||
|
|
||||||
|
moyen_lines = [f"MOYEN N°{num} — {titre}"]
|
||||||
|
if argument:
|
||||||
|
moyen_lines.append(argument)
|
||||||
|
|
||||||
|
# Preuves intégrées dans chaque moyen
|
||||||
|
moyen_preuves = moyen.get("preuves")
|
||||||
|
if moyen_preuves and isinstance(moyen_preuves, list):
|
||||||
|
for p in moyen_preuves:
|
||||||
|
if isinstance(p, dict):
|
||||||
|
ref = p.get("ref", "")
|
||||||
|
fait = p.get("fait", "")
|
||||||
|
signif = p.get("signification", "")
|
||||||
|
moyen_lines.append(f" Preuve : {ref} {fait} → {signif}")
|
||||||
|
|
||||||
|
# Source réglementaire du moyen
|
||||||
|
src_regl = moyen.get("source_reglementaire")
|
||||||
|
if src_regl and src_regl != "null":
|
||||||
|
moyen_lines.append(f" Source : {src_regl}")
|
||||||
|
|
||||||
|
sections.append("\n".join(moyen_lines))
|
||||||
|
|
||||||
|
sections.append(sep)
|
||||||
|
|
||||||
|
# Confrontation biologie / diagnostic (tableau)
|
||||||
|
confrontation = parsed.get("confrontation_bio")
|
||||||
|
if confrontation and isinstance(confrontation, list):
|
||||||
|
table_lines = ["CONFRONTATION BIOLOGIE / DIAGNOSTIC"]
|
||||||
|
table_lines.append(
|
||||||
|
"┌─────────────────┬─────────────┬──────────────┬───────────┬───────────────┐"
|
||||||
|
)
|
||||||
|
table_lines.append(
|
||||||
|
"│ Diagnostic │ Test requis │ Seuil │ Valeur │ Verdict │"
|
||||||
|
)
|
||||||
|
table_lines.append(
|
||||||
|
"├─────────────────┼─────────────┼──────────────┼───────────┼───────────────┤"
|
||||||
|
)
|
||||||
|
for row in confrontation:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
diag = str(row.get("diagnostic", ""))[:17].ljust(17)
|
||||||
|
test = str(row.get("test", ""))[:13].ljust(13)
|
||||||
|
seuil = str(row.get("seuil", ""))[:14].ljust(14)
|
||||||
|
valeur = str(row.get("valeur", ""))[:11].ljust(11)
|
||||||
|
verdict = str(row.get("verdict", ""))[:15].ljust(15)
|
||||||
|
table_lines.append(f"│ {diag}│ {test}│ {seuil}│ {valeur}│ {verdict}│")
|
||||||
|
table_lines.append(
|
||||||
|
"└─────────────────┴─────────────┴──────────────┴───────────┴───────────────┘"
|
||||||
|
)
|
||||||
|
sections.append("\n".join(table_lines))
|
||||||
|
|
||||||
|
sections.append(sep)
|
||||||
|
|
||||||
|
# Codes non défendables (honnêteté intellectuelle)
|
||||||
|
codes_nd = parsed.get("codes_non_defendables")
|
||||||
|
if codes_nd and isinstance(codes_nd, list) and len(codes_nd) > 0:
|
||||||
|
nd_lines = ["⚠ CODES NON DÉFENDABLES (honnêteté intellectuelle)"]
|
||||||
|
for nd in codes_nd:
|
||||||
|
if isinstance(nd, dict):
|
||||||
|
code = nd.get("code", "?")
|
||||||
|
raison = nd.get("raison", "")
|
||||||
|
reco = nd.get("recommandation", "")
|
||||||
|
nd_lines.append(f"- {code} : {raison}")
|
||||||
|
if reco:
|
||||||
|
nd_lines.append(f" → {reco}")
|
||||||
|
sections.append("\n".join(nd_lines))
|
||||||
|
sections.append(sep)
|
||||||
|
|
||||||
|
# Asymétrie d'information
|
||||||
|
asymetrie = parsed.get("asymetrie_information")
|
||||||
|
if asymetrie:
|
||||||
|
sections.append(f"ASYMÉTRIE D'INFORMATION\n{asymetrie}")
|
||||||
|
sections.append(sep)
|
||||||
|
|
||||||
|
# Réponse aux points CPAM
|
||||||
|
reponse_cpam = parsed.get("reponse_points_cpam")
|
||||||
|
if reponse_cpam:
|
||||||
|
sections.append(f"RÉPONSE AUX POINTS DE LA CPAM\n{reponse_cpam}")
|
||||||
|
sections.append(sep)
|
||||||
|
|
||||||
|
# Références réglementaires
|
||||||
|
refs = parsed.get("references")
|
||||||
|
if refs:
|
||||||
|
if isinstance(refs, list):
|
||||||
|
ref_lines = ["RÉFÉRENCES RÉGLEMENTAIRES"]
|
||||||
|
for r in refs:
|
||||||
|
if isinstance(r, dict):
|
||||||
|
doc = r.get("document", "")
|
||||||
|
page = r.get("page", "")
|
||||||
|
citation = r.get("citation", "")
|
||||||
|
ref_lines.append(f"- [{doc}, p.{page}] {citation}")
|
||||||
|
else:
|
||||||
|
ref_lines.append(f"- {r}")
|
||||||
|
sections.append("\n".join(ref_lines))
|
||||||
|
else:
|
||||||
|
sections.append(f"RÉFÉRENCES RÉGLEMENTAIRES\n{refs}")
|
||||||
|
|
||||||
|
sections.append(sep_heavy)
|
||||||
|
|
||||||
|
# Conclusion dispositive
|
||||||
|
conclusion = parsed.get("conclusion_dispositive")
|
||||||
|
if conclusion:
|
||||||
|
sections.append(f"CONCLUSION\n{conclusion}")
|
||||||
|
|
||||||
|
sections.append(sep_heavy)
|
||||||
|
|
||||||
|
# Avertissements
|
||||||
|
sections.extend(_format_warnings(categorized_warnings, ref_warnings))
|
||||||
|
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_response_legacy(
|
||||||
|
parsed: dict,
|
||||||
|
ref_warnings: list[str] | None = None,
|
||||||
|
quality_tier: str | None = None,
|
||||||
|
categorized_warnings: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Formate la réponse LLM au format hérité (rétro-compatibilité cache)."""
|
||||||
sections = []
|
sections = []
|
||||||
|
|
||||||
# Bandeau qualité si tier C
|
# Bandeau qualité si tier C
|
||||||
@@ -543,12 +788,12 @@ def _format_response(
|
|||||||
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
|
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
|
||||||
sections.append(f"POINTS D'ACCORD\n{accord}")
|
sections.append(f"POINTS D'ACCORD\n{accord}")
|
||||||
|
|
||||||
# Nouveaux champs structurés par axe
|
# Champs structurés par axe
|
||||||
contre_med = parsed.get("contre_arguments_medicaux")
|
contre_med = parsed.get("contre_arguments_medicaux")
|
||||||
if contre_med:
|
if contre_med:
|
||||||
sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{contre_med}")
|
sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{contre_med}")
|
||||||
|
|
||||||
# Preuves du dossier (nouveau champ structuré)
|
# Preuves du dossier
|
||||||
preuves = parsed.get("preuves_dossier")
|
preuves = parsed.get("preuves_dossier")
|
||||||
if preuves and isinstance(preuves, list):
|
if preuves and isinstance(preuves, list):
|
||||||
preuves_lines = []
|
preuves_lines = []
|
||||||
@@ -577,7 +822,7 @@ def _format_response(
|
|||||||
if contre:
|
if contre:
|
||||||
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
|
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
|
||||||
|
|
||||||
# Références structurées (nouveau format liste) ou ancien format string
|
# Références structurées ou ancien format string
|
||||||
refs = parsed.get("references")
|
refs = parsed.get("references")
|
||||||
if refs:
|
if refs:
|
||||||
if isinstance(refs, list):
|
if isinstance(refs, list):
|
||||||
@@ -599,7 +844,18 @@ def _format_response(
|
|||||||
if conclusion:
|
if conclusion:
|
||||||
sections.append(f"CONCLUSION\n{conclusion}")
|
sections.append(f"CONCLUSION\n{conclusion}")
|
||||||
|
|
||||||
# Avertissements catégorisés (nouveau format)
|
# Avertissements
|
||||||
|
sections.extend(_format_warnings(categorized_warnings, ref_warnings))
|
||||||
|
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_warnings(
|
||||||
|
categorized_warnings: list[str] | None = None,
|
||||||
|
ref_warnings: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Formate les avertissements qualité (partagé entre les deux formats)."""
|
||||||
|
sections: list[str] = []
|
||||||
if categorized_warnings:
|
if categorized_warnings:
|
||||||
critiques = [w for w in categorized_warnings if w.startswith("[CRITIQUE]")]
|
critiques = [w for w in categorized_warnings if w.startswith("[CRITIQUE]")]
|
||||||
mineurs = [w for w in categorized_warnings if w.startswith("[MINEUR]")]
|
mineurs = [w for w in categorized_warnings if w.startswith("[MINEUR]")]
|
||||||
@@ -612,8 +868,6 @@ def _format_response(
|
|||||||
"AVERTISSEMENTS MINEURS\n" + "\n".join(f"- {w}" for w in mineurs)
|
"AVERTISSEMENTS MINEURS\n" + "\n".join(f"- {w}" for w in mineurs)
|
||||||
)
|
)
|
||||||
elif ref_warnings:
|
elif ref_warnings:
|
||||||
# Fallback ancien format
|
|
||||||
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
|
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
|
||||||
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")
|
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")
|
||||||
|
return sections
|
||||||
return "\n\n".join(sections)
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from ..config import BiologieCle, DossierMedical, load_lab_value_sanity
|
from ..config import BiologieCle, DossierMedical, load_lab_value_sanity
|
||||||
from .bio_normals import BIO_NORMALS, _is_abnormal
|
from .bio_normals import BIO_NORMALS, _is_abnormal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _norm_key(s: str) -> str:
|
def _norm_key(s: str) -> str:
|
||||||
"""Normalise une clé (minuscules, sans accents) pour index YAML."""
|
"""Normalise une clé (minuscules, sans accents) pour index YAML."""
|
||||||
@@ -68,6 +72,100 @@ def _sanitize_bio_value(test_name: str, raw_value: str, sanity_cfg: dict) -> tup
|
|||||||
return token, val, quality, reason
|
return token, val, quality, reason
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_biologie_faiss(text: str, dossier: DossierMedical) -> None:
|
||||||
|
"""Extraction biologique via recherche vectorielle FAISS pour les synonymes.
|
||||||
|
|
||||||
|
Complète les regex pour les termes non prévus ou les variations complexes.
|
||||||
|
"""
|
||||||
|
from .rag_index import get_index
|
||||||
|
from .rag_search import _get_embed_model
|
||||||
|
|
||||||
|
res = get_index(kind="bio")
|
||||||
|
if not res:
|
||||||
|
return
|
||||||
|
faiss_index, metadata = res
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = _get_embed_model()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("FAISS Bio: modèle d'embedding indisponible (%s)", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Découpage du texte en segments glissants (phrases ou groupes de mots)
|
||||||
|
lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 5]
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
for line in lines:
|
||||||
|
if len(line.split()) > 15:
|
||||||
|
words = line.split()
|
||||||
|
for i in range(0, len(words), 10):
|
||||||
|
segments.append(" ".join(words[i:i+12]))
|
||||||
|
else:
|
||||||
|
segments.append(line)
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Encodage des segments
|
||||||
|
try:
|
||||||
|
embeddings = model.encode(segments, normalize_embeddings=True, show_progress_bar=False)
|
||||||
|
embeddings = np.array(embeddings, dtype=np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("FAISS Bio: erreur encodage segments (%s)", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Recherche dans l'index bio
|
||||||
|
MIN_SCORE_BIO = 0.82
|
||||||
|
scores, indices = faiss_index.search(embeddings, 1)
|
||||||
|
|
||||||
|
sanity_cfg = load_lab_value_sanity()
|
||||||
|
seen_faiss = set()
|
||||||
|
|
||||||
|
for i, (score, idx) in enumerate(zip(scores, indices)):
|
||||||
|
s = float(score[0])
|
||||||
|
if s < MIN_SCORE_BIO or idx[0] < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta = metadata[idx[0]]
|
||||||
|
concept_name = meta.get("code")
|
||||||
|
synonym_matched = meta.get("extrait")
|
||||||
|
segment = segments[i]
|
||||||
|
|
||||||
|
# 4. Capture de la valeur numérique
|
||||||
|
val_match = re.search(r"(?:[=àa:]\s*)?(\d+(?:[.,]\d+)?)\s*(?:[a-zA-Z/%/µ/mm3/G/L/U/I]+)?", segment)
|
||||||
|
if not val_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_value = val_match.group(1)
|
||||||
|
entry_key = (concept_name, raw_value)
|
||||||
|
if entry_key in seen_faiss:
|
||||||
|
continue
|
||||||
|
seen_faiss.add(entry_key)
|
||||||
|
|
||||||
|
sanitized = _sanitize_bio_value(concept_name, raw_value, sanity_cfg)
|
||||||
|
if sanitized:
|
||||||
|
token, val_num, quality, reason = sanitized
|
||||||
|
anomalie = _is_abnormal(concept_name, token)
|
||||||
|
|
||||||
|
is_dup = any(b.test == concept_name and b.valeur == raw_value for b in dossier.biologie_cle)
|
||||||
|
if is_dup:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dossier.biologie_cle.append(
|
||||||
|
BiologieCle(
|
||||||
|
test=concept_name,
|
||||||
|
valeur=raw_value,
|
||||||
|
valeur_num=val_num,
|
||||||
|
anomalie=anomalie,
|
||||||
|
quality=quality,
|
||||||
|
discard_reason=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.debug("FAISS Bio match: %s (%s) = %s dans '%s'", concept_name, synonym_matched, raw_value, segment)
|
||||||
|
|
||||||
|
|
||||||
def _extract_biologie(text: str, dossier: DossierMedical) -> None:
|
def _extract_biologie(text: str, dossier: DossierMedical) -> None:
|
||||||
"""Extrait des résultats biologiques clés.
|
"""Extrait des résultats biologiques clés.
|
||||||
|
|
||||||
@@ -90,12 +188,20 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
|
|||||||
# Ionogramme / électrolytes
|
# Ionogramme / électrolytes
|
||||||
(r"(?:[Ss]odium|[Nn]atr[ée]mie|(?<![A-Za-z])Na\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9]{2,3}(?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Sodium"),
|
(r"(?:[Ss]odium|[Nn]atr[ée]mie|(?<![A-Za-z])Na\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9]{2,3}(?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Sodium"),
|
||||||
(r"(?:[Pp]otassium|[Kk]ali[ée]mie|(?<![A-Za-z])K\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9](?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Potassium"),
|
(r"(?:[Pp]otassium|[Kk]ali[ée]mie|(?<![A-Za-z])K\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9](?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Potassium"),
|
||||||
|
(r"(?:[Cc]hlore|[Cc]hlor[ée]mie|(?<![A-Za-z])Cl-?(?![A-Za-z]))\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L)?", "Chlore"),
|
||||||
|
(r"(?:[Cc]alcium|[Cc]alci[ée]mie|(?<![A-Za-z])Ca\+?(?![A-Za-z]))\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|mg/dL)?", "Calcium"),
|
||||||
|
|
||||||
(r"[Tt]roponine\s+(?:us\s+)?(n[ée]gative|positive|normale)", "Troponine"),
|
(r"[Tt]roponine\s+(?:us\s+)?(n[ée]gative|positive|normale)", "Troponine"),
|
||||||
(r"(?:[Hh][ée]moglobine|\bHb\b)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/dL|g/L)?", "Hémoglobine"),
|
(r"(?:[Hh][ée]moglobine|\bHb\b)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/dL|g/L)?", "Hémoglobine"),
|
||||||
|
(r"\bVGM\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:fL)?", "VGM"),
|
||||||
|
(r"\bFerritine\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µg/L|ng/mL)?", "Ferritine"),
|
||||||
(r"[Pp]laquettes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Plaquettes"),
|
(r"[Pp]laquettes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Plaquettes"),
|
||||||
(r"[Ll]eucocytes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Leucocytes"),
|
(r"[Ll]eucocytes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Leucocytes"),
|
||||||
(r"[Cc]r[ée]atinine?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Créatinine"),
|
(r"[Cc]r[ée]atinine?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Créatinine"),
|
||||||
|
(r"\bUr[ée]e\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|g/L)?", "Urée"),
|
||||||
|
(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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -182,3 +288,6 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
|
|||||||
discard_reason=reason,
|
discard_reason=reason,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Complément par recherche vectorielle (Synonymes) ---
|
||||||
|
_extract_biologie_faiss(text, dossier)
|
||||||
|
|||||||
@@ -96,6 +96,46 @@ def extract_medical_info(
|
|||||||
if use_rag:
|
if use_rag:
|
||||||
_enrich_with_rag(dossier)
|
_enrich_with_rag(dossier)
|
||||||
|
|
||||||
|
# NUKE-3 : sélection DP type DIM (CRH uniquement)
|
||||||
|
if dossier.document_type != "trackare":
|
||||||
|
try:
|
||||||
|
from .dp_selector import select_dp, build_synthese
|
||||||
|
|
||||||
|
synthese = build_synthese(dossier, parsed_data)
|
||||||
|
selection = select_dp(
|
||||||
|
dossier, synthese, config={"llm_enabled": use_rag},
|
||||||
|
)
|
||||||
|
dossier.dp_selection = selection
|
||||||
|
|
||||||
|
if selection.chosen_code:
|
||||||
|
current_code = (
|
||||||
|
dossier.diagnostic_principal.cim10_suggestion
|
||||||
|
if dossier.diagnostic_principal else None
|
||||||
|
)
|
||||||
|
has_multiple = len(selection.candidates) >= 2
|
||||||
|
# MAJ DP si :
|
||||||
|
# - DP existant et NUKE-3 sélectionne un code différent
|
||||||
|
# - Pas de DP mais plusieurs candidats (choix non trivial)
|
||||||
|
# Le cas "1 seul candidat, pas de DP" est géré par RULE-DAS-TO-DP
|
||||||
|
should_update = (
|
||||||
|
(current_code and selection.chosen_code != current_code)
|
||||||
|
or (not current_code and has_multiple)
|
||||||
|
)
|
||||||
|
if should_update:
|
||||||
|
dossier.diagnostic_principal = Diagnostic(
|
||||||
|
texte=selection.chosen_term or "",
|
||||||
|
cim10_suggestion=selection.chosen_code,
|
||||||
|
cim10_confidence=selection.confidence,
|
||||||
|
source="nuke3",
|
||||||
|
)
|
||||||
|
|
||||||
|
if selection.verdict == "REVIEW":
|
||||||
|
dossier.alertes_codage.append(
|
||||||
|
f"NUKE-3 REVIEW: DP ambigu — {selection.reason}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("NUKE-3: erreur sélection DP", exc_info=True)
|
||||||
|
|
||||||
# Post-processing : validation des codes CCAM contre le dictionnaire
|
# Post-processing : validation des codes CCAM contre le dictionnaire
|
||||||
_validate_ccam(dossier)
|
_validate_ccam(dossier)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import pdfplumber
|
import pdfplumber
|
||||||
|
|
||||||
from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH, REFERENTIELS_DIR, EMBEDDING_MODEL
|
from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH, BIO_CONCEPTS_PATH, REFERENTIELS_DIR, EMBEDDING_MODEL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -112,11 +112,14 @@ def _paths(kind: str) -> tuple[Path, Path]:
|
|||||||
kind:
|
kind:
|
||||||
- "ref" : référentiels
|
- "ref" : référentiels
|
||||||
- "proc" : procédures
|
- "proc" : procédures
|
||||||
|
- "bio" : concepts biologiques
|
||||||
- "all" : legacy (faiss.index)
|
- "all" : legacy (faiss.index)
|
||||||
"""
|
"""
|
||||||
kind = (kind or "ref").lower()
|
kind = (kind or "ref").lower()
|
||||||
if kind == "proc":
|
if kind == "proc":
|
||||||
return (RAG_INDEX_DIR / "faiss_proc.index", RAG_INDEX_DIR / "metadata_proc.json")
|
return (RAG_INDEX_DIR / "faiss_proc.index", RAG_INDEX_DIR / "metadata_proc.json")
|
||||||
|
if kind == "bio":
|
||||||
|
return (RAG_INDEX_DIR / "faiss_bio.index", RAG_INDEX_DIR / "metadata_bio.json")
|
||||||
if kind == "all":
|
if kind == "all":
|
||||||
return (RAG_INDEX_DIR / "faiss.index", RAG_INDEX_DIR / "metadata.json")
|
return (RAG_INDEX_DIR / "faiss.index", RAG_INDEX_DIR / "metadata.json")
|
||||||
# ref (default)
|
# ref (default)
|
||||||
@@ -470,6 +473,25 @@ def _chunk_cim10_alpha(pdf_path: Path) -> list[Chunk]:
|
|||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_bio_concepts() -> list[Chunk]:
|
||||||
|
"""Génère des chunks à partir de bio_concepts.json pour la recherche sémantique de tests."""
|
||||||
|
if not BIO_CONCEPTS_PATH.exists():
|
||||||
|
return []
|
||||||
|
with open(BIO_CONCEPTS_PATH, encoding="utf-8") as f:
|
||||||
|
concepts = json.load(f)
|
||||||
|
chunks = []
|
||||||
|
for item in concepts:
|
||||||
|
concept_name = item["concept"]
|
||||||
|
# On indexe le nom du concept + tous les synonymes
|
||||||
|
for syn in ([concept_name] + item.get("synonyms", [])):
|
||||||
|
chunks.append(Chunk(
|
||||||
|
text=syn,
|
||||||
|
document="bio_concepts",
|
||||||
|
code=concept_name, # On stocke le nom du concept "pivot" dans 'code'
|
||||||
|
))
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Construction de l'index FAISS
|
# Construction de l'index FAISS
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -489,18 +511,24 @@ def build_index(force: bool = False) -> None:
|
|||||||
|
|
||||||
ref_index_path, ref_meta_path = _paths("ref")
|
ref_index_path, ref_meta_path = _paths("ref")
|
||||||
proc_index_path, proc_meta_path = _paths("proc")
|
proc_index_path, proc_meta_path = _paths("proc")
|
||||||
|
bio_index_path, bio_meta_path = _paths("bio")
|
||||||
|
|
||||||
# Si tout existe déjà et pas de force
|
# Si tout existe déjà et pas de force
|
||||||
ref_ok = ref_index_path.exists() and ref_meta_path.exists()
|
ref_ok = ref_index_path.exists() and ref_meta_path.exists()
|
||||||
proc_ok = proc_index_path.exists() and proc_meta_path.exists()
|
proc_ok = proc_index_path.exists() and proc_meta_path.exists()
|
||||||
|
bio_ok = bio_index_path.exists() and bio_meta_path.exists()
|
||||||
guide_expected = GUIDE_METHODO_PDF.exists()
|
guide_expected = GUIDE_METHODO_PDF.exists()
|
||||||
if not force and ref_ok and ((not guide_expected) or proc_ok):
|
if not force and ref_ok and bio_ok and ((not guide_expected) or proc_ok):
|
||||||
logger.info("Index FAISS déjà existants dans %s (use force=True pour reconstruire)", RAG_INDEX_DIR)
|
logger.info("Index FAISS déjà existants dans %s (use force=True pour reconstruire)", RAG_INDEX_DIR)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Collecter les chunks
|
# Collecter les chunks
|
||||||
ref_chunks: list[Chunk] = []
|
ref_chunks: list[Chunk] = []
|
||||||
proc_chunks: list[Chunk] = []
|
proc_chunks: list[Chunk] = []
|
||||||
|
bio_chunks: list[Chunk] = []
|
||||||
|
|
||||||
|
# Concepts biologiques
|
||||||
|
bio_chunks.extend(_chunk_bio_concepts())
|
||||||
|
|
||||||
# CIM-10 (référentiel)
|
# CIM-10 (référentiel)
|
||||||
if CIM10_PDF.exists():
|
if CIM10_PDF.exists():
|
||||||
@@ -560,6 +588,7 @@ def build_index(force: bool = False) -> None:
|
|||||||
|
|
||||||
_write_index(ref_chunks, ref_index_path, ref_meta_path, "ref")
|
_write_index(ref_chunks, ref_index_path, ref_meta_path, "ref")
|
||||||
_write_index(proc_chunks, proc_index_path, proc_meta_path, "proc")
|
_write_index(proc_chunks, proc_index_path, proc_meta_path, "proc")
|
||||||
|
_write_index(bio_chunks, bio_index_path, bio_meta_path, "bio")
|
||||||
|
|
||||||
# Invalider les singletons
|
# Invalider les singletons
|
||||||
reset_index()
|
reset_index()
|
||||||
@@ -569,7 +598,7 @@ def get_index(kind: str = "ref") -> tuple | None:
|
|||||||
"""Charge un index FAISS et ses métadonnées (singleton lazy-loaded).
|
"""Charge un index FAISS et ses métadonnées (singleton lazy-loaded).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
kind: "ref" | "proc" | "all".
|
kind: "ref" | "proc" | "bio" | "all".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple (faiss_index, metadata_list) ou None si l'index n'existe pas.
|
Tuple (faiss_index, metadata_list) ou None si l'index n'existe pas.
|
||||||
@@ -586,8 +615,8 @@ def get_index(kind: str = "ref") -> tuple | None:
|
|||||||
|
|
||||||
index_path, meta_path = _paths(kind)
|
index_path, meta_path = _paths(kind)
|
||||||
|
|
||||||
# Backwards compat : si ref/proc absent, fallback sur all
|
# Backwards compat : si ref/proc/bio absent, fallback sur all
|
||||||
if kind in ("ref", "proc") and (not index_path.exists() or not meta_path.exists()):
|
if kind in ("ref", "proc", "bio") and (not index_path.exists() or not meta_path.exists()):
|
||||||
legacy_idx, legacy_meta = _paths("all")
|
legacy_idx, legacy_meta = _paths("all")
|
||||||
if legacy_idx.exists() and legacy_meta.exists():
|
if legacy_idx.exists() and legacy_meta.exists():
|
||||||
logger.warning("Index %s absent — fallback legacy faiss.index", kind)
|
logger.warning("Index %s absent — fallback legacy faiss.index", kind)
|
||||||
|
|||||||
@@ -561,7 +561,13 @@ def enrich_diagnostic(
|
|||||||
sources = search_similar(diagnostic.texte, top_k=10)
|
sources = search_similar(diagnostic.texte, top_k=10)
|
||||||
|
|
||||||
if not sources:
|
if not sources:
|
||||||
logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte)
|
# Toujours initialiser sources_rag (même vide) pour traçabilité
|
||||||
|
diagnostic.sources_rag = []
|
||||||
|
logger.debug("RAG: 0 résultat FAISS pour « %s »", diagnostic.texte)
|
||||||
|
# Si un cache hit existe, appliquer le résultat LLM malgré l'absence de sources
|
||||||
|
if cached is not None:
|
||||||
|
logger.info("Cache hit (sans sources FAISS) pour %s : « %s »", diag_type.upper(), diagnostic.texte)
|
||||||
|
_apply_llm_result_diagnostic(diagnostic, cached)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. Stocker les sources RAG
|
# 3. Stocker les sources RAG
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .templates import (
|
|||||||
CPAM_EXTRACTION,
|
CPAM_EXTRACTION,
|
||||||
CPAM_ARGUMENTATION,
|
CPAM_ARGUMENTATION,
|
||||||
CPAM_ADVERSARIAL,
|
CPAM_ADVERSARIAL,
|
||||||
|
DP_RANKER_CONSTRAINED,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -18,4 +19,5 @@ __all__ = [
|
|||||||
"CPAM_EXTRACTION",
|
"CPAM_EXTRACTION",
|
||||||
"CPAM_ARGUMENTATION",
|
"CPAM_ARGUMENTATION",
|
||||||
"CPAM_ADVERSARIAL",
|
"CPAM_ADVERSARIAL",
|
||||||
|
"DP_RANKER_CONSTRAINED",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ Variables par template :
|
|||||||
decision_ucr, dp_ucr_line, da_ucr_line
|
decision_ucr, dp_ucr_line, da_ucr_line
|
||||||
CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre,
|
CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre,
|
||||||
arg_ucr, decision_ucr, codes_str, definitions_str,
|
arg_ucr, decision_ucr, codes_str, definitions_str,
|
||||||
codes_autorises_str, sources_text, extraction_str
|
codes_autorises_str, sources_text, extraction_str,
|
||||||
|
bio_confrontation_str, numero_ogc
|
||||||
CPAM_ADVERSARIAL : response_json, factual_section, normes_section,
|
CPAM_ADVERSARIAL : response_json, factual_section, normes_section,
|
||||||
dp_ucr_line, da_ucr_line
|
dp_ucr_line, da_ucr_line
|
||||||
|
DP_RANKER_CONSTRAINED : candidates_str, ctx_str, n_candidates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -215,96 +217,122 @@ Réponds UNIQUEMENT en JSON :
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 6. CPAM_ARGUMENTATION — Passe 2 contre-argumentation CPAM
|
# 6. CPAM_ARGUMENTATION — Passe 2 contre-argumentation CPAM (méthode TIM)
|
||||||
# Source : cpam_response.py _build_cpam_prompt()
|
# Source : cpam_response.py _build_cpam_prompt()
|
||||||
# Rôle : cpam | Température : 0.1 | max_tokens : 4000
|
# Rôle : cpam | Température : 0.1 | max_tokens : 16000
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
CPAM_ARGUMENTATION = """\
|
CPAM_ARGUMENTATION = """\
|
||||||
Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A.
|
Tu es un médecin DIM senior expert en contentieux T2A. Tu rédiges un MÉMOIRE EN DÉFENSE \
|
||||||
Tu dois produire une analyse ÉQUILIBRÉE ET CRÉDIBLE de la contestation CPAM, puis contre-argumenter en mobilisant trois axes : médical, asymétrie d'information, et réglementaire.
|
structuré et argumenté pour répondre à la contestation CPAM ci-dessous.
|
||||||
|
|
||||||
IMPORTANT — CRÉDIBILITÉ DE L'ANALYSE :
|
Ta méthode suit les 5 passes de raisonnement expert TIM :
|
||||||
Une contre-argumentation crédible reconnaît TOUJOURS au moins un point valide dans le raisonnement adverse.
|
|
||||||
Répondre "Aucun point d'accord" décrédibilise l'ensemble de l'argumentation. Tu DOIS identifier au moins un élément où la CPAM a un point légitime (même partiel), puis expliquer pourquoi cela ne suffit pas à invalider le codage.
|
|
||||||
|
|
||||||
IMPORTANT — CODES CIM-10 :
|
PASSE 1 — CONTEXTE ADMINISTRATIF :
|
||||||
Ne parle JAMAIS de « codage initial » ou « codage contesté » sans citer explicitement le code CIM-10 et son libellé (ex: Z45.80 — Ajustement et entretien d'un dispositif implantable).
|
Analyse le contexte du séjour (âge, sexe, durée, mode d'entrée/sortie, actes) pour cadrer \
|
||||||
Chaque argument doit désigner précisément quel code est défendu ou contesté, avec son libellé complet.
|
ton raisonnement. En pédiatrie (< 18 ans), les normes biologiques et codages diffèrent. \
|
||||||
|
Une admission en urgence implique un contexte aigu influençant le DP.
|
||||||
|
|
||||||
DOSSIER MÉDICAL DE L'ÉTABLISSEMENT :
|
PASSE 2 — MOTIF D'HOSPITALISATION RÉEL :
|
||||||
{dossier_str}
|
Distingue le motif d'entrée déclaré du motif réel en te posant :
|
||||||
|
- 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
|
||||||
|
|
||||||
|
PASSE 3 — CONFRONTATION BIOLOGIE / DIAGNOSTIC :
|
||||||
|
Pour chaque diagnostic contesté, confronte aux seuils biologiques :
|
||||||
|
{bio_confrontation_str}
|
||||||
|
- Une valeur normale CONTREDIT un diagnostic actif basé sur cette biologie
|
||||||
|
- Une valeur pathologique SANS diagnostic est un sous-codage potentiel
|
||||||
|
- CITE les seuils exacts et les valeurs du dossier
|
||||||
|
|
||||||
|
PASSE 4 — HIÉRARCHIE DIAGNOSTIQUE :
|
||||||
|
- Le DP est le diagnostic qui a CONSOMMÉ LE PLUS DE RESSOURCES (pas le plus grave)
|
||||||
|
- Spécifique exclut générique (K81.0 présent → retirer K81.9)
|
||||||
|
- Codes R (symptômes) INTERDITS en DP si étiologie identifiée
|
||||||
|
- Chaque DAS doit répondre OUI à au moins une question :
|
||||||
|
1. Traitement spécifique pendant ce séjour ?
|
||||||
|
2. Allongement de la durée de séjour ?
|
||||||
|
3. Modification de la surveillance ou des examens ?
|
||||||
|
|
||||||
|
PASSE 5 — VALIDATION DÉFENSIVE (regard CPAM) :
|
||||||
|
Pour CHAQUE code défendu, tu DOIS répondre aux 4 questions :
|
||||||
|
1. Ce diagnostic est-il documenté EXPLICITEMENT dans le dossier, ou DÉDUIT ?
|
||||||
|
2. Y a-t-il une preuve OBJECTIVE (valeur bio, imagerie, acte CCAM) ?
|
||||||
|
3. Le code est-il COHÉRENT avec la durée de séjour et les actes réalisés ?
|
||||||
|
4. Quel DOCUMENT du dossier cite-t-on en premier face à la CPAM ?
|
||||||
|
|
||||||
|
DOSSIER MÉDICAL : {dossier_str}
|
||||||
{asymetrie_str}
|
{asymetrie_str}
|
||||||
{tagged_str}
|
{tagged_str}
|
||||||
|
|
||||||
OBJET DU DÉSACCORD : {titre}
|
CONTESTATION CPAM :
|
||||||
|
Objet : {titre}
|
||||||
|
Argument UCR : {arg_ucr}
|
||||||
|
Décision UCR : {decision_ucr}
|
||||||
|
|
||||||
ARGUMENTATION DE LA CPAM (UCR) :
|
CODES EN JEU : {codes_str}
|
||||||
{arg_ucr}
|
|
||||||
|
|
||||||
DÉCISION UCR : {decision_ucr}
|
|
||||||
|
|
||||||
CODES CONTESTÉS :
|
|
||||||
{codes_str}
|
|
||||||
{definitions_str}
|
{definitions_str}
|
||||||
{codes_autorises_str}
|
{codes_autorises_str}
|
||||||
|
|
||||||
SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
|
SOURCES RÉGLEMENTAIRES : {sources_text}
|
||||||
{sources_text}
|
|
||||||
{extraction_str}
|
{extraction_str}
|
||||||
|
|
||||||
CONSIGNES :
|
RÈGLE ABSOLUE — HONNÊTETÉ INTELLECTUELLE :
|
||||||
|
Un mémoire crédible ne force JAMAIS un argument que le dossier ne soutient pas.
|
||||||
|
- Si une valeur biologique est NORMALE alors que le diagnostic l'exige pathologique → \
|
||||||
|
tu DOIS le signaler et NE PAS défendre ce code sur cet axe
|
||||||
|
- Si un diagnostic n'a AUCUNE preuve objective (pas de bio, pas d'imagerie, pas d'acte) → \
|
||||||
|
tu écris : "Ce diagnostic repose sur le seul jugement clinique, sans preuve biologique ou \
|
||||||
|
paraclinique dans le dossier"
|
||||||
|
- Si la confrontation bio CONTREDIT un diagnostic → tu NE LE DÉFENDS PAS et tu le signales \
|
||||||
|
dans le champ "codes_non_defendables"
|
||||||
|
- Si la CPAM a RAISON sur un point → tu le reconnais clairement. Mieux vaut concéder un \
|
||||||
|
point indéfendable et gagner en crédibilité sur les points solides
|
||||||
|
- Principe TIM : "Mieux vaut un code moins précis mais défendable qu'un code précis mais \
|
||||||
|
indéfendable"
|
||||||
|
|
||||||
CONTEXTE CLINIQUE :
|
CONSIGNES DE RÉDACTION :
|
||||||
- Prends en compte l'ÂGE du patient (pédiatrie < 18 ans, personne âgée >= 80 ans), le MODE D'ENTRÉE (urgence vs programmé), et la DURÉE DE SÉJOUR pour contextualiser ton analyse
|
|
||||||
- En pédiatrie, les normes biologiques et les codages peuvent différer de l'adulte
|
|
||||||
- Une admission en urgence implique un contexte clinique aigu qui influence le choix du DP
|
|
||||||
|
|
||||||
ÉTAPE 1 — ANALYSE HONNÊTE (avant de contre-argumenter) :
|
1. STRUCTURE EN MOYENS DE DÉFENSE NUMÉROTÉS (pas de prose libre)
|
||||||
- Identifie ce que la CPAM a compris correctement dans le dossier
|
2. Chaque moyen = un argument autonome avec sa preuve FORMELLEMENT DOCUMENTÉE dans le dossier
|
||||||
- Reconnais les points où leur raisonnement est fondé, même partiellement
|
3. CITE les codes CIM-10 avec libellé complet (ex: N17.8 — Autre insuffisance rénale aiguë)
|
||||||
- Explique ENSUITE pourquoi ces points ne justifient pas leur conclusion
|
4. CITE les valeurs bio EXACTES avec seuils normatifs (ex: "CRP = 145 mg/L [norme < 5]")
|
||||||
|
5. CITE les sources réglementaires au format [Document - page N] "citation verbatim"
|
||||||
|
6. JAMAIS d'argument sans preuve traçable — si tu n'as pas la preuve, NE FAIS PAS l'argument
|
||||||
|
7. Ton ASSERTIF mais factuel — pas de formules creuses ("il convient de noter que...")
|
||||||
|
8. Si un point CPAM est légitime, le reconnaître CLAIREMENT — la crédibilité globale en dépend
|
||||||
|
9. N'invente AUCUN tag, code ou source qui n'est pas fourni ci-dessus
|
||||||
|
10. NE JAMAIS qualifier une valeur NORMALE comme pathologique ni extrapoler au-delà des faits
|
||||||
|
11. Tags valides : [DP], [DAS-N], [BIO-N], [IMG-N], [TRT-N], [ACTE-N], [ANT-N], [COMPL-N]
|
||||||
|
|
||||||
AXE MÉDICAL :
|
Réponds UNIQUEMENT avec un objet JSON :
|
||||||
- Analyse le bien-fondé médical du codage de l'établissement
|
|
||||||
- CITE les éléments cliniques EXACTS du dossier en utilisant UNIQUEMENT les tags [XX-N] fournis dans la section ÉLÉMENTS CLINIQUES RÉFÉRENCÉS
|
|
||||||
- Tags valides : [DP], [DAS-N], [BIO-N], [IMG-N], [TRT-N], [ACTE-N], [ANT-N], [COMPL-N]
|
|
||||||
- N'invente JAMAIS un tag qui ne figure pas dans la liste ci-dessus. Si un élément n'a pas de tag, décris-le en texte libre SANS crochets.
|
|
||||||
- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
|
|
||||||
- Ne mentionne AUCUN élément qui ne figure pas dans les éléments référencés ci-dessus
|
|
||||||
|
|
||||||
AXE ASYMÉTRIE D'INFORMATION :
|
|
||||||
- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis
|
|
||||||
- Pour CHAQUE élément clinique pertinent, cite les VALEURS EXACTES et explique leur signification clinique
|
|
||||||
- Démontre en quoi ces éléments complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté
|
|
||||||
- Ne mentionne AUCUN élément qui n'est pas dans le dossier fourni
|
|
||||||
|
|
||||||
MISE EN FORME :
|
|
||||||
- Structure chaque section avec des tirets pour lister les arguments distincts
|
|
||||||
- Un argument par puce, avec la preuve ou la référence associée
|
|
||||||
|
|
||||||
AXE RÉGLEMENTAIRE :
|
|
||||||
- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle
|
|
||||||
- Confronte le raisonnement CPAM au texte EXACT des sources fournies
|
|
||||||
- Format OBLIGATOIRE pour chaque référence : [Document - page N] suivi d'une CITATION VERBATIM du passage pertinent
|
|
||||||
- INTERDICTION ABSOLUE de citer une référence qui ne figure pas dans les sources fournies ci-dessus
|
|
||||||
- Si aucune source pertinente n'est disponible → écrire explicitement "Pas de source réglementaire disponible"
|
|
||||||
- Relève les contradictions entre l'argumentation CPAM et les règles officielles
|
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec un objet JSON au format suivant :
|
|
||||||
{{
|
{{
|
||||||
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
|
"objet": "Contestation {titre} — OGC {numero_ogc} — Mémoire en défense",
|
||||||
"points_accord": "Points CONCRETS où la CPAM a raison ou partiellement raison (JAMAIS 'Aucun' — il y a toujours au moins un point légitime à reconnaître)",
|
"rappel_faits": "Résumé factuel du séjour en 3-5 lignes : motif, actes, durée, issue",
|
||||||
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage, en expliquant pourquoi les points d'accord ne suffisent pas à invalider le codage",
|
"moyens_defense": [
|
||||||
"preuves_dossier": [
|
{{
|
||||||
{{"ref": "BIO-1 ou DAS-3 ou DP (UNIQUEMENT un tag existant de la section ÉLÉMENTS CLINIQUES RÉFÉRENCÉS)", "element": "biologie|imagerie|traitement|acte|diagnostic|antécédent|complication", "valeur": "valeur exacte du dossier", "signification": "explication clinique"}}
|
"numero": 1,
|
||||||
|
"titre": "Titre court du moyen (ex: Le DP N17.8 est justifié par la biologie)",
|
||||||
|
"argument": "Développement avec preuves tagées [XX-N], valeurs bio avec seuils, sources réglementaires",
|
||||||
|
"preuves": [
|
||||||
|
{{"ref": "[BIO-1]", "fait": "Créatinine = 280 µmol/L [norme 50-120]", "signification": "IRA confirmée"}}
|
||||||
|
],
|
||||||
|
"source_reglementaire": "[Document - page N] citation verbatim ou null"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"confrontation_bio": [
|
||||||
|
{{"diagnostic": "N17.8 IRA", "test": "Créatinine", "valeur": 280, "seuil": "> 130 µmol/L", "verdict": "CONFIRMÉ"}}
|
||||||
|
],
|
||||||
|
"asymetrie_information": "Éléments cliniques que la CPAM n'avait PAS (bio, imagerie, actes) — brièvement",
|
||||||
|
"reponse_points_cpam": "Pour chaque point légitime de la CPAM : reconnaissance CLAIRE + réfutation factuelle OU concession si indéfendable",
|
||||||
|
"codes_non_defendables": [
|
||||||
|
{{"code": "D50.9", "raison": "Hb = 13.5 g/dL [norme > 12 F] — valeur NORMALE, anémie non confirmée biologiquement", "recommandation": "Retrait recommandé — code indéfendable face à la CPAM"}}
|
||||||
],
|
],
|
||||||
"contre_arguments_asymetrie": "Éléments cliniques que la CPAM n'avait pas et qui justifient le codage",
|
|
||||||
"contre_arguments_reglementaires": "Erreurs d'interprétation réglementaire de la CPAM, avec citations verbatim des sources",
|
|
||||||
"references": [
|
"references": [
|
||||||
{{"document": "nom du document source", "page": "numéro de page", "citation": "citation verbatim du passage"}}
|
{{"document": "nom du document source", "page": "numéro de page", "citation": "citation verbatim du passage"}}
|
||||||
],
|
],
|
||||||
"conclusion": "Synthèse en citant EXPLICITEMENT les codes CIM-10 défendus (ex: DP Z45.80 — libellé) : points reconnus à la CPAM, puis pourquoi ce codage précis est néanmoins justifié"
|
"conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs (citer les preuves clés), des règles CIM-10 applicables (citer les sources), et des informations complémentaires non transmises à l'UCR, nous demandons le MAINTIEN du codage : DP [CODE — libellé], DAS [CODE — libellé]. [Si code non défendable :] Nous reconnaissons que le code [CODE] ne dispose pas d'un support documentaire suffisant."
|
||||||
}}"""
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
@@ -315,7 +343,8 @@ Réponds UNIQUEMENT avec un objet JSON au format suivant :
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
CPAM_ADVERSARIAL = """\
|
CPAM_ADVERSARIAL = """\
|
||||||
Tu es un relecteur critique. Vérifie la cohérence de cette contre-argumentation CPAM.
|
Tu es un relecteur critique expert en codage PMSI. Vérifie la cohérence et l'honnêteté \
|
||||||
|
intellectuelle de ce mémoire en défense CPAM.
|
||||||
|
|
||||||
RÉPONSE GÉNÉRÉE :
|
RÉPONSE GÉNÉRÉE :
|
||||||
{response_json}
|
{response_json}
|
||||||
@@ -329,11 +358,17 @@ CODES CONTESTÉS :
|
|||||||
{da_ucr_line}
|
{da_ucr_line}
|
||||||
|
|
||||||
Vérifie STRICTEMENT :
|
Vérifie STRICTEMENT :
|
||||||
1. Chaque valeur bio/imagerie/traitement citée dans les preuves existe dans les éléments factuels
|
1. Chaque moyen de défense a une preuve traçable FORMELLEMENT documentée dans les éléments factuels
|
||||||
2. Si une valeur bio est qualifiée de "élevée", "basse" ou "anormale", vérifie qu'elle est RÉELLEMENT hors normes selon les normes ci-dessus (ex: CRP 5 = NORMAL, pas élevé)
|
2. Si une valeur bio est qualifiée de "élevée", "basse" ou "anormale", vérifie qu'elle est \
|
||||||
3. La conclusion est cohérente avec l'argumentation développée
|
RÉELLEMENT hors normes selon les normes ci-dessus (ex: CRP 5 = NORMAL, pas élevé)
|
||||||
4. Les points d'accord ne contredisent pas les contre-arguments
|
3. AUCUNE valeur NORMALE n'est présentée comme pathologique
|
||||||
5. Les codes CIM-10 mentionnés dans la conclusion sont cohérents avec le reste
|
4. La confrontation bio (champ "confrontation_bio") est cohérente avec les valeurs du dossier \
|
||||||
|
et les seuils normatifs
|
||||||
|
5. Les codes signalés dans "codes_non_defendables" ne sont PAS défendus dans "moyens_defense"
|
||||||
|
6. La conclusion dispositive cite les bons codes et reconnaît les concessions
|
||||||
|
7. Les seuils bio cités correspondent aux normes officielles ci-dessus
|
||||||
|
8. Les codes CIM-10 mentionnés dans la conclusion sont cohérents avec le reste
|
||||||
|
9. Le champ "reponse_points_cpam" répond factuellement aux arguments CPAM (pas de déni)
|
||||||
|
|
||||||
Réponds UNIQUEMENT en JSON :
|
Réponds UNIQUEMENT en JSON :
|
||||||
{{
|
{{
|
||||||
@@ -341,3 +376,36 @@ Réponds UNIQUEMENT en JSON :
|
|||||||
"erreurs": ["description précise de chaque incohérence trouvée"],
|
"erreurs": ["description précise de chaque incohérence trouvée"],
|
||||||
"score_confiance": 0 à 10
|
"score_confiance": 0 à 10
|
||||||
}}"""
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. DP_RANKER_CONSTRAINED — NUKE-3 sélection DP dans une liste fermée
|
||||||
|
# Source : dp_selector.py _llm_rank()
|
||||||
|
# Rôle : coding | Température : 0.0 | max_tokens : 1000
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DP_RANKER_CONSTRAINED = """\
|
||||||
|
Tu es un médecin DIM expert en codage PMSI. Tu dois choisir le Diagnostic Principal (DP) \
|
||||||
|
parmi la liste FERMÉE de {n_candidates} candidats ci-dessous.
|
||||||
|
|
||||||
|
RÈGLES STRICTES :
|
||||||
|
1. Le DP reflète le MOTIF PRINCIPAL de prise en charge pendant ce séjour
|
||||||
|
2. Un acte seul (cholécystectomie, biopsie…) NE PEUT PAS être DP s'il existe un candidat textuel
|
||||||
|
3. Un symptôme (R00-R99) NE PEUT PAS être DP si une étiologie candidate existe dans la liste
|
||||||
|
4. Une comorbidité chronique (HTA, diabète, BPCO) NE PEUT PAS être DP sauf prise en charge ACTIVE
|
||||||
|
5. Tu DOIS choisir un index de la liste — JAMAIS de réponse hors liste
|
||||||
|
|
||||||
|
CANDIDATS :
|
||||||
|
{candidates_str}
|
||||||
|
|
||||||
|
CONTEXTE CLINIQUE :
|
||||||
|
{ctx_str}
|
||||||
|
|
||||||
|
Réponds UNIQUEMENT en JSON :
|
||||||
|
{{
|
||||||
|
"chosen_index": N,
|
||||||
|
"confidence": "high|medium|low",
|
||||||
|
"verdict": "CONFIRMED|REVIEW",
|
||||||
|
"evidence": ["raison 1", "raison 2"],
|
||||||
|
"reason": "explication courte justifiant le choix"
|
||||||
|
}}"""
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ def _threshold_high(cfg: dict, test: str, age_band: str, doc_hi: float | None) -
|
|||||||
|
|
||||||
def _is_sodium_test(test: str) -> bool:
|
def _is_sodium_test(test: str) -> bool:
|
||||||
t = (test or "").lower().strip()
|
t = (test or "").lower().strip()
|
||||||
# 'na' est trop générique: on privilégie sodium/natrémie
|
|
||||||
if "sodium" in t or "natr" in t:
|
if "sodium" in t or "natr" in t:
|
||||||
return True
|
return True
|
||||||
return bool(re.fullmatch(r"na\+?", t))
|
return bool(re.fullmatch(r"na\+?", t))
|
||||||
@@ -174,6 +173,131 @@ def _is_potassium_test(test: str) -> bool:
|
|||||||
return bool(re.fullmatch(r"k\+?", t))
|
return bool(re.fullmatch(r"k\+?", t))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bio_matcher(analyte: str):
|
||||||
|
"""Retourne une fonction de matching pour l'analyte demandé."""
|
||||||
|
a = analyte.lower()
|
||||||
|
if a == "sodium": return _is_sodium_test
|
||||||
|
if a == "potassium": return _is_potassium_test
|
||||||
|
if a == "hemoglobin": return lambda t: "hemoglob" in t.lower() or "hb" in t.lower().split()
|
||||||
|
if a == "platelets": return lambda t: "plaquette" in t.lower() or "platelet" in t.lower()
|
||||||
|
if a == "creatinine": return lambda t: "creatinine" in t.lower()
|
||||||
|
if a == "glucose": return lambda t: "glucose" in t.lower() or "glycemie" in t.lower()
|
||||||
|
if a == "hba1c": return lambda t: "hba1c" in t.lower()
|
||||||
|
if a == "tsh": return lambda t: "tsh" in t.lower()
|
||||||
|
# Fallback: simple inclusion
|
||||||
|
return lambda t: a in t.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_bio_rules_gen(dossier: DossierMedical, cfg_ranges: dict) -> None:
|
||||||
|
"""Applique les règles de validation biologique définies dans config/bio_rules.yaml."""
|
||||||
|
bio_cfg = load_bio_rules() or {}
|
||||||
|
rules = (bio_cfg.get("rules") or {}) if isinstance(bio_cfg, dict) else {}
|
||||||
|
missing_cfg = (bio_cfg.get("missing_evidence") or {}) if isinstance(bio_cfg, dict) else {}
|
||||||
|
age_band = _age_band(dossier, cfg_ranges)
|
||||||
|
|
||||||
|
def _push_need_info_veto(where: str, message: str) -> None:
|
||||||
|
if dossier.veto_report is None: return
|
||||||
|
vr = dossier.veto_report
|
||||||
|
veto = str(missing_cfg.get("veto") or "VETO-17")
|
||||||
|
if not rule_enabled(veto): return
|
||||||
|
severity = str(missing_cfg.get("severity") or "LOW")
|
||||||
|
penalty = int(missing_cfg.get("score_penalty") or 0)
|
||||||
|
if any((it.veto == veto and it.where == where and (it.message or "") == message) for it in (vr.issues or [])):
|
||||||
|
return
|
||||||
|
vr.issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message))
|
||||||
|
if (vr.verdict or "") == "PASS": vr.verdict = "NEED_INFO"
|
||||||
|
if penalty: vr.score_contestabilite = max(0, int(vr.score_contestabilite or 0) - penalty)
|
||||||
|
|
||||||
|
for rule_id, r in rules.items():
|
||||||
|
if not r.get("enabled", True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
analyte = r.get("analyte")
|
||||||
|
if not analyte: continue
|
||||||
|
|
||||||
|
codes = set(r.get("codes") or [])
|
||||||
|
matcher = _get_bio_matcher(analyte)
|
||||||
|
values, lo_doc, hi_doc = _bio_values(dossier, matcher)
|
||||||
|
t_type = r.get("threshold_type", "low") # 'low' pour hypo/anémie, 'high' pour hyper/insuffisance
|
||||||
|
|
||||||
|
# 1) PREUVE MANQUANTE
|
||||||
|
if not values and bool(missing_cfg.get("enabled", False)):
|
||||||
|
for i, das in enumerate(dossier.diagnostics_associes or []):
|
||||||
|
if (das.cim10_suggestion or "") not in codes: continue
|
||||||
|
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"): continue
|
||||||
|
|
||||||
|
rule_key = f"RULE-{rule_id.upper()}-MISSING"
|
||||||
|
if not rule_enabled(rule_key): continue
|
||||||
|
|
||||||
|
reason = f"Preuve manquante: {analyte} non extrait — impossible de valider {das.cim10_suggestion} de façon défendable."
|
||||||
|
das.status = "needs_info"
|
||||||
|
das.cim10_final = None
|
||||||
|
das.cim10_decision = CodeDecision(
|
||||||
|
action="NEED_INFO",
|
||||||
|
final_code=None,
|
||||||
|
downgraded_from=das.cim10_suggestion,
|
||||||
|
reason=reason,
|
||||||
|
needs_info=[f"Valeur(s) de {analyte} + date(s) ?", "Normes du laboratoire si disponibles ?"],
|
||||||
|
applied_rules=[rule_key],
|
||||||
|
)
|
||||||
|
_push_need_info_veto(f"diagnostics_associes[{i}]", f"{das.cim10_suggestion} suggéré mais aucune preuve de {analyte} n'a été extraite.")
|
||||||
|
|
||||||
|
# 2) CONTRADICTION (RULED_OUT)
|
||||||
|
if values:
|
||||||
|
is_conflict = False
|
||||||
|
found_val = 0.0
|
||||||
|
threshold = 0.0
|
||||||
|
|
||||||
|
if t_type == "low":
|
||||||
|
# Pour un diagnostic de type "Bas" (hypo, anémie), on écarte si la valeur est >= seuil bas normal
|
||||||
|
threshold = _threshold(cfg_ranges, analyte, age_band, lo_doc)
|
||||||
|
if min(values) >= threshold:
|
||||||
|
is_conflict = True
|
||||||
|
found_val = min(values)
|
||||||
|
else:
|
||||||
|
# Pour un diagnostic de type "Haut" (hyper, insuff), on écarte si la valeur est <= seuil haut normal
|
||||||
|
threshold = _threshold_high(cfg_ranges, analyte, age_band, hi_doc)
|
||||||
|
if max(values) <= threshold:
|
||||||
|
is_conflict = True
|
||||||
|
found_val = max(values)
|
||||||
|
|
||||||
|
# Cas particulier : seuil fixe dans le YAML (ex: HbA1c > 9)
|
||||||
|
if r.get("threshold_value") is not None:
|
||||||
|
fixed_t = float(r["threshold_value"])
|
||||||
|
if t_type == "high" and max(values) < fixed_t:
|
||||||
|
is_conflict = True
|
||||||
|
found_val = max(values)
|
||||||
|
threshold = fixed_t
|
||||||
|
elif t_type == "low" and min(values) > fixed_t:
|
||||||
|
is_conflict = True
|
||||||
|
found_val = min(values)
|
||||||
|
threshold = fixed_t
|
||||||
|
|
||||||
|
if is_conflict:
|
||||||
|
rule_key = f"RULE-{rule_id.upper()}-NORMAL"
|
||||||
|
if not rule_enabled(rule_key): continue
|
||||||
|
|
||||||
|
op = "≥" if t_type == "low" else "≤"
|
||||||
|
reason = f"Contradiction biologique: {analyte}={found_val} ({op}{threshold}, valeur normale) — {r.get('message', 'diagnostic non retenu')}."
|
||||||
|
|
||||||
|
for das in dossier.diagnostics_associes or []:
|
||||||
|
if (das.cim10_suggestion or "") not in codes: continue
|
||||||
|
das.status = "ruled_out"
|
||||||
|
das.ruled_out_reason = reason
|
||||||
|
das.cim10_final = None
|
||||||
|
das.cim10_decision = CodeDecision(
|
||||||
|
action="RULED_OUT",
|
||||||
|
final_code=None,
|
||||||
|
downgraded_from=das.cim10_suggestion,
|
||||||
|
reason=reason,
|
||||||
|
needs_info=[
|
||||||
|
f"Valeurs de {analyte} sur d'autres dates (trend) ?",
|
||||||
|
f"Mention explicite de {das.cim10_suggestion} confirmée malgré valeurs normales ?",
|
||||||
|
],
|
||||||
|
applied_rules=[rule_key],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _bio_values(
|
def _bio_values(
|
||||||
dossier: DossierMedical,
|
dossier: DossierMedical,
|
||||||
matcher,
|
matcher,
|
||||||
@@ -369,6 +493,30 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
|||||||
for das in dossier.diagnostics_associes or []:
|
for das in dossier.diagnostics_associes or []:
|
||||||
_set_default_final(das)
|
_set_default_final(das)
|
||||||
|
|
||||||
|
# --- Règle: nettoyage hiérarchique (VETO-22bis) ---
|
||||||
|
# Si un code spécifique (ex: K81.0) est présent, on retire le code générique (K81.9)
|
||||||
|
all_final_codes = set()
|
||||||
|
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_final:
|
||||||
|
all_final_codes.add(dossier.diagnostic_principal.cim10_final)
|
||||||
|
for das in dossier.diagnostics_associes or []:
|
||||||
|
if das.cim10_final:
|
||||||
|
all_final_codes.add(das.cim10_final)
|
||||||
|
|
||||||
|
for das in dossier.diagnostics_associes or []:
|
||||||
|
if das.cim10_final and das.cim10_final.endswith(".9"):
|
||||||
|
cat3 = das.cim10_final[:3]
|
||||||
|
# Chercher s'il existe un autre code plus spécifique dans la même catégorie
|
||||||
|
if any(c.startswith(cat3) and c != das.cim10_final for c in all_final_codes):
|
||||||
|
das.status = "removed"
|
||||||
|
das.cim10_decision = CodeDecision(
|
||||||
|
action="REMOVE",
|
||||||
|
final_code=None,
|
||||||
|
downgraded_from=das.cim10_final,
|
||||||
|
reason=f"Code générique {das.cim10_final} retiré car un code plus spécifique de la catégorie {cat3} est présent.",
|
||||||
|
applied_rules=["RULE-HIERARCHY-CLEANUP"],
|
||||||
|
)
|
||||||
|
das.cim10_final = None
|
||||||
|
|
||||||
# --- Règle: D50 sans preuve martiale -> downgrade D64.9 + needs_info ---
|
# --- Règle: D50 sans preuve martiale -> downgrade D64.9 + needs_info ---
|
||||||
if rule_enabled("RULE-D50-NEEDS-IRON"):
|
if rule_enabled("RULE-D50-NEEDS-IRON"):
|
||||||
for das in dossier.diagnostics_associes or []:
|
for das in dossier.diagnostics_associes or []:
|
||||||
@@ -427,186 +575,9 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
|||||||
applied_rules=["RULE-D69.6-PLT-NORMAL"],
|
applied_rules=["RULE-D69.6-PLT-NORMAL"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Pack "bio": contradictions simples Na/K -> ruled_out (piloté par config/bio_rules.yaml)
|
# --- Pack "bio": contradictions pilotées par config/bio_rules.yaml
|
||||||
# Objectif: réduire VETO-09 en écartant les diagnostics "hyper/hypo" quand la valeur est clairement normale.
|
cfg_ranges = load_reference_ranges()
|
||||||
bio_cfg = load_bio_rules() or {}
|
_apply_bio_rules_gen(dossier, cfg_ranges)
|
||||||
rules = (bio_cfg.get("rules") or {}) if isinstance(bio_cfg, dict) else {}
|
|
||||||
|
|
||||||
missing_cfg = (bio_cfg.get("missing_evidence") or {}) if isinstance(bio_cfg, dict) else {}
|
|
||||||
def _push_need_info_veto(where: str, message: str) -> None:
|
|
||||||
"""Ajoute un VETO non-bloquant quand la preuve biologique est manquante."""
|
|
||||||
if dossier.veto_report is None:
|
|
||||||
return
|
|
||||||
vr = dossier.veto_report
|
|
||||||
veto = str(missing_cfg.get("veto") or "VETO-17")
|
|
||||||
# Désactivation globale par YAML (config/rules)
|
|
||||||
if not rule_enabled(veto):
|
|
||||||
return
|
|
||||||
severity = str(missing_cfg.get("severity") or "LOW")
|
|
||||||
penalty = int(missing_cfg.get("score_penalty") or 0)
|
|
||||||
|
|
||||||
# Anti-doublon
|
|
||||||
if any((it.veto == veto and it.where == where and (it.message or "") == message) for it in (vr.issues or [])):
|
|
||||||
return
|
|
||||||
|
|
||||||
vr.issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message))
|
|
||||||
if (vr.verdict or "") == "PASS":
|
|
||||||
vr.verdict = "NEED_INFO"
|
|
||||||
if penalty:
|
|
||||||
vr.score_contestabilite = max(0, int(vr.score_contestabilite or 0) - penalty)
|
|
||||||
|
|
||||||
|
|
||||||
# Sodium (hyponatrémie)
|
|
||||||
r = rules.get("hyponatremia") or {}
|
|
||||||
if r.get("enabled", True):
|
|
||||||
codes = set(r.get("codes") or ["E87.1"])
|
|
||||||
na_values, na_lo_doc, _na_hi_doc = _bio_values(dossier, _is_sodium_test)
|
|
||||||
if (not na_values) and bool(missing_cfg.get("enabled", False)) and rule_enabled("RULE-E87.1-MISSING-NA"):
|
|
||||||
for i, das in enumerate(dossier.diagnostics_associes or []):
|
|
||||||
if (das.cim10_suggestion or "") not in codes:
|
|
||||||
continue
|
|
||||||
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
reason = "Preuve manquante: natrémie (sodium) non extraite — impossible de valider E87.1 de façon défendable."
|
|
||||||
where = f"diagnostics_associes[{i}]"
|
|
||||||
das.status = "needs_info"
|
|
||||||
das.cim10_final = None
|
|
||||||
das.cim10_decision = CodeDecision(
|
|
||||||
action="NEED_INFO",
|
|
||||||
final_code=None,
|
|
||||||
downgraded_from=das.cim10_suggestion,
|
|
||||||
reason=reason,
|
|
||||||
needs_info=[
|
|
||||||
"Valeur(s) de sodium (natrémie) + date(s) ?",
|
|
||||||
"Normes du laboratoire si disponibles ?",
|
|
||||||
],
|
|
||||||
applied_rules=["RULE-E87.1-MISSING-NA"],
|
|
||||||
)
|
|
||||||
_push_need_info_veto(where, "E87.1 suggérée mais aucune natrémie (Na) n'a été extraite des résultats biologiques.")
|
|
||||||
|
|
||||||
if na_values and rule_enabled("RULE-E87.1-NA-NORMAL"):
|
|
||||||
na_threshold = _threshold(cfg_ranges, "sodium", age_band, na_lo_doc)
|
|
||||||
# Ne ruled_out que si AUCUNE valeur n'est sous la borne basse normale.
|
|
||||||
if min(na_values) >= na_threshold:
|
|
||||||
na_val = min(na_values)
|
|
||||||
for das in dossier.diagnostics_associes or []:
|
|
||||||
if (das.cim10_suggestion or "") not in codes:
|
|
||||||
continue
|
|
||||||
das.status = "ruled_out"
|
|
||||||
das.ruled_out_reason = (
|
|
||||||
f"Contradiction biologique: sodium={na_val} (≥{na_threshold}, valeur normale) "
|
|
||||||
"— hyponatrémie non retenue sans preuve explicite."
|
|
||||||
)
|
|
||||||
das.cim10_final = None
|
|
||||||
das.cim10_decision = CodeDecision(
|
|
||||||
action="RULED_OUT",
|
|
||||||
final_code=None,
|
|
||||||
downgraded_from=das.cim10_suggestion,
|
|
||||||
reason=das.ruled_out_reason,
|
|
||||||
needs_info=[
|
|
||||||
"Valeurs de natrémie sur d'autres dates (trend) ?",
|
|
||||||
"Mention explicite d'hyponatrémie confirmée malgré valeurs normales ?",
|
|
||||||
"Contexte (perfusions, diurétiques, SIADH, etc.) documenté ?",
|
|
||||||
],
|
|
||||||
applied_rules=["RULE-E87.1-NA-NORMAL"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Potassium (hyper/hypo)
|
|
||||||
k_values, k_lo_doc, k_hi_doc = _bio_values(dossier, _is_potassium_test)
|
|
||||||
if (not k_values) and bool(missing_cfg.get("enabled", False)):
|
|
||||||
# Valeur de kaliémie manquante : on refuse de valider E87.5/E87.6 sans preuve.
|
|
||||||
codes_hyper = set((rules.get("hyperkalemia") or {}).get("codes") or ["E87.5"])
|
|
||||||
codes_hypo = set((rules.get("hypokalemia") or {}).get("codes") or ["E87.6"])
|
|
||||||
codes = codes_hyper.union(codes_hypo)
|
|
||||||
|
|
||||||
for i, das in enumerate(dossier.diagnostics_associes or []):
|
|
||||||
if (das.cim10_suggestion or "") not in codes:
|
|
||||||
continue
|
|
||||||
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
code = das.cim10_suggestion or ""
|
|
||||||
rule_id = f"RULE-{code}-MISSING-K"
|
|
||||||
if not rule_enabled(rule_id):
|
|
||||||
continue
|
|
||||||
reason = f"Preuve manquante: kaliémie (potassium) non extraite — impossible de valider {code} de façon défendable."
|
|
||||||
where = f"diagnostics_associes[{i}]"
|
|
||||||
das.status = "needs_info"
|
|
||||||
das.cim10_final = None
|
|
||||||
das.cim10_decision = CodeDecision(
|
|
||||||
action="NEED_INFO",
|
|
||||||
final_code=None,
|
|
||||||
downgraded_from=code,
|
|
||||||
reason=reason,
|
|
||||||
needs_info=[
|
|
||||||
"Valeur(s) de potassium (kaliémie) + date(s) ?",
|
|
||||||
"Normes du laboratoire si disponibles ?",
|
|
||||||
],
|
|
||||||
applied_rules=[f"RULE-{code}-MISSING-K"],
|
|
||||||
)
|
|
||||||
_push_need_info_veto(where, f"{code} suggéré mais aucune kaliémie (K) n'a été extraite des résultats biologiques.")
|
|
||||||
|
|
||||||
if k_values:
|
|
||||||
# Hyperkaliémie
|
|
||||||
r = rules.get("hyperkalemia") or {}
|
|
||||||
if r.get("enabled", True) and rule_enabled("RULE-E87.5-K-NORMAL"):
|
|
||||||
codes = set(r.get("codes") or ["E87.5"])
|
|
||||||
k_high = _threshold_high(cfg_ranges, "potassium", age_band, k_hi_doc)
|
|
||||||
# Ruled_out si AUCUNE valeur ne dépasse la borne haute normale.
|
|
||||||
if max(k_values) <= k_high:
|
|
||||||
k_val = max(k_values)
|
|
||||||
for das in dossier.diagnostics_associes or []:
|
|
||||||
if (das.cim10_suggestion or "") not in codes:
|
|
||||||
continue
|
|
||||||
das.status = "ruled_out"
|
|
||||||
das.ruled_out_reason = (
|
|
||||||
f"Contradiction biologique: potassium={k_val} (≤{k_high}, valeur normale) "
|
|
||||||
"— hyperkaliémie non retenue sans preuve explicite."
|
|
||||||
)
|
|
||||||
das.cim10_final = None
|
|
||||||
das.cim10_decision = CodeDecision(
|
|
||||||
action="RULED_OUT",
|
|
||||||
final_code=None,
|
|
||||||
downgraded_from=das.cim10_suggestion,
|
|
||||||
reason=das.ruled_out_reason,
|
|
||||||
needs_info=[
|
|
||||||
"Valeurs de kaliémie sur d'autres dates (trend) ?",
|
|
||||||
"Mention explicite d'hyperkaliémie confirmée malgré valeurs normales ?",
|
|
||||||
"Contexte (IRA, IEC/ARA2, spironolactone, hémolyse) documenté ?",
|
|
||||||
],
|
|
||||||
applied_rules=["RULE-E87.5-K-NORMAL"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hypokaliémie
|
|
||||||
r = rules.get("hypokalemia") or {}
|
|
||||||
if r.get("enabled", True) and rule_enabled("RULE-E87.6-K-NORMAL"):
|
|
||||||
codes = set(r.get("codes") or ["E87.6"])
|
|
||||||
k_low = _threshold(cfg_ranges, "potassium_low", age_band, k_lo_doc)
|
|
||||||
# Ruled_out si AUCUNE valeur n'est sous la borne basse normale.
|
|
||||||
if min(k_values) >= k_low:
|
|
||||||
k_val = min(k_values)
|
|
||||||
for das in dossier.diagnostics_associes or []:
|
|
||||||
if (das.cim10_suggestion or "") not in codes:
|
|
||||||
continue
|
|
||||||
das.status = "ruled_out"
|
|
||||||
das.ruled_out_reason = (
|
|
||||||
f"Contradiction biologique: potassium={k_val} (≥{k_low}, valeur normale) "
|
|
||||||
"— hypokaliémie non retenue sans preuve explicite."
|
|
||||||
)
|
|
||||||
das.cim10_final = None
|
|
||||||
das.cim10_decision = CodeDecision(
|
|
||||||
action="RULED_OUT",
|
|
||||||
final_code=None,
|
|
||||||
downgraded_from=das.cim10_suggestion,
|
|
||||||
reason=das.ruled_out_reason,
|
|
||||||
needs_info=[
|
|
||||||
"Valeurs de kaliémie sur d'autres dates (trend) ?",
|
|
||||||
"Mention explicite d'hypokaliémie confirmée malgré valeurs normales ?",
|
|
||||||
"Contexte (diurétiques, diarrhées, pertes rénales) documenté ?",
|
|
||||||
],
|
|
||||||
applied_rules=["RULE-E87.6-K-NORMAL"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Règle: promotion DAS→DP quand aucun DP n'a été extrait ---
|
# --- Règle: promotion DAS→DP quand aucun DP n'a été extrait ---
|
||||||
if rule_enabled("RULE-DAS-TO-DP"):
|
if rule_enabled("RULE-DAS-TO-DP"):
|
||||||
@@ -638,6 +609,10 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
dossier.diagnostics_associes.remove(best)
|
dossier.diagnostics_associes.remove(best)
|
||||||
|
# Traçabilité : alerte DIM lisible pour audit
|
||||||
|
dossier.alertes_codage.append(
|
||||||
|
f"RULE-DAS-TO-DP: DP absent → DAS {best.cim10_final} ({best.texte}) promu en DP"
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"PROMOTE_DP: DAS %s (%s) promu en DP — aucun DP extrait",
|
"PROMOTE_DP: DAS %s (%s) promu en DP — aucun DP extrait",
|
||||||
best.cim10_final, best.texte,
|
best.cim10_final, best.texte,
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ audit-able, et indépendant des modèles.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from ..config import (
|
from ..config import (
|
||||||
ActeCCAM,
|
ActeCCAM,
|
||||||
BiologieCle,
|
BiologieCle,
|
||||||
@@ -22,6 +25,11 @@ from ..config import (
|
|||||||
VetoReport,
|
VetoReport,
|
||||||
rule_enabled,
|
rule_enabled,
|
||||||
rule_force_severity,
|
rule_force_severity,
|
||||||
|
load_demographic_rules,
|
||||||
|
load_diagnostic_conflicts,
|
||||||
|
load_procedure_diagnosis_rules,
|
||||||
|
load_temporal_rules,
|
||||||
|
load_parcours_rules,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,7 +229,7 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
issues: list[VetoIssue] = []
|
issues: list[VetoIssue] = []
|
||||||
seen_issue_keys: set[tuple[str, str, str]] = set() # (veto, where, message)
|
seen_issue_keys: set[tuple[str, str, str]] = set() # (veto, where, message)
|
||||||
|
|
||||||
def add(veto: str, severity: str, where: str, message: str):
|
def add(veto: str, severity: str, where: str, message: str, citation: str | None = None):
|
||||||
# Désactivation globale par YAML (config/rules)
|
# Désactivation globale par YAML (config/rules)
|
||||||
if not rule_enabled(veto):
|
if not rule_enabled(veto):
|
||||||
return
|
return
|
||||||
@@ -233,14 +241,17 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
if key in seen_issue_keys:
|
if key in seen_issue_keys:
|
||||||
return
|
return
|
||||||
seen_issue_keys.add(key)
|
seen_issue_keys.add(key)
|
||||||
issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message))
|
issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message, citation=citation))
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# VETO-02 : code sans preuve
|
# VETO-02 : code sans preuve
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
dp = dossier.diagnostic_principal
|
dp = dossier.diagnostic_principal
|
||||||
if dp and dp.cim10_suggestion:
|
if dp and dp.cim10_suggestion:
|
||||||
if not _has_evidence(dp):
|
if getattr(dp, "source", None) == "trackare":
|
||||||
|
# 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")
|
||||||
|
|
||||||
for i, das in enumerate(dossier.diagnostics_associes):
|
for i, das in enumerate(dossier.diagnostics_associes):
|
||||||
@@ -362,6 +373,48 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
if das.cim10_suggestion and das.cim10_suggestion.startswith(("N17", "N18", "N19")) and creat < 110 and das.cim10_confidence == "high":
|
if das.cim10_suggestion and das.cim10_suggestion.startswith(("N17", "N18", "N19")) and creat < 110 and das.cim10_confidence == "high":
|
||||||
add("VETO-09", "LOW", f"diagnostics_associes[{i}]", f"IR {das.cim10_suggestion} à confirmer (créat={creat})")
|
add("VETO-09", "LOW", f"diagnostics_associes[{i}]", f"IR {das.cim10_suggestion} à confirmer (créat={creat})")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# VETO-18 & VETO-19 : Règles démographiques (Âge / Sexe)
|
||||||
|
# -------------------------------------------------
|
||||||
|
demo_cfg = load_demographic_rules()
|
||||||
|
patient_sex = (dossier.sejour.sexe or "").upper()
|
||||||
|
patient_age = dossier.sejour.age
|
||||||
|
|
||||||
|
def _check_demo(d: Diagnostic, where: str):
|
||||||
|
code = str(d.cim10_suggestion or "")
|
||||||
|
if not code: return
|
||||||
|
cat3 = code[:3]
|
||||||
|
|
||||||
|
# Sexe
|
||||||
|
for rule_name, rule in demo_cfg.get("sex_rules", {}).items():
|
||||||
|
if cat3 in rule.get("codes", []):
|
||||||
|
req_sex = rule.get("required_sex")
|
||||||
|
if req_sex and patient_sex and patient_sex != req_sex:
|
||||||
|
add("VETO-19", rule.get("severity", "HARD"), where,
|
||||||
|
f"Incohérence Sexe: {code} réservé au sexe {req_sex} (patient: {patient_sex})",
|
||||||
|
citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
# Âge
|
||||||
|
for rule_name, rule in demo_cfg.get("age_rules", {}).items():
|
||||||
|
if cat3 in rule.get("codes", []):
|
||||||
|
if patient_age is not None:
|
||||||
|
max_age = rule.get("max_age_years")
|
||||||
|
min_age = rule.get("min_age_years")
|
||||||
|
if max_age is not None and patient_age > max_age:
|
||||||
|
add("VETO-18", rule.get("severity", "HARD"), where,
|
||||||
|
f"Incohérence Âge: {code} réservé à l'âge ≤ {max_age} ans (patient: {patient_age} ans)",
|
||||||
|
citation=rule.get("atih_ref"))
|
||||||
|
if min_age is not None and patient_age < min_age:
|
||||||
|
add("VETO-18", rule.get("severity", "HARD"), where,
|
||||||
|
f"Incohérence Âge: {code} réservé à l'âge ≥ {min_age} ans (patient: {patient_age} ans)",
|
||||||
|
citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
if dp:
|
||||||
|
_check_demo(dp, "diagnostic_principal")
|
||||||
|
for i, das in enumerate(dossier.diagnostics_associes):
|
||||||
|
if not _is_ruled_out(das):
|
||||||
|
_check_demo(das, f"diagnostics_associes[{i}]")
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# VETO-12 : sur-confiance
|
# VETO-12 : sur-confiance
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@@ -383,7 +436,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
if z3 not in _Z_DP_WHITELIST:
|
if z3 not in _Z_DP_WHITELIST:
|
||||||
add("VETO-20", "MEDIUM", "diagnostic_principal",
|
add("VETO-20", "MEDIUM", "diagnostic_principal",
|
||||||
f"DP {dp.cim10_suggestion} est un code Z interdit en DP (catégorie {z3}). "
|
f"DP {dp.cim10_suggestion} est un code Z interdit en DP (catégorie {z3}). "
|
||||||
"Les codes Z ne sont autorisés en DP que pour certains motifs (Z51 chimio, Z09 suivi, etc.).")
|
"Les codes Z ne sont autorisés en DP que pour certains motifs (Z51 chimio, Z09 suivi, etc.).",
|
||||||
|
citation="Guide Méthodologique MCO : Règles de sélection du Diagnostic Principal (Chapitre Z)")
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# VETO-21 : Code R (symptôme) en DP → CMD 23, tarification faible
|
# VETO-21 : Code R (symptôme) en DP → CMD 23, tarification faible
|
||||||
@@ -399,7 +453,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
severity = "LOW" if has_precise else "MEDIUM"
|
severity = "LOW" if has_precise else "MEDIUM"
|
||||||
add("VETO-21", severity, "diagnostic_principal",
|
add("VETO-21", severity, "diagnostic_principal",
|
||||||
f"DP {dp.cim10_suggestion} est un code symptôme (chapitre R) → CMD 23. "
|
f"DP {dp.cim10_suggestion} est un code symptôme (chapitre R) → CMD 23. "
|
||||||
"Un diagnostic étiologique précis devrait être recherché comme DP.")
|
"Un diagnostic étiologique précis devrait être recherché comme DP.",
|
||||||
|
citation="Guide Méthodologique MCO : Sélection du DP et codes de symptômes (Chapitre XVIII)")
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# VETO-22 : Même catégorie CIM-10 3 chars en DP + DAS
|
# VETO-22 : Même catégorie CIM-10 3 chars en DP + DAS
|
||||||
@@ -415,26 +470,39 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
if das_cat == dp_cat and das.cim10_suggestion != dp.cim10_suggestion:
|
if das_cat == dp_cat and das.cim10_suggestion != dp.cim10_suggestion:
|
||||||
add("VETO-22", "LOW", f"diagnostics_associes[{i}]",
|
add("VETO-22", "LOW", f"diagnostics_associes[{i}]",
|
||||||
f"DAS {das.cim10_suggestion} même catégorie que DP {dp.cim10_suggestion} "
|
f"DAS {das.cim10_suggestion} même catégorie que DP {dp.cim10_suggestion} "
|
||||||
f"({dp_cat}). Vérifier si la sous-catégorie DAS est pertinente ou redondante.")
|
f"({dp_cat}). Vérifier si la sous-catégorie DAS est pertinente ou redondante.",
|
||||||
|
citation="Guide Méthodologique MCO : Règle de non-redondance du codage")
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# VETO-23 : Exclusions mutuelles (diabète type 1 vs type 2, HTA)
|
# VETO-25 & VETO-26 : Cohérence inter-diagnostics (conflits)
|
||||||
# Règle PMSI : codes incompatibles dans le même séjour.
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
all_codes = set()
|
conflict_cfg = load_diagnostic_conflicts()
|
||||||
|
codes_full = set()
|
||||||
if dp and dp.cim10_suggestion:
|
if dp and dp.cim10_suggestion:
|
||||||
all_codes.add(str(dp.cim10_suggestion)[:3])
|
codes_full.add(str(dp.cim10_suggestion))
|
||||||
for das in dossier.diagnostics_associes:
|
for das in dossier.diagnostics_associes:
|
||||||
if not _is_ruled_out(das) and das.cim10_suggestion:
|
if not _is_ruled_out(das) and das.cim10_suggestion:
|
||||||
all_codes.add(str(das.cim10_suggestion)[:3])
|
codes_full.add(str(das.cim10_suggestion))
|
||||||
|
|
||||||
_MUTUAL_EXCLUSIONS = [
|
# Exclusions mutuelles
|
||||||
({"E10"}, {"E11"}, "Diabète type 1 (E10) et type 2 (E11) mutuellement exclusifs"),
|
for rule in conflict_cfg.get("mutual_exclusions", []):
|
||||||
({"I10"}, {"I11", "I12", "I13"}, "HTA essentielle (I10) incompatible avec HTA secondaire (I11/I12/I13)"),
|
matches = [c for c in codes_full if any(c.startswith(prefix) for prefix in rule.get("codes", []))]
|
||||||
]
|
if len(set(c[:3] for c in matches)) > 1:
|
||||||
for group_a, group_b, msg in _MUTUAL_EXCLUSIONS:
|
add("VETO-25", rule.get("severity", "MEDIUM"), "diagnostics_associes",
|
||||||
if (all_codes & group_a) and (all_codes & group_b):
|
f"{rule.get('name')}: {rule.get('message')} ({', '.join(matches)})",
|
||||||
add("VETO-23", "MEDIUM", "diagnostics_associes", msg)
|
citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
# Incompatibilités
|
||||||
|
for rule in conflict_cfg.get("incompatibilities", []):
|
||||||
|
pair = rule.get("pair", [])
|
||||||
|
if len(pair) >= 2:
|
||||||
|
p1_prefixes = [pair[0]] if isinstance(pair[0], str) else pair[0]
|
||||||
|
p2_prefixes = pair[1:]
|
||||||
|
part1 = any(any(c.startswith(p) for p in p1_prefixes) for c in codes_full)
|
||||||
|
part2 = any(any(c.startswith(p) for p in p2_prefixes) for c in codes_full)
|
||||||
|
if part1 and part2:
|
||||||
|
add("VETO-26", rule.get("severity", "HARD"), "diagnostics_associes", rule.get("message"),
|
||||||
|
citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y)
|
# VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y)
|
||||||
@@ -443,13 +511,125 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
has_injury = any(
|
has_injury = any(
|
||||||
str(c).startswith(("S", "T")) and not str(c).startswith(("T80", "T81", "T82", "T83", "T84", "T85", "T86", "T87", "T88"))
|
str(c).startswith(("S", "T")) and not str(c).startswith(("T80", "T81", "T82", "T83", "T84", "T85", "T86", "T87", "T88"))
|
||||||
for c in all_codes
|
for c in codes_full
|
||||||
)
|
)
|
||||||
has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in all_codes)
|
has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in codes_full)
|
||||||
if has_injury and not has_external:
|
if has_injury and not has_external:
|
||||||
add("VETO-24", "LOW", "diagnostics_associes",
|
add("VETO-24", "LOW", "diagnostics_associes",
|
||||||
"Lésion traumatique (S/T) sans code de cause externe (V/W/X/Y). "
|
"Lésion traumatique (S/T) sans code de cause externe (V/W/X/Y). "
|
||||||
"La réglementation PMSI exige un code de circonstance pour les traumatismes.")
|
"La réglementation PMSI exige un code de circonstance pour les traumatismes.",
|
||||||
|
citation="Guide Méthodologique MCO : Codage des lésions et causes externes (Chapitre XIX/XX)")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# VETO-27 : Corrélation Actes CCAM / Diagnostics CIM-10
|
||||||
|
# -------------------------------------------------
|
||||||
|
proc_diag_cfg = load_procedure_diagnosis_rules()
|
||||||
|
actes_codes = {str(a.code_ccam_suggestion) for a in dossier.actes_ccam if a.code_ccam_suggestion}
|
||||||
|
diags_codes = set()
|
||||||
|
if dp and dp.cim10_suggestion:
|
||||||
|
diags_codes.add(str(dp.cim10_suggestion))
|
||||||
|
for das in dossier.diagnostics_associes:
|
||||||
|
if not _is_ruled_out(das) and das.cim10_suggestion:
|
||||||
|
diags_codes.add(str(das.cim10_suggestion))
|
||||||
|
|
||||||
|
for rule in proc_diag_cfg.get("rules", []):
|
||||||
|
patterns = rule.get("procedure_patterns", [])
|
||||||
|
matching_actes = [ac for ac in actes_codes if any(ac.startswith(p) for p in patterns)]
|
||||||
|
|
||||||
|
if matching_actes:
|
||||||
|
required = rule.get("required_diagnosis", [])
|
||||||
|
has_justification = any(any(dc.startswith(r) for r in required) for dc in diags_codes)
|
||||||
|
|
||||||
|
if not has_justification:
|
||||||
|
for i, acte in enumerate(dossier.actes_ccam):
|
||||||
|
if str(acte.code_ccam_suggestion) in matching_actes:
|
||||||
|
add("VETO-27", rule.get("severity", "HARD"), f"actes_ccam[{i}]",
|
||||||
|
f"Acte {acte.code_ccam_suggestion} ({rule.get('name')}) sans diagnostic justificatif requis ({', '.join(required)})",
|
||||||
|
citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# VETO-28 : Validation Temporelle (Durée de séjour)
|
||||||
|
# -------------------------------------------------
|
||||||
|
temporal_cfg = load_temporal_rules()
|
||||||
|
duree_reelle = dossier.sejour.duree_sejour
|
||||||
|
|
||||||
|
if duree_reelle is not None:
|
||||||
|
# Collecter tous les codes du séjour
|
||||||
|
all_stay_codes = set()
|
||||||
|
if dp and dp.cim10_suggestion: all_stay_codes.add(str(dp.cim10_suggestion))
|
||||||
|
for d in dossier.diagnostics_associes:
|
||||||
|
if not _is_ruled_out(d) and d.cim10_suggestion: all_stay_codes.add(str(d.cim10_suggestion))
|
||||||
|
for a in dossier.actes_ccam:
|
||||||
|
if a.code_ccam_suggestion: all_stay_codes.add(str(a.code_ccam_suggestion))
|
||||||
|
|
||||||
|
for rule in temporal_cfg.get("rules", []):
|
||||||
|
patterns = rule.get("codes", [])
|
||||||
|
# Vérifier si un des codes de la règle est présent dans le dossier
|
||||||
|
if any(any(c.startswith(p) for p in patterns) for c in all_stay_codes):
|
||||||
|
min_d = rule.get("min_stay_days")
|
||||||
|
max_d = rule.get("max_stay_days")
|
||||||
|
|
||||||
|
if min_d is not None and duree_reelle < min_d:
|
||||||
|
add("VETO-28", rule.get("severity", "MEDIUM"), "sejour",
|
||||||
|
f"{rule.get('name')}: {rule.get('message')} (réel: {duree_reelle}j, attendu min: {min_d}j)",
|
||||||
|
citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
if max_d is not None and duree_reelle > max_d:
|
||||||
|
add("VETO-28", rule.get("severity", "LOW"), "sejour",
|
||||||
|
f"{rule.get('name')}: {rule.get('message')} (réel: {duree_reelle}j, attendu max: {max_d}j)",
|
||||||
|
citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# VETO-29, 30, 31 : Parcours Patient et Inventaire (Expertise DIM)
|
||||||
|
# -------------------------------------------------
|
||||||
|
parcours_cfg = load_parcours_rules()
|
||||||
|
|
||||||
|
# VETO-29 : Inventaire des pièces
|
||||||
|
doc_rules = parcours_cfg.get("documentary_rules", {}).get("required_documents", [])
|
||||||
|
current_doc_types = {dossier.document_type.lower()}
|
||||||
|
# En cas de dossier fusionné, on peut avoir plusieurs types dans source_files (heuristique)
|
||||||
|
for sf in (dossier.source_files or []):
|
||||||
|
if "CRO" in sf.upper(): current_doc_types.add("cro")
|
||||||
|
if "ANAPATH" in sf.upper(): current_doc_types.add("anapath")
|
||||||
|
if "CRH" in sf.upper(): current_doc_types.add("crh")
|
||||||
|
|
||||||
|
for rule in doc_rules:
|
||||||
|
# Condition sur les actes
|
||||||
|
if rule.get("if_has_procedure_prefix"):
|
||||||
|
has_proc = any(any(str(a.code_ccam_suggestion).startswith(p) for p in rule["if_has_procedure_prefix"])
|
||||||
|
for a in dossier.actes_ccam if a.code_ccam_suggestion)
|
||||||
|
if has_proc:
|
||||||
|
if not any(req in current_doc_types for req in rule.get("require_one_of", [])):
|
||||||
|
add("VETO-29", rule.get("severity", "HARD"), "Global", rule.get("message"), citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
# Condition sur les diagnostics
|
||||||
|
if rule.get("if_has_diagnosis_prefix"):
|
||||||
|
has_diag = any(any(str(c).startswith(p) for p in rule["if_has_diagnosis_prefix"]) for c in codes_full)
|
||||||
|
if has_diag:
|
||||||
|
if not any(req in current_doc_types for req in rule.get("require_one_of", [])):
|
||||||
|
add("VETO-29", rule.get("severity", "MEDIUM"), "Global", rule.get("message"), citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
|
# VETO-30 : Cohérence Urgences
|
||||||
|
urg_cfg = parcours_cfg.get("pathway_rules", {}).get("emergency_admission", {})
|
||||||
|
is_urgences = "URGENCES" in (dossier.sejour.mode_entree or "").upper()
|
||||||
|
if is_urgences and urg_cfg.get("check_urgences_match"):
|
||||||
|
# On vérifie si le DP est mentionné dans un document typé 'urgences' (si disponible)
|
||||||
|
if dossier.diagnostic_principal and dossier.diagnostic_principal.source != "urgences" and dossier.document_type != "urgences":
|
||||||
|
# Alerte légère car le DP peut évoluer, mais le TIM doit vérifier
|
||||||
|
add("VETO-30", urg_cfg.get("severity", "LOW"), "diagnostic_principal",
|
||||||
|
"Patient admis via les Urgences : vérifier que le DP correspond bien au motif d'admission initial.",
|
||||||
|
citation=urg_cfg.get("atih_ref"))
|
||||||
|
|
||||||
|
# VETO-31 : Cohérence Gravité / Mode de sortie
|
||||||
|
grav_rules = parcours_cfg.get("pathway_rules", {}).get("gravity_coherence", [])
|
||||||
|
mode_sortie = (dossier.sejour.mode_sortie or "").upper()
|
||||||
|
max_sev = 1
|
||||||
|
if dossier.ghm_estimation: max_sev = dossier.ghm_estimation.severite
|
||||||
|
|
||||||
|
for rule in grav_rules:
|
||||||
|
if any(m.upper() in mode_sortie for m in rule.get("if_mode_sortie", [])):
|
||||||
|
if max_sev < rule.get("require_min_severity", 2):
|
||||||
|
add("VETO-31", rule.get("severity", "MEDIUM"), "sejour", rule.get("message"), citation=rule.get("atih_ref"))
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Post-traitement : si un veto HARD existe pour un même 'where',
|
# Post-traitement : si un veto HARD existe pour un même 'where',
|
||||||
@@ -481,9 +661,9 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
|||||||
if it.severity == "HARD":
|
if it.severity == "HARD":
|
||||||
score -= 30
|
score -= 30
|
||||||
elif it.severity == "MEDIUM":
|
elif it.severity == "MEDIUM":
|
||||||
score -= 10
|
score -= 15
|
||||||
else:
|
else:
|
||||||
score -= 3
|
score -= 5
|
||||||
score = max(0, min(100, score))
|
score = max(0, min(100, score))
|
||||||
|
|
||||||
return VetoReport(verdict=verdict, score_contestabilite=score, issues=issues)
|
return VetoReport(verdict=verdict, score_contestabilite=score, issues=issues)
|
||||||
|
|||||||
@@ -385,9 +385,9 @@ def decision_badge(decision) -> Markup:
|
|||||||
labels = {
|
labels = {
|
||||||
"DOWNGRADE": ("Rétrogradé", "#fef3c7", "#92400e"),
|
"DOWNGRADE": ("Rétrogradé", "#fef3c7", "#92400e"),
|
||||||
"REMOVE": ("Supprimé", "#fee2e2", "#dc2626"),
|
"REMOVE": ("Supprimé", "#fee2e2", "#dc2626"),
|
||||||
"RULED_OUT": ("Écarté", "#f1f5f9", "#64748b"),
|
"RULED_OUT": ("Écarté (Contradiction)", "#f1f5f9", "#64748b"),
|
||||||
"NEED_INFO": ("Info requise", "#fff7ed", "#c2410c"),
|
"NEED_INFO": ("Preuve manquante", "#fff7ed", "#c2410c"),
|
||||||
"PROMOTE_DP": ("Promu DP", "#dbeafe", "#1d4ed8"),
|
"PROMOTE_DP": ("Promu en DP", "#dbeafe", "#1d4ed8"),
|
||||||
}
|
}
|
||||||
label, bg, fg = labels.get(action, (action, "#f1f5f9", "#64748b"))
|
label, bg, fg = labels.get(action, (action, "#f1f5f9", "#64748b"))
|
||||||
return Markup(f'<span class="badge" style="background:{bg};color:{fg};font-size:0.7rem;">{label}</span>')
|
return Markup(f'<span class="badge" style="background:{bg};color:{fg};font-size:0.7rem;">{label}</span>')
|
||||||
@@ -428,6 +428,30 @@ def format_cpam_text(text: str | None) -> Markup:
|
|||||||
# App factory
|
# App factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def human_where(value: str | None) -> str:
|
||||||
|
"""Rend une localisation technique lisible (ex: diagnostics_associes[0] -> DAS n°1)."""
|
||||||
|
if not value:
|
||||||
|
return "Global"
|
||||||
|
if value == "diagnostic_principal":
|
||||||
|
return "Diagnostic Principal"
|
||||||
|
if value == "diagnostics_associes":
|
||||||
|
return "Diagnostics Associés"
|
||||||
|
if value == "sejour":
|
||||||
|
return "Séjour"
|
||||||
|
|
||||||
|
# Matching diagnostics_associes[i]
|
||||||
|
m = re.match(r"diagnostics_associes\[(\d+)\]", value)
|
||||||
|
if m:
|
||||||
|
return f"DAS n°{int(m.group(1)) + 1}"
|
||||||
|
|
||||||
|
# Matching actes_ccam[i]
|
||||||
|
m = re.match(r"actes_ccam\[(\d+)\]", value)
|
||||||
|
if m:
|
||||||
|
return f"Acte n°{int(m.group(1)) + 1}"
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -440,6 +464,7 @@ def create_app() -> Flask:
|
|||||||
app.jinja_env.filters["format_doc_name"] = format_doc_name
|
app.jinja_env.filters["format_doc_name"] = format_doc_name
|
||||||
app.jinja_env.filters["format_cpam_text"] = format_cpam_text
|
app.jinja_env.filters["format_cpam_text"] = format_cpam_text
|
||||||
app.jinja_env.filters["decision_badge"] = decision_badge
|
app.jinja_env.filters["decision_badge"] = decision_badge
|
||||||
|
app.jinja_env.filters["human_where"] = human_where
|
||||||
|
|
||||||
ccam_dict = load_ccam_dict()
|
ccam_dict = load_ccam_dict()
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,64 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ---- Synthèse Expert (Refonte) ---- #}
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-top: 1rem; margin-bottom: 1.5rem;">
|
||||||
|
|
||||||
|
{# 1. Sécurité & Conformité (Vetos) #}
|
||||||
|
<div class="card" style="margin:0; border-top: 4px solid #ef4444; background: #fff1f2;">
|
||||||
|
<h3 style="color:#991b1b; font-size: 1rem; margin-bottom: 0.75rem;">🛡️ Sécurité & Conformité</h3>
|
||||||
|
<div style="font-size: 0.85rem; color: #7f1d1d;">
|
||||||
|
{% if dossier.veto_report and dossier.veto_report.issues %}
|
||||||
|
{% for issue in dossier.veto_report.issues if issue.severity in ['HARD', 'MEDIUM'] %}
|
||||||
|
<div style="margin-bottom: 0.5rem; border-bottom: 1px solid #fecaca; padding-bottom: 0.25rem;">
|
||||||
|
<strong>[{{ issue.severity|replace('HARD', 'Bloquant')|replace('MEDIUM', 'À vérifier') }}]</strong> {{ issue.message }}
|
||||||
|
{% if issue.citation %}<br><em style="font-size:0.75rem; opacity:0.8;">ATIH: {{ issue.citation }}</em>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div style="color: #059669; font-weight: 600;">✅ Aucune anomalie majeure détectée.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 2. Optimisation de la Recette (CMA) #}
|
||||||
|
<div class="card" style="margin:0; border-top: 4px solid #10b981; background: #ecfdf5;">
|
||||||
|
<h3 style="color:#065f46; font-size: 1rem; margin-bottom: 0.75rem;">💰 Valorisation (CMA)</h3>
|
||||||
|
<div style="font-size: 0.85rem; color: #064e3b;">
|
||||||
|
{% set cma_alerts = [] %}
|
||||||
|
{% for alerte in dossier.alertes_codage if alerte.startswith('CMA') %}{% set _ = cma_alerts.append(alerte) %}{% endfor %}
|
||||||
|
{% if cma_alerts %}
|
||||||
|
<ul style="margin:0; padding-left: 1.2rem;">
|
||||||
|
{% for alerte in cma_alerts %}
|
||||||
|
<li style="margin-bottom: 0.25rem;">{{ alerte }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div style="opacity: 0.7;">Aucune comorbidité (CMA) détectée.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 3. Audit & Analyse IA (QC) #}
|
||||||
|
<div class="card" style="margin:0; border-top: 4px solid #3b82f6; background: #eff6ff;">
|
||||||
|
<h3 style="color:#1e40af; font-size: 1rem; margin-bottom: 0.75rem;">🔍 Audit de l'Expert IA</h3>
|
||||||
|
<div style="font-size: 0.85rem; color: #1e3a8a;">
|
||||||
|
{% set qc_alerts = [] %}
|
||||||
|
{% for alerte in dossier.alertes_codage if alerte.startswith('QC:') %}{% set _ = qc_alerts.append(alerte) %}{% endfor %}
|
||||||
|
{% if qc_alerts %}
|
||||||
|
{% for alerte in qc_alerts %}
|
||||||
|
<div style="margin-bottom: 0.5rem; border-bottom: 1px solid #bfdbfe; padding-bottom: 0.25rem; font-style: italic;">
|
||||||
|
{{ alerte|replace('QC: ', '') }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div style="opacity: 0.7;">Aucune recommandation particulière.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ---- Séjour ---- #}
|
{# ---- Séjour ---- #}
|
||||||
{% set s = dossier.sejour %}
|
{% set s = dossier.sejour %}
|
||||||
{% if s.sexe or s.age or s.date_entree or s.date_sortie or s.duree_sejour is not none or s.imc or s.poids or s.taille %}
|
{% if s.sexe or s.age or s.date_entree or s.date_sortie or s.duree_sejour is not none or s.imc or s.poids or s.taille %}
|
||||||
@@ -301,22 +359,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# ---- Alertes de codage ---- #}
|
|
||||||
{% if dossier.alertes_codage %}
|
|
||||||
<div class="card section" style="border-left:4px solid #f97316;background:#fff7ed;">
|
|
||||||
<h3 style="color:#c2410c;">Alertes de codage ({{ dossier.alertes_codage|length }})</h3>
|
|
||||||
<ul style="margin:0;padding-left:1.2rem;">
|
|
||||||
{% for alerte in dossier.alertes_codage %}
|
|
||||||
{% if alerte.startswith('NON-CUMUL') %}
|
|
||||||
<li class="alerte-noncumul" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="alerte-standard" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ---- Contestabilité (VetoReport) ---- #}
|
{# ---- Contestabilité (VetoReport) ---- #}
|
||||||
{% if dossier.veto_report %}
|
{% if dossier.veto_report %}
|
||||||
{% set vr = dossier.veto_report %}
|
{% set vr = dossier.veto_report %}
|
||||||
@@ -328,36 +370,37 @@
|
|||||||
{% set vr_color = '#ef4444' %}
|
{% set vr_color = '#ef4444' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card section" style="border-left:4px solid {{ vr_color }};">
|
<div class="card section" style="border-left:4px solid {{ vr_color }};">
|
||||||
<h3>Contestabilité du dossier</h3>
|
<h3>Contestabilité du dossier (Qualité PMSI)</h3>
|
||||||
<div style="display:flex;align-items:center;gap:1rem;">
|
<div style="display:flex;align-items:center;gap:1rem;">
|
||||||
{% if vr.verdict == 'PASS' %}
|
{% if vr.verdict == 'PASS' %}
|
||||||
<span class="badge" style="background:#d1fae5;color:#065f46;font-weight:700;">PASS</span>
|
<span class="badge" style="background:#d1fae5;color:#065f46;font-weight:700;">CONFORME</span>
|
||||||
{% elif vr.verdict == 'NEED_INFO' %}
|
{% elif vr.verdict == 'NEED_INFO' %}
|
||||||
<span class="badge" style="background:#fef3c7;color:#92400e;font-weight:700;">NEED_INFO</span>
|
<span class="badge" style="background:#fef3c7;color:#92400e;font-weight:700;">À COMPLÉTER</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge" style="background:#fee2e2;color:#dc2626;font-weight:700;">FAIL</span>
|
<span class="badge" style="background:#fee2e2;color:#dc2626;font-weight:700;">NON CONFORME</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="flex:1;height:8px;background:#e2e8f0;border-radius:4px;">
|
<div style="flex:1;height:8px;background:#e2e8f0;border-radius:4px;">
|
||||||
<div style="width:{{ vr.score_contestabilite }}%;height:100%;background:{{ vr_color }};border-radius:4px;"></div>
|
<div style="width:{{ vr.score_contestabilite }}%;height:100%;background:{{ vr_color }};border-radius:4px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<span style="font-weight:600;">{{ vr.score_contestabilite }}/100</span>
|
<span style="font-weight:600;">Score : {{ vr.score_contestabilite }}/100</span>
|
||||||
</div>
|
</div>
|
||||||
{% if vr.issues %}
|
{% if vr.issues %}
|
||||||
<details style="margin-top:0.5rem;">
|
<details style="margin-top:0.5rem;">
|
||||||
<summary style="font-size:0.8rem;color:#64748b;">Problèmes détectés ({{ vr.issues|length }})</summary>
|
<summary style="font-size:0.8rem;color:#64748b;cursor:pointer;">Détail des anomalies détectées ({{ vr.issues|length }})</summary>
|
||||||
<table style="margin-top:0.25rem;">
|
<table style="margin-top:0.25rem;">
|
||||||
<thead><tr><th>Veto</th><th>Sévérité</th><th>Localisation</th><th>Message</th></tr></thead>
|
<thead><tr><th>Code Règle</th><th>Sévérité</th><th>Localisation</th><th>Message d'alerte</th><th>Référence ATIH</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for issue in vr.issues %}
|
{% for issue in vr.issues %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code style="font-size:0.75rem;">{{ issue.veto }}</code></td>
|
<td><code style="font-size:0.75rem;">{{ issue.veto }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
{% if issue.severity == 'HARD' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">HARD</span>
|
{% if issue.severity == 'HARD' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Bloquant</span>
|
||||||
{% elif issue.severity == 'MEDIUM' %}<span class="badge" style="background:#fef3c7;color:#92400e;">MEDIUM</span>
|
{% elif issue.severity == 'MEDIUM' %}<span class="badge" style="background:#fef3c7;color:#92400e;">À vérifier</span>
|
||||||
{% else %}<span class="badge" style="background:#f0fdf4;color:#166534;">LOW</span>{% endif %}
|
{% else %}<span class="badge" style="background:#f0fdf4;color:#166534;">Optimisation</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:0.75rem;color:#64748b;">{{ issue.where }}</td>
|
<td style="font-size:0.75rem;color:#64748b;">{{ issue.where|human_where }}</td>
|
||||||
<td style="font-size:0.8rem;">{{ issue.message }}</td>
|
<td style="font-size:0.8rem;">{{ issue.message }}</td>
|
||||||
|
<td style="font-size:0.75rem;color:#475569;font-style:italic;">{{ issue.citation or '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -506,7 +549,7 @@
|
|||||||
<details style="margin-top:0.3rem;"><summary style="font-size:0.7rem;color:#0369a1;cursor:pointer;">preuves ({{ das.preuves_cliniques|length }})</summary>
|
<details style="margin-top:0.3rem;"><summary style="font-size:0.7rem;color:#0369a1;cursor:pointer;">preuves ({{ das.preuves_cliniques|length }})</summary>
|
||||||
<ul style="margin:0.15rem 0 0 0;padding-left:1rem;font-size:0.75rem;">
|
<ul style="margin:0.15rem 0 0 0;padding-left:1rem;font-size:0.75rem;">
|
||||||
{% for p in das.preuves_cliniques %}
|
{% for p in das.preuves_cliniques %}
|
||||||
<li><span style="font-weight:600;color:#0369a1;">[{{ p.type }}]</span> {{ p.element }} <span style="color:#64748b;">→ {{ p.interpretation }}</span></li>
|
<li><span style="font-weight:600;{% if p.type == 'biologie' %}color:#0891b2;{% else %}color:#0369a1;{% endif %}">[{{ p.type }}]</span> {{ p.element }} <span style="color:#64748b;">→ {{ p.interpretation }}</span></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ def dossier_complet() -> DossierMedical:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dossier_trackare_dp() -> DossierMedical:
|
||||||
|
"""Dossier Trackare : DP pré-codé sans preuve RAG (source d'autorité)."""
|
||||||
|
return DossierMedical(
|
||||||
|
source_file="trackare-TEST.pdf",
|
||||||
|
document_type="trackare",
|
||||||
|
sejour=Sejour(sexe="F", age=72, duree_sejour=3),
|
||||||
|
diagnostic_principal=Diagnostic(
|
||||||
|
texte="Pancréatite aiguë biliaire",
|
||||||
|
cim10_suggestion="K85.1",
|
||||||
|
cim10_confidence="high",
|
||||||
|
source="trackare",
|
||||||
|
# Pas de source_excerpt, sources_rag, preuves_cliniques
|
||||||
|
# → c'est normal pour un Trackare
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def controle_cpam() -> ControleCPAM:
|
def controle_cpam() -> ControleCPAM:
|
||||||
"""Contrôle CPAM de test avec codes contestés."""
|
"""Contrôle CPAM de test avec codes contestés."""
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ from src.config import (
|
|||||||
)
|
)
|
||||||
from src.control.cpam_response import (
|
from src.control.cpam_response import (
|
||||||
_assess_dossier_strength,
|
_assess_dossier_strength,
|
||||||
|
_build_bio_confrontation,
|
||||||
_build_bio_summary,
|
_build_bio_summary,
|
||||||
_build_correction_prompt,
|
_build_correction_prompt,
|
||||||
_build_cpam_prompt,
|
_build_cpam_prompt,
|
||||||
_build_tagged_context,
|
_build_tagged_context,
|
||||||
|
_BIO_THRESHOLDS,
|
||||||
_check_das_bio_coherence,
|
_check_das_bio_coherence,
|
||||||
_extraction_pass,
|
_extraction_pass,
|
||||||
_format_response,
|
_format_response,
|
||||||
@@ -138,14 +140,18 @@ class TestBuildPrompt:
|
|||||||
assert "CIM-10 FR 2026" in prompt
|
assert "CIM-10 FR 2026" in prompt
|
||||||
assert "page 64" in prompt
|
assert "page 64" in prompt
|
||||||
|
|
||||||
def test_prompt_contains_three_axes(self):
|
def test_prompt_contains_tim_passes(self):
|
||||||
|
"""Le prompt TIM contient les 5 passes de raisonnement."""
|
||||||
dossier = _make_dossier()
|
dossier = _make_dossier()
|
||||||
controle = _make_controle()
|
controle = _make_controle()
|
||||||
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
assert "AXE MÉDICAL" in prompt
|
assert "PASSE 1" in prompt
|
||||||
assert "AXE ASYMÉTRIE D'INFORMATION" in prompt
|
assert "PASSE 2" in prompt
|
||||||
assert "AXE RÉGLEMENTAIRE" in prompt
|
assert "PASSE 3" in prompt
|
||||||
|
assert "PASSE 4" in prompt
|
||||||
|
assert "PASSE 5" in prompt
|
||||||
|
assert "MÉMOIRE EN DÉFENSE" in prompt
|
||||||
|
|
||||||
def test_prompt_contains_traitements_imagerie_when_present(self):
|
def test_prompt_contains_traitements_imagerie_when_present(self):
|
||||||
dossier = _make_dossier_complet()
|
dossier = _make_dossier_complet()
|
||||||
@@ -181,39 +187,44 @@ class TestBuildPrompt:
|
|||||||
|
|
||||||
assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" not in prompt
|
assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" not in prompt
|
||||||
|
|
||||||
def test_prompt_json_format_new_fields(self):
|
def test_prompt_json_format_tim_fields(self):
|
||||||
|
"""Le format JSON demandé inclut les champs TIM."""
|
||||||
dossier = _make_dossier()
|
dossier = _make_dossier()
|
||||||
controle = _make_controle()
|
controle = _make_controle()
|
||||||
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
assert "contre_arguments_medicaux" in prompt
|
assert "moyens_defense" in prompt
|
||||||
assert "contre_arguments_asymetrie" in prompt
|
assert "confrontation_bio" in prompt
|
||||||
assert "contre_arguments_reglementaires" in prompt
|
assert "conclusion_dispositive" in prompt
|
||||||
|
assert "codes_non_defendables" in prompt
|
||||||
|
assert "rappel_faits" in prompt
|
||||||
|
|
||||||
def test_prompt_contains_cite_exacts(self):
|
def test_prompt_contains_honesty_rules(self):
|
||||||
"""Le prompt renforcé demande des preuves exactes."""
|
"""Le prompt TIM contient les règles d'honnêteté intellectuelle."""
|
||||||
dossier = _make_dossier()
|
dossier = _make_dossier()
|
||||||
controle = _make_controle()
|
controle = _make_controle()
|
||||||
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
|
assert "HONNÊTETÉ INTELLECTUELLE" in prompt
|
||||||
assert "CITE" in prompt
|
assert "CITE" in prompt
|
||||||
assert "EXACTS" in prompt
|
assert "JAMAIS" in prompt
|
||||||
|
|
||||||
def test_prompt_contains_interdiction(self):
|
def test_prompt_contains_redaction_consignes(self):
|
||||||
"""Le prompt interdit les références inventées."""
|
"""Le prompt TIM contient les consignes de rédaction numérotées."""
|
||||||
dossier = _make_dossier()
|
dossier = _make_dossier()
|
||||||
controle = _make_controle()
|
controle = _make_controle()
|
||||||
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
assert "INTERDICTION ABSOLUE" in prompt
|
assert "MOYENS DE DÉFENSE NUMÉROTÉS" in prompt
|
||||||
|
assert "N'invente AUCUN tag" in prompt
|
||||||
|
|
||||||
def test_prompt_contains_preuves_dossier_field(self):
|
def test_prompt_contains_bio_confrontation(self):
|
||||||
"""Le format JSON demandé inclut preuves_dossier."""
|
"""Le prompt TIM inclut la section confrontation biologie."""
|
||||||
dossier = _make_dossier()
|
dossier = _make_dossier_complet()
|
||||||
controle = _make_controle()
|
controle = _make_controle()
|
||||||
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
assert "preuves_dossier" in prompt
|
assert "CONFRONTATION BIOLOGIE" in prompt
|
||||||
|
|
||||||
@patch("src.control.cpam_context.validate_code", return_value=(True, "Iléus paralytique et obstruction intestinale"))
|
@patch("src.control.cpam_context.validate_code", return_value=(True, "Iléus paralytique et obstruction intestinale"))
|
||||||
@patch("src.control.cpam_context.normalize_code", return_value="K56.0")
|
@patch("src.control.cpam_context.normalize_code", return_value="K56.0")
|
||||||
@@ -365,6 +376,96 @@ class TestFormatResponse:
|
|||||||
assert "AVERTISSEMENT" in text
|
assert "AVERTISSEMENT" in text
|
||||||
assert "Manuel Imaginaire 2025" in text
|
assert "Manuel Imaginaire 2025" in text
|
||||||
|
|
||||||
|
# --- Tests nouveau format TIM ---
|
||||||
|
|
||||||
|
def test_tim_format_memoire_defense(self):
|
||||||
|
"""Le format TIM produit un mémoire en défense structuré."""
|
||||||
|
parsed = {
|
||||||
|
"objet": "Contestation DAS — OGC 17 — Mémoire en défense",
|
||||||
|
"rappel_faits": "Patient M, 65 ans, hospitalisé 5j pour cholécystite aiguë.",
|
||||||
|
"moyens_defense": [
|
||||||
|
{
|
||||||
|
"numero": 1,
|
||||||
|
"titre": "Le DP K81.0 est justifié par la biologie",
|
||||||
|
"argument": "CRP à 180 mg/L confirme l'inflammation aiguë.",
|
||||||
|
"preuves": [
|
||||||
|
{"ref": "[BIO-1]", "fait": "CRP = 180 mg/L [norme < 5]", "signification": "inflammation sévère"}
|
||||||
|
],
|
||||||
|
"source_reglementaire": "[Guide Méthodologique MCO 2026 - p.45] citation",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"confrontation_bio": [
|
||||||
|
{"diagnostic": "K81.0", "test": "CRP", "valeur": 180, "seuil": "> 5", "verdict": "CONFIRMÉ"}
|
||||||
|
],
|
||||||
|
"asymetrie_information": "Bio non transmise à l'UCR",
|
||||||
|
"reponse_points_cpam": "La CPAM a raison sur X, mais...",
|
||||||
|
"codes_non_defendables": [],
|
||||||
|
"references": [
|
||||||
|
{"document": "Guide Méthodologique MCO 2026", "page": "45", "citation": "Le DAS doit..."}
|
||||||
|
],
|
||||||
|
"conclusion_dispositive": "Par conséquent, nous demandons le MAINTIEN du codage.",
|
||||||
|
}
|
||||||
|
text = _format_response(parsed)
|
||||||
|
|
||||||
|
assert "MÉMOIRE EN DÉFENSE" in text
|
||||||
|
assert "RAPPEL DES FAITS" in text
|
||||||
|
assert "MOYEN N°1" in text
|
||||||
|
assert "K81.0" in text
|
||||||
|
assert "Preuve" in text
|
||||||
|
assert "Source" in text
|
||||||
|
assert "CONFRONTATION BIOLOGIE" in text
|
||||||
|
assert "CONFIRMÉ" in text
|
||||||
|
assert "ASYMÉTRIE D'INFORMATION" in text
|
||||||
|
assert "RÉPONSE AUX POINTS DE LA CPAM" in text
|
||||||
|
assert "RÉFÉRENCES RÉGLEMENTAIRES" in text
|
||||||
|
assert "CONCLUSION" in text
|
||||||
|
assert "MAINTIEN" in text
|
||||||
|
|
||||||
|
def test_tim_format_codes_non_defendables(self):
|
||||||
|
"""Les codes non défendables apparaissent dans le format TIM."""
|
||||||
|
parsed = {
|
||||||
|
"moyens_defense": [],
|
||||||
|
"codes_non_defendables": [
|
||||||
|
{"code": "D50.9", "raison": "Hb = 13.5, valeur NORMALE", "recommandation": "Retrait recommandé"}
|
||||||
|
],
|
||||||
|
"conclusion_dispositive": "Nous reconnaissons...",
|
||||||
|
}
|
||||||
|
text = _format_response(parsed)
|
||||||
|
|
||||||
|
assert "CODES NON DÉFENDABLES" in text
|
||||||
|
assert "D50.9" in text
|
||||||
|
assert "Retrait recommandé" in text
|
||||||
|
|
||||||
|
def test_tim_format_confrontation_table(self):
|
||||||
|
"""Le tableau de confrontation bio est formaté en grille."""
|
||||||
|
parsed = {
|
||||||
|
"moyens_defense": [],
|
||||||
|
"confrontation_bio": [
|
||||||
|
{"diagnostic": "N17.8 IRA", "test": "Créatinine", "valeur": 280, "seuil": "> 130", "verdict": "CONFIRMÉ"},
|
||||||
|
{"diagnostic": "E87.1 HypoNa", "test": "Sodium", "valeur": 138, "seuil": "< 135", "verdict": "NON CONFIRMÉ"},
|
||||||
|
],
|
||||||
|
"conclusion_dispositive": "Conclusion...",
|
||||||
|
}
|
||||||
|
text = _format_response(parsed)
|
||||||
|
|
||||||
|
assert "N17.8" in text
|
||||||
|
assert "Créatinine" in text
|
||||||
|
assert "CONFIRMÉ" in text
|
||||||
|
assert "NON CONFIRMÉ" in text
|
||||||
|
assert "┌" in text # table border
|
||||||
|
|
||||||
|
def test_tim_retrocompat_legacy_format(self):
|
||||||
|
"""L'ancien format (sans moyens_defense) utilise le rendu legacy."""
|
||||||
|
parsed = {
|
||||||
|
"analyse_contestation": "Analyse...",
|
||||||
|
"contre_arguments_medicaux": "Arguments médicaux...",
|
||||||
|
"conclusion": "Conclusion...",
|
||||||
|
}
|
||||||
|
text = _format_response(parsed)
|
||||||
|
|
||||||
|
assert "CONTRE-ARGUMENTS MÉDICAUX" in text
|
||||||
|
assert "MÉMOIRE EN DÉFENSE" not in text
|
||||||
|
|
||||||
|
|
||||||
class TestValidateReferences:
|
class TestValidateReferences:
|
||||||
def test_valid_reference_no_warning(self):
|
def test_valid_reference_no_warning(self):
|
||||||
@@ -1061,6 +1162,81 @@ class TestCheckDasBioCoherence:
|
|||||||
assert "NORMAL" in prompt
|
assert "NORMAL" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestBioConfrontation:
|
||||||
|
"""Tests pour la confrontation biologie/diagnostic TIM."""
|
||||||
|
|
||||||
|
def test_confrontation_with_matching_bio(self):
|
||||||
|
"""Code avec bio disponible et pathologique → CONFIRMÉ."""
|
||||||
|
dossier = DossierMedical(
|
||||||
|
source_file="test.pdf",
|
||||||
|
sejour=Sejour(sexe="M", age=65),
|
||||||
|
diagnostic_principal=Diagnostic(texte="IRA", cim10_suggestion="N17.8"),
|
||||||
|
biologie_cle=[
|
||||||
|
BiologieCle(test="Créatinine", valeur="280 µmol/L", anomalie=True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
controle = ControleCPAM(
|
||||||
|
numero_ogc=1, titre="Test", arg_ucr="Test",
|
||||||
|
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
|
||||||
|
)
|
||||||
|
result = _build_bio_confrontation(dossier, controle)
|
||||||
|
|
||||||
|
assert "N17" in result
|
||||||
|
assert "Créatinine" in result
|
||||||
|
assert "280" in result
|
||||||
|
assert "CONFIRMÉ" in result
|
||||||
|
|
||||||
|
def test_confrontation_normal_value(self):
|
||||||
|
"""Code avec bio NORMALE → NON CONFIRMÉ."""
|
||||||
|
dossier = DossierMedical(
|
||||||
|
source_file="test.pdf",
|
||||||
|
sejour=Sejour(sexe="F", age=70),
|
||||||
|
diagnostic_principal=Diagnostic(texte="Hyponatrémie", cim10_suggestion="E87.1"),
|
||||||
|
biologie_cle=[
|
||||||
|
BiologieCle(test="Sodium", valeur="138 mmol/L", anomalie=False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
controle = ControleCPAM(
|
||||||
|
numero_ogc=1, titre="Test", arg_ucr="Test",
|
||||||
|
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
|
||||||
|
)
|
||||||
|
result = _build_bio_confrontation(dossier, controle)
|
||||||
|
|
||||||
|
assert "E87.1" in result
|
||||||
|
assert "NON CONFIRMÉ" in result
|
||||||
|
|
||||||
|
def test_confrontation_missing_bio(self):
|
||||||
|
"""Code avec bio absente → NON DISPONIBLE."""
|
||||||
|
dossier = DossierMedical(
|
||||||
|
source_file="test.pdf",
|
||||||
|
sejour=Sejour(sexe="M", age=50),
|
||||||
|
diagnostic_principal=Diagnostic(texte="IRA", cim10_suggestion="N17.8"),
|
||||||
|
biologie_cle=[],
|
||||||
|
)
|
||||||
|
controle = ControleCPAM(
|
||||||
|
numero_ogc=1, titre="Test", arg_ucr="Test",
|
||||||
|
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
|
||||||
|
)
|
||||||
|
result = _build_bio_confrontation(dossier, controle)
|
||||||
|
|
||||||
|
assert "NON DISPONIBLE" in result
|
||||||
|
|
||||||
|
def test_confrontation_no_threshold(self):
|
||||||
|
"""Code sans seuil dans _BIO_THRESHOLDS → message par défaut."""
|
||||||
|
dossier = DossierMedical(
|
||||||
|
source_file="test.pdf",
|
||||||
|
sejour=Sejour(),
|
||||||
|
diagnostic_principal=Diagnostic(texte="Fracture", cim10_suggestion="S72.0"),
|
||||||
|
)
|
||||||
|
controle = ControleCPAM(
|
||||||
|
numero_ogc=1, titre="Test", arg_ucr="Test",
|
||||||
|
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
|
||||||
|
)
|
||||||
|
result = _build_bio_confrontation(dossier, controle)
|
||||||
|
|
||||||
|
assert "Aucun seuil" in result
|
||||||
|
|
||||||
|
|
||||||
class TestPatientContext:
|
class TestPatientContext:
|
||||||
"""Tests pour le contexte patient dans le prompt."""
|
"""Tests pour le contexte patient dans le prompt."""
|
||||||
|
|
||||||
@@ -1103,14 +1279,14 @@ class TestPatientContext:
|
|||||||
assert "ADMISSION EN URGENCE" in prompt
|
assert "ADMISSION EN URGENCE" in prompt
|
||||||
|
|
||||||
def test_context_consigne_in_prompt(self):
|
def test_context_consigne_in_prompt(self):
|
||||||
"""Le prompt contient une consigne sur le contexte clinique."""
|
"""Le prompt TIM contient les consignes sur le contexte patient."""
|
||||||
dossier = _make_dossier()
|
dossier = _make_dossier()
|
||||||
controle = _make_controle()
|
controle = _make_controle()
|
||||||
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
assert "CONTEXTE CLINIQUE" in prompt
|
assert "CONTEXTE ADMINISTRATIF" in prompt
|
||||||
assert "ÂGE" in prompt
|
assert "pédiatrie" in prompt.lower() or "Pédiatrie" in prompt
|
||||||
assert "MODE D'ENTRÉE" in prompt
|
assert "urgence" in prompt.lower()
|
||||||
|
|
||||||
|
|
||||||
class TestExtractionPass:
|
class TestExtractionPass:
|
||||||
|
|||||||
Reference in New Issue
Block a user