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:
dom
2026-03-04 11:57:07 +01:00
parent 795110d2e6
commit ce7a9650af
19 changed files with 1681 additions and 418 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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",
] ]

View File

@@ -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"
}}"""

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()

View File

@@ -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;">&rarr; {{ 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;">&rarr; {{ p.interpretation }}</span></li>
{% endfor %} {% endfor %}
</ul> </ul>
</details> </details>

View File

@@ -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."""

View File

@@ -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: