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:
125
src/config.py
125
src/config.py
@@ -28,6 +28,11 @@ CONFIG_DIR = BASE_DIR / "config"
|
||||
REFERENCE_RANGES_PATH = CONFIG_DIR / "reference_ranges.yaml"
|
||||
BIO_RULES_PATH = CONFIG_DIR / "bio_rules.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_BASE_PATH = RULES_DIR / "base.yaml"
|
||||
RULES_ENABLED_PATH = RULES_DIR / "enabled.yaml"
|
||||
@@ -128,6 +133,7 @@ UPLOAD_MAX_SIZE_MB = 50
|
||||
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
|
||||
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
|
||||
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json"
|
||||
BIO_CONCEPTS_PATH = BASE_DIR / "data" / "bio_concepts.json"
|
||||
CMA_LEVELS_PATH = BASE_DIR / "data" / "cma_levels.json"
|
||||
CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json"
|
||||
CIM10_PDF = Path(os.environ.get("T2A_CIM10_PDF", "/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf"))
|
||||
@@ -247,6 +253,124 @@ def load_bio_rules() -> Dict[str, Any]:
|
||||
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) ---
|
||||
|
||||
|
||||
@@ -827,6 +951,7 @@ class VetoIssue(BaseModel):
|
||||
severity: str # HARD | MEDIUM | LOW
|
||||
where: str
|
||||
message: str
|
||||
citation: Optional[str] = None
|
||||
|
||||
|
||||
class VetoReport(BaseModel):
|
||||
|
||||
@@ -195,6 +195,118 @@ def _build_tagged_context(dossier: DossierMedical) -> tuple[str, dict[str, str]]
|
||||
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
|
||||
_BIO_INTERPRETATION: dict[str, dict[str, str]] = {
|
||||
# --- Hépatique / digestif ---
|
||||
@@ -648,6 +760,9 @@ def _build_cpam_prompt(
|
||||
"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)
|
||||
bio_summary = _build_bio_summary(dossier)
|
||||
if bio_summary:
|
||||
@@ -736,5 +851,7 @@ def _build_cpam_prompt(
|
||||
codes_autorises_str=codes_autorises_str,
|
||||
sources_text=sources_text,
|
||||
extraction_str=extraction_str,
|
||||
bio_confrontation_str=bio_confrontation,
|
||||
numero_ogc=controle.numero_ogc,
|
||||
)
|
||||
return prompt, tag_map
|
||||
|
||||
@@ -38,8 +38,10 @@ from .cpam_context import ( # noqa: F401
|
||||
_get_code_label,
|
||||
_get_cim10_definitions,
|
||||
_BIO_INTERPRETATION,
|
||||
_BIO_THRESHOLDS,
|
||||
_assess_dossier_strength,
|
||||
_build_bio_summary,
|
||||
_build_bio_confrontation,
|
||||
_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
|
||||
@@ -120,6 +122,11 @@ def generate_cpam_response(
|
||||
|
||||
# 1. Passe 1 — Extraction structurée (compréhension avant argumentation)
|
||||
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
|
||||
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")
|
||||
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
|
||||
sanitized = _sanitize_unauthorized_codes(result, dossier, controle)
|
||||
if sanitized:
|
||||
@@ -175,6 +188,16 @@ def generate_cpam_response(
|
||||
logger.warning(" CPAM : %d code(s) hors périmètre", len(code_warnings))
|
||||
|
||||
# 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] = []
|
||||
validation = _validate_adversarial(result, tag_map, controle)
|
||||
if validation and not validation.get("coherent", True):
|
||||
@@ -186,48 +209,51 @@ def generate_cpam_response(
|
||||
if adversarial_warnings:
|
||||
adversarial_warnings.append(f"Score de confiance : {score}/10")
|
||||
|
||||
# 8b. Boucle de correction (max 1 retry)
|
||||
if (validation
|
||||
and not validation.get("coherent", True)
|
||||
and validation.get("score_confiance", 10) <= 5
|
||||
and rule_enabled("RULE-CPAM-CORRECTION-LOOP")):
|
||||
# 8b. Boucle de correction (max 2 retries)
|
||||
max_corrections = 2
|
||||
for attempt in range(max_corrections):
|
||||
if not (validation
|
||||
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", [])
|
||||
logger.warning(" Score adversarial %s/10 — correction en cours (%d erreur(s))",
|
||||
validation.get("score_confiance"), len(erreurs_v))
|
||||
logger.warning(" Score adversarial %s/10 — correction %d/%d (%d erreur(s))",
|
||||
validation.get("score_confiance"), attempt + 1, max_corrections, len(erreurs_v))
|
||||
|
||||
correction_prompt = _build_correction_prompt(prompt, result, validation)
|
||||
corrected = call_ollama(correction_prompt, temperature=0.0, max_tokens=16000, role="cpam")
|
||||
if corrected is None:
|
||||
corrected = call_anthropic(correction_prompt, temperature=0.0, max_tokens=16000)
|
||||
|
||||
if corrected:
|
||||
# Re-valider la correction
|
||||
validation2 = _validate_adversarial(corrected, tag_map, controle)
|
||||
score2 = validation2.get("score_confiance", 0) if validation2 else 0
|
||||
score1 = validation.get("score_confiance", 0)
|
||||
if not corrected:
|
||||
break
|
||||
|
||||
if score2 > score1:
|
||||
logger.info(" Correction acceptée (score %s → %s)", score1, score2)
|
||||
result = corrected
|
||||
validation = validation2
|
||||
# Sanitiser + recalculer les warnings
|
||||
_sanitize_unauthorized_codes(result, dossier, controle)
|
||||
ref_warnings = _validate_references(result, sources)
|
||||
grounding_warnings = _validate_grounding(result, tag_map)
|
||||
code_warnings = _validate_codes_in_response(result, dossier, controle)
|
||||
adversarial_warnings = []
|
||||
if validation and not validation.get("coherent", True):
|
||||
for e in validation.get("erreurs", []):
|
||||
if isinstance(e, str) and e.strip():
|
||||
adversarial_warnings.append(f"Incohérence détectée : {e}")
|
||||
if adversarial_warnings:
|
||||
adversarial_warnings.append(
|
||||
f"Score de confiance : {validation.get('score_confiance', '?')}/10"
|
||||
)
|
||||
else:
|
||||
logger.warning(" Correction rejetée (score %s → %s) — conserve l'original",
|
||||
score1, score2)
|
||||
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:
|
||||
logger.info(" Correction %d acceptée (score %s → %s)", attempt + 1, score1, score2)
|
||||
result = corrected
|
||||
validation = validation2
|
||||
_sanitize_unauthorized_codes(result, dossier, controle)
|
||||
ref_warnings = _validate_references(result, sources)
|
||||
grounding_warnings = _validate_grounding(result, tag_map)
|
||||
code_warnings = _validate_codes_in_response(result, dossier, controle)
|
||||
adversarial_warnings = []
|
||||
if validation and not validation.get("coherent", True):
|
||||
for e in validation.get("erreurs", []):
|
||||
if isinstance(e, str) and e.strip():
|
||||
adversarial_warnings.append(f"Incohérence détectée : {e}")
|
||||
if adversarial_warnings:
|
||||
adversarial_warnings.append(
|
||||
f"Score de confiance : {validation.get('score_confiance', '?')}/10"
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
"""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.
|
||||
|
||||
@@ -44,24 +48,40 @@ def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[st
|
||||
return []
|
||||
|
||||
warnings: list[str] = []
|
||||
preuves = response_data.get("preuves_dossier")
|
||||
if not preuves or not isinstance(preuves, list):
|
||||
return warnings
|
||||
|
||||
for p in preuves:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
ref = p.get("ref", "")
|
||||
def _check_ref(ref: str, context: str) -> None:
|
||||
if not ref:
|
||||
continue
|
||||
if ref not in tag_map:
|
||||
matched_tag = _fuzzy_match_ref(ref, tag_map)
|
||||
if matched_tag:
|
||||
logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag)
|
||||
continue # pas de warning
|
||||
valeur = p.get("valeur", "?")
|
||||
warnings.append(f"Preuve [{ref}] non traçable (« {valeur} »)")
|
||||
logger.warning("Grounding : preuve [%s] introuvable dans les tags du dossier", ref)
|
||||
return
|
||||
# Nettoyer les crochets si présents (nouveau format utilise "[BIO-1]")
|
||||
clean_ref = ref.strip("[]")
|
||||
if clean_ref in tag_map or ref in tag_map:
|
||||
return
|
||||
matched_tag = _fuzzy_match_ref(clean_ref, tag_map)
|
||||
if matched_tag:
|
||||
logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag)
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
# 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 = (
|
||||
# Ancien format
|
||||
"analyse_contestation",
|
||||
"contre_arguments_medicaux",
|
||||
"contre_arguments_asymetrie",
|
||||
"contre_arguments_reglementaires",
|
||||
"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:
|
||||
parsed[key] = new_val
|
||||
|
||||
# Sanitiser aussi les preuves_dossier.valeur
|
||||
# Sanitiser aussi les preuves_dossier.valeur (ancien format)
|
||||
preuves = parsed.get("preuves_dossier")
|
||||
if preuves and isinstance(preuves, list):
|
||||
for p in preuves:
|
||||
@@ -240,6 +267,27 @@ def _sanitize_unauthorized_codes(
|
||||
if new_v != 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:
|
||||
for code in removed:
|
||||
norm = normalize_code(code)
|
||||
@@ -275,7 +323,7 @@ def _validate_codes_in_response(
|
||||
if val and isinstance(val, str):
|
||||
text_fields.append(val)
|
||||
|
||||
# Preuves du dossier — valeurs
|
||||
# Preuves du dossier — valeurs (ancien format)
|
||||
preuves = parsed.get("preuves_dossier")
|
||||
if preuves and isinstance(preuves, list):
|
||||
for p in preuves:
|
||||
@@ -284,6 +332,16 @@ def _validate_codes_in_response(
|
||||
if v and isinstance(v, str):
|
||||
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)
|
||||
found_codes = _CIM10_CODE_RE.findall(combined_text)
|
||||
|
||||
@@ -330,6 +388,18 @@ def _validate_adversarial(
|
||||
"""
|
||||
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
|
||||
if tag_map:
|
||||
factual_lines = "\n".join(f" [{tag}] {content}" for tag, content in tag_map.items())
|
||||
@@ -341,8 +411,8 @@ def _validate_adversarial(
|
||||
try:
|
||||
response_json = _json.dumps(response_data, ensure_ascii=False, indent=None)
|
||||
# Tronquer si trop long pour le prompt de validation
|
||||
if len(response_json) > 3000:
|
||||
response_json = response_json[:3000] + "..."
|
||||
if len(response_json) > 10000:
|
||||
response_json = response_json[:10000] + "..."
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Validation adversariale : impossible de sérialiser la réponse")
|
||||
return None
|
||||
@@ -365,9 +435,9 @@ def _validate_adversarial(
|
||||
)
|
||||
|
||||
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:
|
||||
result = call_anthropic(prompt, temperature=0.0, max_tokens=3000)
|
||||
result = call_anthropic(prompt, temperature=0.0, max_tokens=6000)
|
||||
if result is None:
|
||||
logger.warning(" Validation adversariale échouée — LLM indisponible")
|
||||
return None
|
||||
@@ -407,14 +477,20 @@ def _build_correction_prompt(
|
||||
erreurs = adversarial_result.get("erreurs", [])
|
||||
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 = {}
|
||||
# Ancien format
|
||||
for key in ("analyse_contestation", "contre_arguments_medicaux",
|
||||
"contre_arguments_asymetrie", "contre_arguments_reglementaires",
|
||||
"conclusion"):
|
||||
val = original_response.get(key)
|
||||
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 "")
|
||||
|
||||
try:
|
||||
@@ -522,13 +598,182 @@ def _assess_quality_tier(
|
||||
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(
|
||||
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 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 = []
|
||||
|
||||
# Bandeau qualité si tier C
|
||||
@@ -543,12 +788,12 @@ def _format_response(
|
||||
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
|
||||
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")
|
||||
if 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")
|
||||
if preuves and isinstance(preuves, list):
|
||||
preuves_lines = []
|
||||
@@ -577,7 +822,7 @@ def _format_response(
|
||||
if 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")
|
||||
if refs:
|
||||
if isinstance(refs, list):
|
||||
@@ -599,7 +844,18 @@ def _format_response(
|
||||
if 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:
|
||||
critiques = [w for w in categorized_warnings if w.startswith("[CRITIQUE]")]
|
||||
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)
|
||||
)
|
||||
elif ref_warnings:
|
||||
# Fallback ancien format
|
||||
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
|
||||
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")
|
||||
|
||||
return "\n\n".join(sections)
|
||||
return sections
|
||||
|
||||
@@ -4,10 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
from ..config import BiologieCle, DossierMedical, load_lab_value_sanity
|
||||
from .bio_normals import BIO_NORMALS, _is_abnormal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _norm_key(s: str) -> str:
|
||||
"""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
|
||||
|
||||
|
||||
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:
|
||||
"""Extrait des résultats biologiques clés.
|
||||
|
||||
@@ -90,12 +188,20 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
|
||||
# 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"(?:[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"(?:[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"[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"\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,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Complément par recherche vectorielle (Synonymes) ---
|
||||
_extract_biologie_faiss(text, dossier)
|
||||
|
||||
@@ -96,6 +96,46 @@ def extract_medical_info(
|
||||
if use_rag:
|
||||
_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
|
||||
_validate_ccam(dossier)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Optional
|
||||
|
||||
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__)
|
||||
|
||||
@@ -112,11 +112,14 @@ def _paths(kind: str) -> tuple[Path, Path]:
|
||||
kind:
|
||||
- "ref" : référentiels
|
||||
- "proc" : procédures
|
||||
- "bio" : concepts biologiques
|
||||
- "all" : legacy (faiss.index)
|
||||
"""
|
||||
kind = (kind or "ref").lower()
|
||||
if kind == "proc":
|
||||
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":
|
||||
return (RAG_INDEX_DIR / "faiss.index", RAG_INDEX_DIR / "metadata.json")
|
||||
# ref (default)
|
||||
@@ -470,6 +473,25 @@ def _chunk_cim10_alpha(pdf_path: Path) -> list[Chunk]:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -489,18 +511,24 @@ def build_index(force: bool = False) -> None:
|
||||
|
||||
ref_index_path, ref_meta_path = _paths("ref")
|
||||
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
|
||||
ref_ok = ref_index_path.exists() and ref_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()
|
||||
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)
|
||||
return
|
||||
|
||||
# Collecter les chunks
|
||||
ref_chunks: list[Chunk] = []
|
||||
proc_chunks: list[Chunk] = []
|
||||
bio_chunks: list[Chunk] = []
|
||||
|
||||
# Concepts biologiques
|
||||
bio_chunks.extend(_chunk_bio_concepts())
|
||||
|
||||
# CIM-10 (référentiel)
|
||||
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(proc_chunks, proc_index_path, proc_meta_path, "proc")
|
||||
_write_index(bio_chunks, bio_index_path, bio_meta_path, "bio")
|
||||
|
||||
# Invalider les singletons
|
||||
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).
|
||||
|
||||
Args:
|
||||
kind: "ref" | "proc" | "all".
|
||||
kind: "ref" | "proc" | "bio" | "all".
|
||||
|
||||
Returns:
|
||||
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)
|
||||
|
||||
# Backwards compat : si ref/proc absent, fallback sur all
|
||||
if kind in ("ref", "proc") and (not index_path.exists() or not meta_path.exists()):
|
||||
# Backwards compat : si ref/proc/bio absent, fallback sur all
|
||||
if kind in ("ref", "proc", "bio") and (not index_path.exists() or not meta_path.exists()):
|
||||
legacy_idx, legacy_meta = _paths("all")
|
||||
if legacy_idx.exists() and legacy_meta.exists():
|
||||
logger.warning("Index %s absent — fallback legacy faiss.index", kind)
|
||||
|
||||
@@ -561,7 +561,13 @@ def enrich_diagnostic(
|
||||
sources = search_similar(diagnostic.texte, top_k=10)
|
||||
|
||||
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
|
||||
|
||||
# 3. Stocker les sources RAG
|
||||
|
||||
@@ -8,6 +8,7 @@ from .templates import (
|
||||
CPAM_EXTRACTION,
|
||||
CPAM_ARGUMENTATION,
|
||||
CPAM_ADVERSARIAL,
|
||||
DP_RANKER_CONSTRAINED,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -18,4 +19,5 @@ __all__ = [
|
||||
"CPAM_EXTRACTION",
|
||||
"CPAM_ARGUMENTATION",
|
||||
"CPAM_ADVERSARIAL",
|
||||
"DP_RANKER_CONSTRAINED",
|
||||
]
|
||||
|
||||
@@ -14,9 +14,11 @@ Variables par template :
|
||||
decision_ucr, dp_ucr_line, da_ucr_line
|
||||
CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre,
|
||||
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,
|
||||
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()
|
||||
# Rôle : cpam | Température : 0.1 | max_tokens : 4000
|
||||
# Rôle : cpam | Température : 0.1 | max_tokens : 16000
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CPAM_ARGUMENTATION = """\
|
||||
Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A.
|
||||
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.
|
||||
Tu es un médecin DIM senior expert en contentieux T2A. Tu rédiges un MÉMOIRE EN DÉFENSE \
|
||||
structuré et argumenté pour répondre à la contestation CPAM ci-dessous.
|
||||
|
||||
IMPORTANT — CRÉDIBILITÉ DE L'ANALYSE :
|
||||
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.
|
||||
Ta méthode suit les 5 passes de raisonnement expert TIM :
|
||||
|
||||
IMPORTANT — CODES CIM-10 :
|
||||
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).
|
||||
Chaque argument doit désigner précisément quel code est défendu ou contesté, avec son libellé complet.
|
||||
PASSE 1 — CONTEXTE ADMINISTRATIF :
|
||||
Analyse le contexte du séjour (âge, sexe, durée, mode d'entrée/sortie, actes) pour cadrer \
|
||||
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 :
|
||||
{dossier_str}
|
||||
PASSE 2 — MOTIF D'HOSPITALISATION RÉEL :
|
||||
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}
|
||||
{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) :
|
||||
{arg_ucr}
|
||||
|
||||
DÉCISION UCR : {decision_ucr}
|
||||
|
||||
CODES CONTESTÉS :
|
||||
{codes_str}
|
||||
CODES EN JEU : {codes_str}
|
||||
{definitions_str}
|
||||
{codes_autorises_str}
|
||||
|
||||
SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
|
||||
{sources_text}
|
||||
SOURCES RÉGLEMENTAIRES : {sources_text}
|
||||
{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 :
|
||||
- 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
|
||||
CONSIGNES DE RÉDACTION :
|
||||
|
||||
ÉTAPE 1 — ANALYSE HONNÊTE (avant de contre-argumenter) :
|
||||
- Identifie ce que la CPAM a compris correctement dans le dossier
|
||||
- Reconnais les points où leur raisonnement est fondé, même partiellement
|
||||
- Explique ENSUITE pourquoi ces points ne justifient pas leur conclusion
|
||||
1. STRUCTURE EN MOYENS DE DÉFENSE NUMÉROTÉS (pas de prose libre)
|
||||
2. Chaque moyen = un argument autonome avec sa preuve FORMELLEMENT DOCUMENTÉE dans le dossier
|
||||
3. CITE les codes CIM-10 avec libellé complet (ex: N17.8 — Autre insuffisance rénale aiguë)
|
||||
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 :
|
||||
- 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 :
|
||||
Réponds UNIQUEMENT avec un objet JSON :
|
||||
{{
|
||||
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
|
||||
"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)",
|
||||
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage, en expliquant pourquoi les points d'accord ne suffisent pas à invalider le codage",
|
||||
"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"}}
|
||||
"objet": "Contestation {titre} — OGC {numero_ogc} — Mémoire en défense",
|
||||
"rappel_faits": "Résumé factuel du séjour en 3-5 lignes : motif, actes, durée, issue",
|
||||
"moyens_defense": [
|
||||
{{
|
||||
"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": [
|
||||
{{"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 = """\
|
||||
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 :
|
||||
{response_json}
|
||||
@@ -329,11 +358,17 @@ CODES CONTESTÉS :
|
||||
{da_ucr_line}
|
||||
|
||||
Vérifie STRICTEMENT :
|
||||
1. Chaque valeur bio/imagerie/traitement citée dans les preuves existe 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é)
|
||||
3. La conclusion est cohérente avec l'argumentation développée
|
||||
4. Les points d'accord ne contredisent pas les contre-arguments
|
||||
5. Les codes CIM-10 mentionnés dans la conclusion sont cohérents avec le reste
|
||||
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é)
|
||||
3. AUCUNE valeur NORMALE n'est présentée comme pathologique
|
||||
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 :
|
||||
{{
|
||||
@@ -341,3 +376,36 @@ Réponds UNIQUEMENT en JSON :
|
||||
"erreurs": ["description précise de chaque incohérence trouvée"],
|
||||
"score_confiance": 0 à 10
|
||||
}}"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. DP_RANKER_CONSTRAINED — NUKE-3 sélection DP dans une liste fermée
|
||||
# Source : dp_selector.py _llm_rank()
|
||||
# Rôle : coding | Température : 0.0 | max_tokens : 1000
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DP_RANKER_CONSTRAINED = """\
|
||||
Tu es un médecin DIM expert en codage PMSI. Tu dois choisir le Diagnostic Principal (DP) \
|
||||
parmi la liste FERMÉE de {n_candidates} candidats ci-dessous.
|
||||
|
||||
RÈGLES STRICTES :
|
||||
1. Le DP reflète le MOTIF PRINCIPAL de prise en charge pendant ce séjour
|
||||
2. Un acte seul (cholécystectomie, biopsie…) NE PEUT PAS être DP s'il existe un candidat textuel
|
||||
3. Un symptôme (R00-R99) NE PEUT PAS être DP si une étiologie candidate existe dans la liste
|
||||
4. Une comorbidité chronique (HTA, diabète, BPCO) NE PEUT PAS être DP sauf prise en charge ACTIVE
|
||||
5. Tu DOIS choisir un index de la liste — JAMAIS de réponse hors liste
|
||||
|
||||
CANDIDATS :
|
||||
{candidates_str}
|
||||
|
||||
CONTEXTE CLINIQUE :
|
||||
{ctx_str}
|
||||
|
||||
Réponds UNIQUEMENT en JSON :
|
||||
{{
|
||||
"chosen_index": N,
|
||||
"confidence": "high|medium|low",
|
||||
"verdict": "CONFIRMED|REVIEW",
|
||||
"evidence": ["raison 1", "raison 2"],
|
||||
"reason": "explication courte justifiant le choix"
|
||||
}}"""
|
||||
|
||||
@@ -161,7 +161,6 @@ def _threshold_high(cfg: dict, test: str, age_band: str, doc_hi: float | None) -
|
||||
|
||||
def _is_sodium_test(test: str) -> bool:
|
||||
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:
|
||||
return True
|
||||
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))
|
||||
|
||||
|
||||
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(
|
||||
dossier: DossierMedical,
|
||||
matcher,
|
||||
@@ -369,6 +493,30 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
_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 ---
|
||||
if rule_enabled("RULE-D50-NEEDS-IRON"):
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
@@ -427,186 +575,9 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
||||
applied_rules=["RULE-D69.6-PLT-NORMAL"],
|
||||
)
|
||||
|
||||
# --- Pack "bio": contradictions simples Na/K -> ruled_out (piloté par config/bio_rules.yaml)
|
||||
# Objectif: réduire VETO-09 en écartant les diagnostics "hyper/hypo" quand la valeur est clairement normale.
|
||||
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 {}
|
||||
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"],
|
||||
)
|
||||
# --- Pack "bio": contradictions pilotées par config/bio_rules.yaml
|
||||
cfg_ranges = load_reference_ranges()
|
||||
_apply_bio_rules_gen(dossier, cfg_ranges)
|
||||
|
||||
# --- Règle: promotion DAS→DP quand aucun DP n'a été extrait ---
|
||||
if rule_enabled("RULE-DAS-TO-DP"):
|
||||
@@ -638,6 +609,10 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
||||
),
|
||||
)
|
||||
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(
|
||||
"PROMOTE_DP: DAS %s (%s) promu en DP — aucun DP extrait",
|
||||
best.cim10_final, best.texte,
|
||||
|
||||
@@ -9,10 +9,13 @@ audit-able, et indépendant des modèles.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..config import (
|
||||
ActeCCAM,
|
||||
BiologieCle,
|
||||
@@ -22,6 +25,11 @@ from ..config import (
|
||||
VetoReport,
|
||||
rule_enabled,
|
||||
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] = []
|
||||
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)
|
||||
if not rule_enabled(veto):
|
||||
return
|
||||
@@ -233,14 +241,17 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
||||
if key in seen_issue_keys:
|
||||
return
|
||||
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
|
||||
# -----------------------------
|
||||
dp = dossier.diagnostic_principal
|
||||
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")
|
||||
|
||||
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":
|
||||
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
|
||||
# -------------------------------------------------
|
||||
@@ -383,7 +436,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
||||
if z3 not in _Z_DP_WHITELIST:
|
||||
add("VETO-20", "MEDIUM", "diagnostic_principal",
|
||||
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
|
||||
@@ -399,7 +453,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
||||
severity = "LOW" if has_precise else "MEDIUM"
|
||||
add("VETO-21", severity, "diagnostic_principal",
|
||||
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
|
||||
@@ -415,26 +470,39 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
||||
if das_cat == dp_cat and das.cim10_suggestion != dp.cim10_suggestion:
|
||||
add("VETO-22", "LOW", f"diagnostics_associes[{i}]",
|
||||
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)
|
||||
# Règle PMSI : codes incompatibles dans le même séjour.
|
||||
# VETO-25 & VETO-26 : Cohérence inter-diagnostics (conflits)
|
||||
# -------------------------------------------------
|
||||
all_codes = set()
|
||||
conflict_cfg = load_diagnostic_conflicts()
|
||||
codes_full = set()
|
||||
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:
|
||||
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))
|
||||
|
||||
# Exclusions mutuelles
|
||||
for rule in conflict_cfg.get("mutual_exclusions", []):
|
||||
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:
|
||||
add("VETO-25", rule.get("severity", "MEDIUM"), "diagnostics_associes",
|
||||
f"{rule.get('name')}: {rule.get('message')} ({', '.join(matches)})",
|
||||
citation=rule.get("atih_ref"))
|
||||
|
||||
_MUTUAL_EXCLUSIONS = [
|
||||
({"E10"}, {"E11"}, "Diabète type 1 (E10) et type 2 (E11) mutuellement exclusifs"),
|
||||
({"I10"}, {"I11", "I12", "I13"}, "HTA essentielle (I10) incompatible avec HTA secondaire (I11/I12/I13)"),
|
||||
]
|
||||
for group_a, group_b, msg in _MUTUAL_EXCLUSIONS:
|
||||
if (all_codes & group_a) and (all_codes & group_b):
|
||||
add("VETO-23", "MEDIUM", "diagnostics_associes", msg)
|
||||
# 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)
|
||||
@@ -443,13 +511,125 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
||||
# -------------------------------------------------
|
||||
has_injury = any(
|
||||
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:
|
||||
add("VETO-24", "LOW", "diagnostics_associes",
|
||||
"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',
|
||||
@@ -481,9 +661,9 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
||||
if it.severity == "HARD":
|
||||
score -= 30
|
||||
elif it.severity == "MEDIUM":
|
||||
score -= 10
|
||||
score -= 15
|
||||
else:
|
||||
score -= 3
|
||||
score -= 5
|
||||
score = max(0, min(100, score))
|
||||
|
||||
return VetoReport(verdict=verdict, score_contestabilite=score, issues=issues)
|
||||
|
||||
@@ -385,9 +385,9 @@ def decision_badge(decision) -> Markup:
|
||||
labels = {
|
||||
"DOWNGRADE": ("Rétrogradé", "#fef3c7", "#92400e"),
|
||||
"REMOVE": ("Supprimé", "#fee2e2", "#dc2626"),
|
||||
"RULED_OUT": ("Écarté", "#f1f5f9", "#64748b"),
|
||||
"NEED_INFO": ("Info requise", "#fff7ed", "#c2410c"),
|
||||
"PROMOTE_DP": ("Promu DP", "#dbeafe", "#1d4ed8"),
|
||||
"RULED_OUT": ("Écarté (Contradiction)", "#f1f5f9", "#64748b"),
|
||||
"NEED_INFO": ("Preuve manquante", "#fff7ed", "#c2410c"),
|
||||
"PROMOTE_DP": ("Promu en DP", "#dbeafe", "#1d4ed8"),
|
||||
}
|
||||
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>')
|
||||
@@ -428,6 +428,30 @@ def format_cpam_text(text: str | None) -> Markup:
|
||||
# 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:
|
||||
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_cpam_text"] = format_cpam_text
|
||||
app.jinja_env.filters["decision_badge"] = decision_badge
|
||||
app.jinja_env.filters["human_where"] = human_where
|
||||
|
||||
ccam_dict = load_ccam_dict()
|
||||
|
||||
|
||||
@@ -49,6 +49,64 @@
|
||||
{% endif %}
|
||||
</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 ---- #}
|
||||
{% 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 %}
|
||||
@@ -301,22 +359,6 @@
|
||||
</div>
|
||||
{% 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) ---- #}
|
||||
{% if dossier.veto_report %}
|
||||
{% set vr = dossier.veto_report %}
|
||||
@@ -328,36 +370,37 @@
|
||||
{% set vr_color = '#ef4444' %}
|
||||
{% endif %}
|
||||
<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;">
|
||||
{% 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' %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
<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>
|
||||
<span style="font-weight:600;">{{ vr.score_contestabilite }}/100</span>
|
||||
<span style="font-weight:600;">Score : {{ vr.score_contestabilite }}/100</span>
|
||||
</div>
|
||||
{% if vr.issues %}
|
||||
<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;">
|
||||
<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>
|
||||
{% for issue in vr.issues %}
|
||||
<tr>
|
||||
<td><code style="font-size:0.75rem;">{{ issue.veto }}</code></td>
|
||||
<td>
|
||||
{% if issue.severity == 'HARD' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">HARD</span>
|
||||
{% elif issue.severity == 'MEDIUM' %}<span class="badge" style="background:#fef3c7;color:#92400e;">MEDIUM</span>
|
||||
{% else %}<span class="badge" style="background:#f0fdf4;color:#166534;">LOW</span>{% endif %}
|
||||
{% 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;">À vérifier</span>
|
||||
{% else %}<span class="badge" style="background:#f0fdf4;color:#166534;">Optimisation</span>{% endif %}
|
||||
</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.75rem;color:#475569;font-style:italic;">{{ issue.citation or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</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>
|
||||
<ul style="margin:0.15rem 0 0 0;padding-left:1rem;font-size:0.75rem;">
|
||||
{% for p in das.preuves_cliniques %}
|
||||
<li><span style="font-weight:600;color:#0369a1;">[{{ p.type }}]</span> {{ p.element }} <span style="color:#64748b;">→ {{ p.interpretation }}</span></li>
|
||||
<li><span style="font-weight:600;{% if p.type == 'biologie' %}color:#0891b2;{% else %}color:#0369a1;{% endif %}">[{{ p.type }}]</span> {{ p.element }} <span style="color:#64748b;">→ {{ p.interpretation }}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user