feat: guardian déterministe + config modèles locaux + prompt TIM R1-R6
Guardian déterministe post-LLM (0 appel modèle, <1ms) : - Corrige les valeurs bio hallucinées via confrontation dossier - Step 1b : vérifie l'association test↔diagnostic via _BIO_THRESHOLDS - Chemin bidirectionnel : CONFIRMÉ↔NON CONFIRMÉ selon bio réelle - Force R3 : codes bio-infirmés → codes_non_defendables - Step 2b : retire les codes bio-confirmés de codes_non_defendables - Retire les moyens défendant des codes bio-contredits - _safe_bio_replace() : regex protégeant les normes [X-Y] - Nettoyage texte libre (conclusion, rappel, codes_nd, raisonnement) - Score factuel déterministe avec pénalités Config modèles pour déploiement local (DGX Spark) : - CPAM : mistral-small3.2:24b (TIM complet, bonne précision bio) - Validation : qwen3:32b (rapide, LOGIC-3 actif) - Timeout : 120s → 600s pour modèles locaux Ollama : migration /api/generate → /api/chat (messages format) Prompt CPAM_ARGUMENTATION restructuré : - R1-R6 non-négociables en tête (avant données) - Champ raisonnement_interne (chain-of-thought structuré) - 5 passes TIM avec références explicites aux règles Test cpam_quality : métriques guardian dans le résumé Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
.env.example
16
.env.example
@@ -5,19 +5,25 @@
|
|||||||
|
|
||||||
# === Ollama ===
|
# === Ollama ===
|
||||||
# OLLAMA_URL=http://localhost:11434
|
# OLLAMA_URL=http://localhost:11434
|
||||||
# OLLAMA_MODEL=gemma3:27b-cloud
|
# OLLAMA_MODEL=gemma3:27b
|
||||||
# OLLAMA_TIMEOUT=120
|
# OLLAMA_TIMEOUT=600
|
||||||
# OLLAMA_MAX_PARALLEL=2
|
# OLLAMA_MAX_PARALLEL=2
|
||||||
|
|
||||||
# === Modèles par rôle LLM ===
|
# === Modèles par rôle LLM ===
|
||||||
# T2A_MODEL_CODING=gemma3:27b-cloud # Codage CIM-10/CCAM, extraction DAS
|
# T2A_MODEL_CODING=gemma3:27b # Codage CIM-10/CCAM, extraction DAS
|
||||||
# T2A_MODEL_CPAM=deepseek-v3.2:cloud # CPAM passe 1 + passe 2
|
# T2A_MODEL_CPAM=mistral-small3.2:24b # CPAM passe 1 + passe 2 (TIM complet, bonne précision bio)
|
||||||
# T2A_MODEL_VALIDATION=deepseek-v3.2:cloud # Validation adversariale
|
# T2A_MODEL_VALIDATION=qwen3:32b # Validation adversariale (rapide, modèle différent → LOGIC-3 actif)
|
||||||
# T2A_MODEL_QC=gemma3:12b # QC batch justifications
|
# T2A_MODEL_QC=gemma3:12b # QC batch justifications
|
||||||
#
|
#
|
||||||
# IMPORTANT : T2A_MODEL_CPAM et T2A_MODEL_VALIDATION DOIVENT être différents
|
# IMPORTANT : T2A_MODEL_CPAM et T2A_MODEL_VALIDATION DOIVENT être différents
|
||||||
# en production pour que la validation adversariale soit réellement indépendante.
|
# en production pour que la validation adversariale soit réellement indépendante.
|
||||||
# Si identiques, la validation adversariale est automatiquement dégradée (LOGIC-3).
|
# Si identiques, la validation adversariale est automatiquement dégradée (LOGIC-3).
|
||||||
|
#
|
||||||
|
# Benchmark (dossier 183_23087212, machine locale) :
|
||||||
|
# mistral-small3.2:24b → TIM complet, meilleure précision bio, 430s
|
||||||
|
# qwen3:32b → TIM complet, rapide (302s), JSON fiable
|
||||||
|
# gemma3:27b → hallucinations bio, format TIM non respecté
|
||||||
|
# llama3.3:70b → riche mais trop lent (1743s), nécessite DGX Spark
|
||||||
|
|
||||||
# === Sélecteur DP (NUKE-3) ===
|
# === Sélecteur DP (NUKE-3) ===
|
||||||
# T2A_DP_RANKER_LLM=1 # 1/true/yes = LLM tiebreaker actif, 0/false/no = pré-ranker déterministe uniquement
|
# T2A_DP_RANKER_LLM=1 # 1/true/yes = LLM tiebreaker actif, 0/false/no = pré-ranker déterministe uniquement
|
||||||
|
|||||||
@@ -53,17 +53,17 @@ NER_CONFIDENCE_THRESHOLD = float(os.environ.get("T2A_NER_THRESHOLD", "0.80"))
|
|||||||
# --- Configuration Ollama ---
|
# --- Configuration Ollama ---
|
||||||
|
|
||||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:27b-cloud")
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:27b")
|
||||||
OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "120"))
|
OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "600"))
|
||||||
OLLAMA_CACHE_PATH = BASE_DIR / "data" / "ollama_cache.json"
|
OLLAMA_CACHE_PATH = BASE_DIR / "data" / "ollama_cache.json"
|
||||||
OLLAMA_MAX_PARALLEL = int(os.environ.get("OLLAMA_MAX_PARALLEL", "2"))
|
OLLAMA_MAX_PARALLEL = int(os.environ.get("OLLAMA_MAX_PARALLEL", "2"))
|
||||||
|
|
||||||
# --- Modèles par rôle LLM ---
|
# --- Modèles par rôle LLM ---
|
||||||
|
|
||||||
OLLAMA_MODELS: dict[str, str] = {
|
OLLAMA_MODELS: dict[str, str] = {
|
||||||
"coding": os.environ.get("T2A_MODEL_CODING", "gemma3:27b-cloud"),
|
"coding": os.environ.get("T2A_MODEL_CODING", "gemma3:27b"),
|
||||||
"cpam": os.environ.get("T2A_MODEL_CPAM", "gemma3:27b-cloud"),
|
"cpam": os.environ.get("T2A_MODEL_CPAM", "mistral-small3.2:24b"),
|
||||||
"validation": os.environ.get("T2A_MODEL_VALIDATION", "deepseek-v3.2:cloud"),
|
"validation": os.environ.get("T2A_MODEL_VALIDATION", "qwen3:32b"),
|
||||||
"qc": os.environ.get("T2A_MODEL_QC", "gemma3:12b"),
|
"qc": os.environ.get("T2A_MODEL_QC", "gemma3:12b"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from .cpam_validation import (
|
|||||||
_build_correction_prompt,
|
_build_correction_prompt,
|
||||||
_format_response,
|
_format_response,
|
||||||
_assess_quality_tier,
|
_assess_quality_tier,
|
||||||
|
_guardian_deterministic,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Backward compat — sera retiré dans un commit futur
|
# Backward compat — sera retiré dans un commit futur
|
||||||
@@ -172,6 +173,9 @@ def generate_cpam_response(
|
|||||||
logger.info(" CPAM : %d code(s) hors périmètre supprimé(s) : %s",
|
logger.info(" CPAM : %d code(s) hors périmètre supprimé(s) : %s",
|
||||||
len(sanitized), ", ".join(sanitized))
|
len(sanitized), ", ".join(sanitized))
|
||||||
|
|
||||||
|
# 6b. Gardien déterministe — corrige hallucinations bio, force R3
|
||||||
|
result = _guardian_deterministic(result, dossier, controle, tag_map)
|
||||||
|
|
||||||
# 7. Validation des références RAG
|
# 7. Validation des références RAG
|
||||||
ref_warnings = _validate_references(result, sources)
|
ref_warnings = _validate_references(result, sources)
|
||||||
if ref_warnings:
|
if ref_warnings:
|
||||||
@@ -239,6 +243,7 @@ def generate_cpam_response(
|
|||||||
result = corrected
|
result = corrected
|
||||||
validation = validation2
|
validation = validation2
|
||||||
_sanitize_unauthorized_codes(result, dossier, controle)
|
_sanitize_unauthorized_codes(result, dossier, controle)
|
||||||
|
result = _guardian_deterministic(result, dossier, controle, tag_map)
|
||||||
ref_warnings = _validate_references(result, sources)
|
ref_warnings = _validate_references(result, sources)
|
||||||
grounding_warnings = _validate_grounding(result, tag_map)
|
grounding_warnings = _validate_grounding(result, tag_map)
|
||||||
code_warnings = _validate_codes_in_response(result, dossier, controle)
|
code_warnings = _validate_codes_in_response(result, dossier, controle)
|
||||||
|
|||||||
@@ -603,6 +603,350 @@ def _is_new_tim_format(parsed: dict) -> bool:
|
|||||||
return "moyens_defense" in parsed
|
return "moyens_defense" in parsed
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gardien déterministe — validation/correction post-LLM sans appel modèle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _guardian_deterministic(
|
||||||
|
result: dict,
|
||||||
|
dossier: DossierMedical,
|
||||||
|
controle: ControleCPAM,
|
||||||
|
tag_map: dict[str, str],
|
||||||
|
) -> dict:
|
||||||
|
"""Gardien déterministe post-LLM : corrige les hallucinations factuelles.
|
||||||
|
|
||||||
|
Opérations (0 appel LLM, < 1ms) :
|
||||||
|
1. Corrige les valeurs bio hallucinées dans confrontation_bio
|
||||||
|
2. Force la cohérence R3 (bio NON CONFIRMÉ → codes_non_defendables)
|
||||||
|
3. Retire des moyens_defense les codes bio-contredits
|
||||||
|
4. Vérifie les tags dans les preuves
|
||||||
|
5. Calcule un score factuel déterministe
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict avec les corrections appliquées + champ "guardian_report" ajouté.
|
||||||
|
"""
|
||||||
|
from .cpam_context import _BIO_THRESHOLDS
|
||||||
|
|
||||||
|
if not _is_new_tim_format(result):
|
||||||
|
return result
|
||||||
|
|
||||||
|
report: dict = {
|
||||||
|
"bio_corrections": [],
|
||||||
|
"codes_moved_to_nd": [],
|
||||||
|
"preuves_invalid_tags": [],
|
||||||
|
"score_factuel": 10,
|
||||||
|
}
|
||||||
|
penalties = 0
|
||||||
|
|
||||||
|
# --- Indexer les valeurs bio réelles du dossier ---
|
||||||
|
bio_reelles: dict[str, float] = {}
|
||||||
|
for b in dossier.biologie_cle:
|
||||||
|
if b.test and b.valeur_num is not None:
|
||||||
|
bio_reelles[b.test] = b.valeur_num
|
||||||
|
elif b.test and b.valeur:
|
||||||
|
try:
|
||||||
|
bio_reelles[b.test] = float(b.valeur.replace(",", ".").split()[0])
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ===== 1. Corriger confrontation_bio =====
|
||||||
|
confrontation = result.get("confrontation_bio", [])
|
||||||
|
codes_infirmes: set[str] = set() # codes dont la bio est normale
|
||||||
|
codes_confirmes: set[str] = set() # codes dont la bio est pathologique
|
||||||
|
|
||||||
|
if isinstance(confrontation, list):
|
||||||
|
for i, entry in enumerate(confrontation):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
test_name = str(entry.get("test", ""))
|
||||||
|
valeur_llm = entry.get("valeur")
|
||||||
|
diagnostic = str(entry.get("diagnostic", ""))
|
||||||
|
|
||||||
|
# --- 1b. Vérifier l'association test↔diagnostic via _BIO_THRESHOLDS ---
|
||||||
|
code_match = re.match(r"([A-Z]\d{2}(?:\.\d{1,2})?)", diagnostic)
|
||||||
|
if code_match:
|
||||||
|
diag_code = code_match.group(1)
|
||||||
|
# Chercher dans _BIO_THRESHOLDS (préfixe 3 ou 5 chars)
|
||||||
|
expected = (_BIO_THRESHOLDS.get(diag_code)
|
||||||
|
or _BIO_THRESHOLDS.get(diag_code[:3]))
|
||||||
|
if expected:
|
||||||
|
expected_test = expected["test"].lower()
|
||||||
|
if expected_test not in test_name.lower() and test_name.lower() not in expected_test:
|
||||||
|
# Le LLM a associé le mauvais test → corriger
|
||||||
|
old_test = test_name
|
||||||
|
entry["test"] = expected["test"]
|
||||||
|
test_name = expected["test"]
|
||||||
|
# Chercher la bonne valeur dans le dossier
|
||||||
|
for bio_key, bio_val in bio_reelles.items():
|
||||||
|
if expected_test in bio_key.lower() or bio_key.lower() in expected_test:
|
||||||
|
entry["valeur"] = bio_val
|
||||||
|
valeur_llm = bio_val
|
||||||
|
entry["seuil"] = expected.get("condition", "")
|
||||||
|
break
|
||||||
|
report["bio_corrections"].append({
|
||||||
|
"test": old_test,
|
||||||
|
"correction": f"mauvais test pour {diag_code} : {old_test} → {expected['test']}",
|
||||||
|
"type": "wrong_test_mapping",
|
||||||
|
})
|
||||||
|
penalties += 2
|
||||||
|
logger.info(" Gardien : %s → test corrigé de '%s' vers '%s'",
|
||||||
|
diag_code, old_test, expected["test"])
|
||||||
|
|
||||||
|
# Chercher la valeur réelle dans le dossier
|
||||||
|
valeur_reelle = None
|
||||||
|
for bio_key, bio_val in bio_reelles.items():
|
||||||
|
if bio_key.lower() in test_name.lower() or test_name.lower() in bio_key.lower():
|
||||||
|
valeur_reelle = bio_val
|
||||||
|
break
|
||||||
|
|
||||||
|
if valeur_reelle is not None:
|
||||||
|
# Vérifier si le LLM a inventé une valeur différente
|
||||||
|
try:
|
||||||
|
v_llm = float(str(valeur_llm).replace(",", ".").split()[0]) if valeur_llm else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
v_llm = None
|
||||||
|
|
||||||
|
if v_llm is not None and abs(v_llm - valeur_reelle) > 0.1:
|
||||||
|
report["bio_corrections"].append({
|
||||||
|
"test": test_name,
|
||||||
|
"valeur_llm": v_llm,
|
||||||
|
"valeur_reelle": valeur_reelle,
|
||||||
|
"diagnostic": diagnostic,
|
||||||
|
})
|
||||||
|
entry["valeur"] = valeur_reelle
|
||||||
|
penalties += 2
|
||||||
|
|
||||||
|
# Recalculer le verdict avec la valeur réelle
|
||||||
|
matched_bio_key = None
|
||||||
|
for bio_key in bio_reelles:
|
||||||
|
if bio_key.lower() in test_name.lower() or test_name.lower() in bio_key.lower():
|
||||||
|
matched_bio_key = bio_key
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched_bio_key and matched_bio_key in BIO_NORMALS:
|
||||||
|
lo, hi = BIO_NORMALS[matched_bio_key]
|
||||||
|
is_normal = lo <= valeur_reelle <= hi
|
||||||
|
|
||||||
|
old_verdict = str(entry.get("verdict", "")).upper()
|
||||||
|
if is_normal and "CONFIRMÉ" in old_verdict and "NON" not in old_verdict:
|
||||||
|
# Verdict CONFIRMÉ mais valeur normale → correction
|
||||||
|
entry["verdict"] = "NON CONFIRMÉ — valeur NORMALE"
|
||||||
|
report["bio_corrections"].append({
|
||||||
|
"test": test_name,
|
||||||
|
"verdict_corrige": "CONFIRMÉ → NON CONFIRMÉ",
|
||||||
|
"valeur": valeur_reelle,
|
||||||
|
"norme": f"[{lo}-{hi}]",
|
||||||
|
})
|
||||||
|
penalties += 2
|
||||||
|
elif not is_normal and "NON" in old_verdict and "CONFIRMÉ" in old_verdict:
|
||||||
|
# Verdict NON CONFIRMÉ mais valeur pathologique → correction inverse
|
||||||
|
entry["verdict"] = "CONFIRMÉ — valeur PATHOLOGIQUE"
|
||||||
|
report["bio_corrections"].append({
|
||||||
|
"test": test_name,
|
||||||
|
"verdict_corrige": "NON CONFIRMÉ → CONFIRMÉ",
|
||||||
|
"valeur": valeur_reelle,
|
||||||
|
"norme": f"[{lo}-{hi}]",
|
||||||
|
})
|
||||||
|
penalties += 2
|
||||||
|
|
||||||
|
code_match_v = re.match(r"([A-Z]\d{2}(?:\.\d{1,2})?)", diagnostic)
|
||||||
|
if is_normal:
|
||||||
|
# Extraire le code CIM-10 du champ diagnostic
|
||||||
|
if code_match_v:
|
||||||
|
codes_infirmes.add(code_match_v.group(1))
|
||||||
|
elif not is_normal and code_match_v:
|
||||||
|
# Bio pathologique → ce code est CONFIRMÉ, pas infirmé
|
||||||
|
codes_confirmes.add(code_match_v.group(1))
|
||||||
|
|
||||||
|
# ===== 2. Force R3 : codes infirmés → codes_non_defendables =====
|
||||||
|
codes_nd = result.get("codes_non_defendables", [])
|
||||||
|
if not isinstance(codes_nd, list):
|
||||||
|
codes_nd = []
|
||||||
|
result["codes_non_defendables"] = codes_nd
|
||||||
|
|
||||||
|
codes_nd_existants = {
|
||||||
|
nd.get("code", "") for nd in codes_nd if isinstance(nd, dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
for code_infirme in codes_infirmes:
|
||||||
|
if code_infirme not in codes_nd_existants:
|
||||||
|
# Chercher la valeur bio pour le message
|
||||||
|
bio_detail = ""
|
||||||
|
for bio_key, bio_val in bio_reelles.items():
|
||||||
|
if bio_key in BIO_NORMALS:
|
||||||
|
lo, hi = BIO_NORMALS[bio_key]
|
||||||
|
# Vérifier si ce test est lié au code via _BIO_THRESHOLDS
|
||||||
|
prefix = code_infirme[:3]
|
||||||
|
threshold = _BIO_THRESHOLDS.get(prefix) or _BIO_THRESHOLDS.get(code_infirme[:5] if len(code_infirme) >= 5 else code_infirme)
|
||||||
|
if threshold and bio_key.lower() in threshold.get("test", "").lower():
|
||||||
|
bio_detail = f"{bio_key} = {bio_val} [norme {lo}-{hi}] — valeur NORMALE"
|
||||||
|
break
|
||||||
|
|
||||||
|
is_valid, label = validate_code(normalize_code(code_infirme))
|
||||||
|
label_str = f" ({label})" if is_valid and label else ""
|
||||||
|
|
||||||
|
codes_nd.append({
|
||||||
|
"code": code_infirme,
|
||||||
|
"raison": f"{bio_detail or 'Bio normale'}, diagnostic non confirmé biologiquement",
|
||||||
|
"recommandation": "Retrait recommandé — code indéfendable (gardien déterministe)",
|
||||||
|
"_source": "guardian",
|
||||||
|
})
|
||||||
|
report["codes_moved_to_nd"].append(code_infirme)
|
||||||
|
penalties += 1
|
||||||
|
|
||||||
|
# ===== 2b. Retirer de codes_non_defendables les codes bio-CONFIRMÉS =====
|
||||||
|
if codes_confirmes and codes_nd:
|
||||||
|
codes_nd_before = len(codes_nd)
|
||||||
|
result["codes_non_defendables"] = [
|
||||||
|
nd for nd in codes_nd
|
||||||
|
if not isinstance(nd, dict)
|
||||||
|
or nd.get("code", "") not in codes_confirmes
|
||||||
|
]
|
||||||
|
codes_nd = result["codes_non_defendables"]
|
||||||
|
removed = codes_nd_before - len(codes_nd)
|
||||||
|
if removed:
|
||||||
|
report.setdefault("codes_rescued_from_nd", list(codes_confirmes))
|
||||||
|
logger.info(" Gardien : %d code(s) retirés de non-défendables (bio pathologique) : %s",
|
||||||
|
removed, ", ".join(codes_confirmes))
|
||||||
|
|
||||||
|
# ===== 3. Retirer des moyens_defense les codes bio-contredits =====
|
||||||
|
moyens = result.get("moyens_defense", [])
|
||||||
|
if isinstance(moyens, list) and codes_infirmes:
|
||||||
|
moyens_filtres = []
|
||||||
|
for moyen in moyens:
|
||||||
|
if not isinstance(moyen, dict):
|
||||||
|
moyens_filtres.append(moyen)
|
||||||
|
continue
|
||||||
|
titre = str(moyen.get("titre", "")).upper()
|
||||||
|
argument = str(moyen.get("argument", "")).upper()
|
||||||
|
moyen_text = titre + " " + argument
|
||||||
|
|
||||||
|
# Vérifier si ce moyen défend un code infirmé
|
||||||
|
defends_infirme = False
|
||||||
|
for code_inf in codes_infirmes:
|
||||||
|
if code_inf in moyen_text:
|
||||||
|
defends_infirme = True
|
||||||
|
logger.info(" Gardien : moyen retiré (défend %s, bio normale)", code_inf)
|
||||||
|
penalties += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
if not defends_infirme:
|
||||||
|
moyens_filtres.append(moyen)
|
||||||
|
|
||||||
|
result["moyens_defense"] = moyens_filtres
|
||||||
|
|
||||||
|
# Renuméroter
|
||||||
|
for idx, moyen in enumerate(result["moyens_defense"]):
|
||||||
|
if isinstance(moyen, dict):
|
||||||
|
moyen["numero"] = idx + 1
|
||||||
|
|
||||||
|
# ===== 4. Vérifier les tags dans les preuves =====
|
||||||
|
if tag_map:
|
||||||
|
for moyen in result.get("moyens_defense", []):
|
||||||
|
if not isinstance(moyen, dict):
|
||||||
|
continue
|
||||||
|
for preuve in moyen.get("preuves", []):
|
||||||
|
if not isinstance(preuve, dict):
|
||||||
|
continue
|
||||||
|
ref = str(preuve.get("ref", ""))
|
||||||
|
# Extraire le tag (ex: [BIO-1] → BIO-1)
|
||||||
|
tag_match = re.findall(r"\[([A-Z]+-\d+)\]", ref)
|
||||||
|
for tag in tag_match:
|
||||||
|
if tag not in tag_map:
|
||||||
|
report["preuves_invalid_tags"].append(tag)
|
||||||
|
penalties += 0.5
|
||||||
|
|
||||||
|
# ===== 5. Nettoyage des champs texte libre =====
|
||||||
|
# Remplacer les valeurs bio hallucinées dans les strings (conclusion, rappel, etc.)
|
||||||
|
text_fields = [
|
||||||
|
"rappel_faits", "conclusion_dispositive", "reponse_points_cpam",
|
||||||
|
"asymetrie_information",
|
||||||
|
]
|
||||||
|
bio_replacements: list[tuple[str, str]] = [] # (pattern_llm, valeur_reelle)
|
||||||
|
for corr in report["bio_corrections"]:
|
||||||
|
v_llm = corr.get("valeur_llm")
|
||||||
|
v_reelle = corr.get("valeur_reelle")
|
||||||
|
if v_llm is not None and v_reelle is not None:
|
||||||
|
fmt_llm = str(int(v_llm)) if v_llm == int(v_llm) else str(v_llm)
|
||||||
|
fmt_reel = str(int(v_reelle)) if v_reelle == int(v_reelle) else str(v_reelle)
|
||||||
|
if fmt_llm != fmt_reel:
|
||||||
|
bio_replacements.append((fmt_llm, fmt_reel))
|
||||||
|
|
||||||
|
def _safe_bio_replace(text: str, old: str, new: str) -> str:
|
||||||
|
"""Remplace une valeur bio en évitant les contextes de norme [X-Y].
|
||||||
|
|
||||||
|
Ne remplace PAS si le nombre est précédé de '-' (borne haute d'une norme)
|
||||||
|
ou suivi de '-' sans espace (borne basse d'une norme).
|
||||||
|
Utilise des word boundaries pour plus de sécurité.
|
||||||
|
"""
|
||||||
|
# Pattern : old_value précédé de word boundary ou espace, pas de tiret collé
|
||||||
|
pattern = r"(?<![-\d])\b" + re.escape(old) + r"\b(?![-])"
|
||||||
|
return re.sub(pattern, new, text)
|
||||||
|
|
||||||
|
if bio_replacements:
|
||||||
|
n_replacements = 0
|
||||||
|
for field in text_fields:
|
||||||
|
val = result.get(field)
|
||||||
|
if isinstance(val, str):
|
||||||
|
for old, new in bio_replacements:
|
||||||
|
new_val = _safe_bio_replace(val, old, new)
|
||||||
|
if new_val != val:
|
||||||
|
n_replacements += 1
|
||||||
|
val = new_val
|
||||||
|
result[field] = val
|
||||||
|
|
||||||
|
# Aussi dans les arguments des moyens restants
|
||||||
|
for moyen in result.get("moyens_defense", []):
|
||||||
|
if isinstance(moyen, dict):
|
||||||
|
for key in ("titre", "argument"):
|
||||||
|
txt = moyen.get(key)
|
||||||
|
if isinstance(txt, str):
|
||||||
|
for old, new in bio_replacements:
|
||||||
|
txt = _safe_bio_replace(txt, old, new)
|
||||||
|
moyen[key] = txt
|
||||||
|
|
||||||
|
# Et dans raisonnement_interne
|
||||||
|
ri = result.get("raisonnement_interne")
|
||||||
|
if isinstance(ri, dict):
|
||||||
|
for k, v in ri.items():
|
||||||
|
if isinstance(v, str):
|
||||||
|
for old, new in bio_replacements:
|
||||||
|
v = _safe_bio_replace(v, old, new)
|
||||||
|
ri[k] = v
|
||||||
|
|
||||||
|
# Et dans codes_non_defendables (raison, recommandation)
|
||||||
|
for nd in result.get("codes_non_defendables", []):
|
||||||
|
if isinstance(nd, dict):
|
||||||
|
for key in ("raison", "recommandation"):
|
||||||
|
txt = nd.get(key)
|
||||||
|
if isinstance(txt, str):
|
||||||
|
for old, new in bio_replacements:
|
||||||
|
txt = _safe_bio_replace(txt, old, new)
|
||||||
|
nd[key] = txt
|
||||||
|
|
||||||
|
report["text_replacements"] = n_replacements
|
||||||
|
if n_replacements:
|
||||||
|
logger.info(" Gardien : %d remplacement(s) texte dans champs libres", n_replacements)
|
||||||
|
|
||||||
|
# ===== 6. Score factuel déterministe =====
|
||||||
|
report["score_factuel"] = max(0, round(10 - penalties))
|
||||||
|
result["guardian_report"] = report
|
||||||
|
|
||||||
|
# Log synthèse
|
||||||
|
n_bio = len(report["bio_corrections"])
|
||||||
|
n_moved = len(report["codes_moved_to_nd"])
|
||||||
|
n_tags = len(report["preuves_invalid_tags"])
|
||||||
|
if n_bio or n_moved or n_tags:
|
||||||
|
logger.warning(
|
||||||
|
" Gardien déterministe : %d correction(s) bio, %d code(s) → ND, %d tag(s) invalide(s), score factuel %d/10",
|
||||||
|
n_bio, n_moved, n_tags, report["score_factuel"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(" Gardien déterministe : aucune correction nécessaire, score factuel 10/10")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _format_response(
|
def _format_response(
|
||||||
parsed: dict,
|
parsed: dict,
|
||||||
ref_warnings: list[str] | None = None,
|
ref_warnings: list[str] | None = None,
|
||||||
|
|||||||
@@ -168,10 +168,10 @@ def call_ollama(
|
|||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{OLLAMA_URL}/api/generate",
|
f"{OLLAMA_URL}/api/chat",
|
||||||
json={
|
json={
|
||||||
"model": use_model,
|
"model": use_model,
|
||||||
"prompt": prompt,
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -190,7 +190,7 @@ def call_ollama(
|
|||||||
continue
|
continue
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
resp_data = response.json()
|
resp_data = response.json()
|
||||||
raw = resp_data.get("response", "")
|
raw = resp_data.get("message", {}).get("content", "")
|
||||||
done_reason = resp_data.get("done_reason", "")
|
done_reason = resp_data.get("done_reason", "")
|
||||||
eval_count = resp_data.get("eval_count", 0)
|
eval_count = resp_data.get("eval_count", 0)
|
||||||
if done_reason == "length":
|
if done_reason == "length":
|
||||||
|
|||||||
@@ -226,41 +226,38 @@ CPAM_ARGUMENTATION = """\
|
|||||||
Tu es un médecin DIM senior expert en contentieux T2A. Tu rédiges un MÉMOIRE EN DÉFENSE \
|
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.
|
structuré et argumenté pour répondre à la contestation CPAM ci-dessous.
|
||||||
|
|
||||||
Ta méthode suit les 5 passes de raisonnement expert TIM :
|
══════════════════════════════════════════════════════════════
|
||||||
|
RÈGLE ABSOLUE — HONNÊTETÉ INTELLECTUELLE (lire AVANT tout raisonnement)
|
||||||
|
══════════════════════════════════════════════════════════════
|
||||||
|
Ces règles sont NON NÉGOCIABLES et s'appliquent à CHAQUE code, CHAQUE argument :
|
||||||
|
|
||||||
PASSE 1 — CONTEXTE ADMINISTRATIF :
|
R1. BIOLOGIE NORMALE = CODE INDÉFENDABLE sur cet axe
|
||||||
Analyse le contexte du séjour (âge, sexe, durée, mode d'entrée/sortie, actes) pour cadrer \
|
Si une valeur biologique est dans les normes alors que le diagnostic l'exige pathologique
|
||||||
ton raisonnement. En pédiatrie (< 18 ans), les normes biologiques et codages diffèrent. \
|
→ ce code va dans "codes_non_defendables", PAS dans "moyens_defense"
|
||||||
Une admission en urgence implique un contexte aigu influençant le DP.
|
|
||||||
|
|
||||||
PASSE 2 — MOTIF D'HOSPITALISATION RÉEL :
|
R2. ABSENCE DE PREUVE OBJECTIVE = SIGNAL OBLIGATOIRE
|
||||||
Distingue le motif d'entrée déclaré du motif réel en te posant :
|
Si un diagnostic n'a aucune preuve (pas de bio, pas d'imagerie, pas d'acte CCAM)
|
||||||
- Pourquoi CE patient a été hospitalisé CE JOUR (événement déclencheur)
|
→ tu DOIS écrire : "Ce diagnostic repose sur le seul jugement clinique du médecin,
|
||||||
- Quel acte thérapeutique principal a été réalisé
|
sans preuve biologique ou paraclinique dans le dossier transmis"
|
||||||
- Le DP retenu est-il cohérent avec cet acte et la durée de séjour
|
|
||||||
|
|
||||||
PASSE 3 — CONFRONTATION BIOLOGIE / DIAGNOSTIC :
|
R3. COHÉRENCE CROISÉE OBLIGATOIRE
|
||||||
Pour chaque diagnostic contesté, confronte aux seuils biologiques :
|
Tout code présent dans "confrontation_bio" avec verdict INFIRMÉ ou CONTREDIT
|
||||||
{bio_confrontation_str}
|
→ DOIT figurer dans "codes_non_defendables" — toute contradiction entre ces deux
|
||||||
- Une valeur normale CONTREDIT un diagnostic actif basé sur cette biologie
|
champs est une erreur critique
|
||||||
- Une valeur pathologique SANS diagnostic est un sous-codage potentiel
|
|
||||||
- CITE les seuils exacts et les valeurs du dossier
|
|
||||||
|
|
||||||
PASSE 4 — HIÉRARCHIE DIAGNOSTIQUE :
|
R4. CONCESSION CRÉDIBILISANTE
|
||||||
- Le DP est le diagnostic qui a CONSOMMÉ LE PLUS DE RESSOURCES (pas le plus grave)
|
Si la CPAM a raison sur un point, le reconnaître explicitement dans
|
||||||
- Spécifique exclut générique (K81.0 présent → retirer K81.9)
|
"reponse_points_cpam" — la crédibilité globale du mémoire en dépend
|
||||||
- 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) :
|
R5. PRINCIPE TIM
|
||||||
Pour CHAQUE code défendu, tu DOIS répondre aux 4 questions :
|
"Mieux vaut un code moins précis mais défendable qu'un code précis mais indéfendable"
|
||||||
1. Ce diagnostic est-il documenté EXPLICITEMENT dans le dossier, ou DÉDUIT ?
|
Ne jamais forcer un argument que le dossier ne soutient pas objectivement
|
||||||
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 ?
|
R6. ZÉRO INVENTION
|
||||||
4. Quel DOCUMENT du dossier cite-t-on en premier face à la CPAM ?
|
N'invente AUCUN tag, valeur biologique, code ou source absents du dossier fourni
|
||||||
|
══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DONNÉES DU DOSSIER :
|
||||||
|
|
||||||
DOSSIER MÉDICAL : {dossier_str}
|
DOSSIER MÉDICAL : {dossier_str}
|
||||||
{asymetrie_str}
|
{asymetrie_str}
|
||||||
@@ -278,22 +275,52 @@ CODES EN JEU : {codes_str}
|
|||||||
SOURCES RÉGLEMENTAIRES : {sources_text}
|
SOURCES RÉGLEMENTAIRES : {sources_text}
|
||||||
{extraction_str}
|
{extraction_str}
|
||||||
|
|
||||||
RÈGLE ABSOLUE — HONNÊTETÉ INTELLECTUELLE :
|
SEUILS BIOLOGIQUES DE RÉFÉRENCE :
|
||||||
Un mémoire crédible ne force JAMAIS un argument que le dossier ne soutient pas.
|
{bio_confrontation_str}
|
||||||
- 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"
|
|
||||||
|
|
||||||
CONSIGNES DE RÉDACTION :
|
══════════════════════════════════════════════════════════════
|
||||||
|
MÉTHODE DE RAISONNEMENT — 5 PASSES TIM
|
||||||
|
══════════════════════════════════════════════════════════════
|
||||||
|
Effectue ces 5 passes MENTALEMENT avant de rédiger le JSON.
|
||||||
|
Externalise ton raisonnement dans le champ "raisonnement_interne" du JSON.
|
||||||
|
|
||||||
|
PASSE 1 — CONTEXTE ADMINISTRATIF :
|
||||||
|
Analyse le contexte du séjour (âge, sexe, durée, mode d'entrée/sortie, actes).
|
||||||
|
En pédiatrie (< 18 ans), les normes biologiques et codages diffèrent.
|
||||||
|
Une admission en urgence implique un contexte aigu influençant le DP.
|
||||||
|
|
||||||
|
PASSE 2 — MOTIF D'HOSPITALISATION RÉEL :
|
||||||
|
- Pourquoi CE patient a été hospitalisé CE JOUR (événement déclencheur)
|
||||||
|
- Quel acte thérapeutique principal a été réalisé
|
||||||
|
- Le DP retenu est-il cohérent avec cet acte et la durée de séjour
|
||||||
|
|
||||||
|
PASSE 3 — CONFRONTATION BIOLOGIE / DIAGNOSTIC (appliquer R1 et R3) :
|
||||||
|
Pour CHAQUE diagnostic contesté, comparer aux seuils ci-dessus.
|
||||||
|
DÉCISION BINAIRE pour chaque code :
|
||||||
|
→ valeur pathologique confirmée : le code est DÉFENDABLE sur cet axe
|
||||||
|
→ valeur normale ou absente : le code va dans codes_non_defendables (R1)
|
||||||
|
Citer les seuils exacts et les valeurs du dossier — jamais de valeur inventée (R6)
|
||||||
|
|
||||||
|
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 :
|
||||||
|
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 que tu envisages de défendre, 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 ?
|
||||||
|
Si une réponse est NON ou DÉDUIT → appliquer R2, ne pas construire de moyen sur cet axe
|
||||||
|
|
||||||
|
══════════════════════════════════════════════════════════════
|
||||||
|
CONSIGNES DE RÉDACTION DES MOYENS
|
||||||
|
══════════════════════════════════════════════════════════════
|
||||||
1. STRUCTURE EN MOYENS DE DÉFENSE NUMÉROTÉS (pas de prose libre)
|
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
|
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ë)
|
3. CITE les codes CIM-10 avec libellé complet (ex: N17.8 — Autre insuffisance rénale aiguë)
|
||||||
@@ -301,14 +328,19 @@ CONSIGNES DE RÉDACTION :
|
|||||||
5. CITE les sources réglementaires au format [Document - page N] "citation verbatim"
|
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
|
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...")
|
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
|
8. Si un point CPAM est légitime, le reconnaître CLAIREMENT (R4)
|
||||||
9. N'invente AUCUN tag, code ou source qui n'est pas fourni ci-dessus
|
9. Tags valides UNIQUEMENT : [DP], [DAS-N], [BIO-N], [IMG-N], [TRT-N], [ACTE-N], [ANT-N], [COMPL-N]
|
||||||
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]
|
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec un objet JSON :
|
Réponds UNIQUEMENT avec un objet JSON :
|
||||||
{{
|
{{
|
||||||
"objet": "Contestation {titre} — OGC {numero_ogc} — Mémoire en défense",
|
"objet": "Contestation {titre} — OGC {numero_ogc} — Mémoire en défense",
|
||||||
|
"raisonnement_interne": {{
|
||||||
|
"passe1_contexte": "synthèse de la passe 1 : profil patient, durée séjour, mode entrée/sortie, actes clés",
|
||||||
|
"passe2_motif_reel": "événement déclencheur identifié, cohérence DP/actes/durée",
|
||||||
|
"passe3_bio": "pour chaque code contesté : valeur bio vs seuil → DÉFENDABLE ou NON DÉFENDABLE",
|
||||||
|
"passe4_hierarchie": "validation hiérarchie DP/DAS, exclusions symptômes, critères ressources",
|
||||||
|
"passe5_validation": "liste des codes retenus pour défense avec justification des 4 questions, liste des codes écartés avec raison"
|
||||||
|
}},
|
||||||
"rappel_faits": "Résumé factuel du séjour en 3-5 lignes : motif, actes, durée, issue",
|
"rappel_faits": "Résumé factuel du séjour en 3-5 lignes : motif, actes, durée, issue",
|
||||||
"moyens_defense": [
|
"moyens_defense": [
|
||||||
{{
|
{{
|
||||||
@@ -325,7 +357,7 @@ Réponds UNIQUEMENT avec un objet JSON :
|
|||||||
{{"diagnostic": "N17.8 IRA", "test": "Créatinine", "valeur": 280, "seuil": "> 130 µmol/L", "verdict": "CONFIRMÉ"}}
|
{{"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",
|
"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",
|
"reponse_points_cpam": "Pour chaque point légitime de la CPAM : reconnaissance CLAIRE + réfutation factuelle OU concession explicite si indéfendable",
|
||||||
"codes_non_defendables": [
|
"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"}}
|
{{"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"}}
|
||||||
],
|
],
|
||||||
|
|||||||
239
test_cpam_quality.py
Normal file
239
test_cpam_quality.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test qualité CPAM — format TIM (mémoire en défense) sur dossiers réels.
|
||||||
|
|
||||||
|
Charge des dossiers JSON existants et appelle generate_cpam_response()
|
||||||
|
pour valider le nouveau format TIM sans relancer le pipeline complet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ajouter le répertoire racine au path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from src.config import DossierMedical, ControleCPAM
|
||||||
|
from src.control.cpam_response import generate_cpam_response
|
||||||
|
from src.control.cpam_validation import _is_new_tim_format
|
||||||
|
|
||||||
|
# Configurer logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)-5s %(name)s — %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("test_cpam_quality")
|
||||||
|
|
||||||
|
# Dossiers à tester (variété de cas)
|
||||||
|
DOSSIERS_TEST = [
|
||||||
|
"183_23087212", # Désaccord DP+DAS
|
||||||
|
"116_23065570", # DAS
|
||||||
|
"143_23096917", # DP+DAS
|
||||||
|
"132_23080179", # Facturation
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_dossier(name: str) -> DossierMedical | None:
|
||||||
|
"""Charge un dossier JSON depuis output/structured/."""
|
||||||
|
base = Path(__file__).parent / "output" / "structured" / name
|
||||||
|
# Préférer le fichier fusionné
|
||||||
|
fusionne = list(base.glob("*_fusionne_cim10.json"))
|
||||||
|
json_files = fusionne if fusionne else sorted(base.glob("*.json"))
|
||||||
|
if not json_files:
|
||||||
|
logger.error("Aucun JSON trouvé pour %s", name)
|
||||||
|
return None
|
||||||
|
with open(json_files[0], encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return DossierMedical(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dossier(name: str) -> dict:
|
||||||
|
"""Teste generate_cpam_response sur un dossier et retourne les métriques."""
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info("DOSSIER : %s", name)
|
||||||
|
logger.info("=" * 70)
|
||||||
|
|
||||||
|
dossier = load_dossier(name)
|
||||||
|
if not dossier:
|
||||||
|
return {"name": name, "error": "Dossier non trouvé"}
|
||||||
|
|
||||||
|
if not dossier.controles_cpam:
|
||||||
|
return {"name": name, "error": "Pas de contrôle CPAM"}
|
||||||
|
|
||||||
|
controle = dossier.controles_cpam[0]
|
||||||
|
logger.info("Contrôle : OGC %d — %s", controle.numero_ogc, controle.titre)
|
||||||
|
logger.info("DP UCR : %s | DA UCR : %s", controle.dp_ucr or "-", controle.da_ucr or "-")
|
||||||
|
|
||||||
|
# Appeler generate_cpam_response
|
||||||
|
t0 = time.time()
|
||||||
|
text, result, rag_sources = generate_cpam_response(dossier, controle)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
metrics = {
|
||||||
|
"name": name,
|
||||||
|
"titre": controle.titre,
|
||||||
|
"elapsed_s": round(elapsed, 1),
|
||||||
|
"text_len": len(text),
|
||||||
|
"rag_sources": len(rag_sources),
|
||||||
|
"tier": controle.quality_tier or "?",
|
||||||
|
}
|
||||||
|
|
||||||
|
if result:
|
||||||
|
is_tim = _is_new_tim_format(result)
|
||||||
|
metrics["format"] = "TIM" if is_tim else "legacy"
|
||||||
|
|
||||||
|
if is_tim:
|
||||||
|
# Nouveau format TIM
|
||||||
|
moyens = result.get("moyens_defense", [])
|
||||||
|
confrontation = result.get("confrontation_bio", [])
|
||||||
|
codes_nd = result.get("codes_non_defendables", [])
|
||||||
|
refs = result.get("references", [])
|
||||||
|
conclusion = result.get("conclusion_dispositive", "")
|
||||||
|
|
||||||
|
# Compter les preuves dans les moyens
|
||||||
|
total_preuves = 0
|
||||||
|
preuves_with_ref = 0
|
||||||
|
for m in moyens:
|
||||||
|
if isinstance(m, dict):
|
||||||
|
for p in m.get("preuves", []):
|
||||||
|
if isinstance(p, dict):
|
||||||
|
total_preuves += 1
|
||||||
|
if p.get("ref"):
|
||||||
|
preuves_with_ref += 1
|
||||||
|
|
||||||
|
metrics["moyens_count"] = len(moyens)
|
||||||
|
metrics["preuves_count"] = total_preuves
|
||||||
|
metrics["preuves_with_ref"] = preuves_with_ref
|
||||||
|
metrics["confrontation_count"] = len(confrontation)
|
||||||
|
metrics["codes_nd_count"] = len(codes_nd)
|
||||||
|
metrics["refs_count"] = len(refs) if isinstance(refs, list) else 0
|
||||||
|
metrics["conclusion_len"] = len(conclusion)
|
||||||
|
metrics["has_rappel_faits"] = bool(result.get("rappel_faits"))
|
||||||
|
metrics["has_reponse_cpam"] = bool(result.get("reponse_points_cpam"))
|
||||||
|
|
||||||
|
logger.info("-" * 40)
|
||||||
|
logger.info("FORMAT : TIM (mémoire en défense)")
|
||||||
|
logger.info("RÉSULTAT : %d chars, %.1fs, tier %s", len(text), elapsed, metrics["tier"])
|
||||||
|
logger.info(" Moyens de défense : %d", len(moyens))
|
||||||
|
logger.info(" Preuves : %d (dont %d avec tag)", total_preuves, preuves_with_ref)
|
||||||
|
logger.info(" Confrontation bio : %d entrées", len(confrontation))
|
||||||
|
logger.info(" Codes non défendables : %d", len(codes_nd))
|
||||||
|
logger.info(" Références : %d", metrics["refs_count"])
|
||||||
|
logger.info(" Sources RAG : %d", len(rag_sources))
|
||||||
|
if confrontation:
|
||||||
|
for row in confrontation:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
logger.info(" Bio: %s → %s = %s → %s",
|
||||||
|
row.get("diagnostic", "?"), row.get("test", "?"),
|
||||||
|
row.get("valeur", "?"), row.get("verdict", "?"))
|
||||||
|
if codes_nd:
|
||||||
|
for nd in codes_nd:
|
||||||
|
if isinstance(nd, dict):
|
||||||
|
logger.info(" ⚠ Non défendable: %s — %s",
|
||||||
|
nd.get("code", "?"), nd.get("raison", "?")[:80])
|
||||||
|
|
||||||
|
# --- Guardian report ---
|
||||||
|
guardian = result.get("guardian_report", {})
|
||||||
|
if guardian:
|
||||||
|
bio_corr = guardian.get("bio_corrections", [])
|
||||||
|
codes_moved = guardian.get("codes_moved_to_nd", [])
|
||||||
|
text_repl = guardian.get("text_replacements", 0)
|
||||||
|
score_f = guardian.get("score_factuel", "?")
|
||||||
|
metrics["guardian_bio_corrections"] = len(bio_corr)
|
||||||
|
metrics["guardian_codes_moved"] = len(codes_moved)
|
||||||
|
metrics["guardian_text_replacements"] = int(text_repl) if text_repl else 0
|
||||||
|
metrics["guardian_score_factuel"] = score_f
|
||||||
|
logger.info(" --- GUARDIAN REPORT ---")
|
||||||
|
logger.info(" Score factuel : %s/10", score_f)
|
||||||
|
logger.info(" Bio corrections : %d", len(bio_corr))
|
||||||
|
for c in bio_corr:
|
||||||
|
logger.info(" %s : LLM=%s → réel=%s",
|
||||||
|
c.get("test", "?"), c.get("valeur_llm", c.get("llm_value", "?")),
|
||||||
|
c.get("valeur_reelle", c.get("real_value", "?")))
|
||||||
|
if codes_moved:
|
||||||
|
logger.info(" Codes déplacés vers non-défendables : %s",
|
||||||
|
", ".join(codes_moved))
|
||||||
|
if text_repl:
|
||||||
|
logger.info(" Remplacements texte : %s", text_repl)
|
||||||
|
else:
|
||||||
|
metrics["guardian_bio_corrections"] = 0
|
||||||
|
metrics["guardian_codes_moved"] = 0
|
||||||
|
metrics["guardian_text_replacements"] = 0
|
||||||
|
metrics["guardian_score_factuel"] = "N/A"
|
||||||
|
else:
|
||||||
|
# Ancien format (fallback)
|
||||||
|
preuves = result.get("preuves_dossier", [])
|
||||||
|
refs = result.get("references", [])
|
||||||
|
conclusion = result.get("conclusion", "")
|
||||||
|
|
||||||
|
metrics["moyens_count"] = 0
|
||||||
|
metrics["preuves_count"] = len(preuves) if isinstance(preuves, list) else 0
|
||||||
|
metrics["preuves_with_ref"] = sum(1 for p in (preuves or []) if isinstance(p, dict) and p.get("ref"))
|
||||||
|
metrics["confrontation_count"] = 0
|
||||||
|
metrics["codes_nd_count"] = 0
|
||||||
|
metrics["refs_count"] = len(refs) if isinstance(refs, list) else 0
|
||||||
|
metrics["conclusion_len"] = len(conclusion)
|
||||||
|
|
||||||
|
logger.info("-" * 40)
|
||||||
|
logger.info("FORMAT : legacy (ancien)")
|
||||||
|
logger.info("RÉSULTAT : %d chars, %.1fs, tier %s", len(text), elapsed, metrics["tier"])
|
||||||
|
else:
|
||||||
|
metrics["error"] = "LLM a retourné None"
|
||||||
|
metrics["format"] = "N/A"
|
||||||
|
logger.error("LLM n'a retourné aucun résultat !")
|
||||||
|
|
||||||
|
# Afficher la contre-argumentation complète
|
||||||
|
print("\n" + "~" * 70)
|
||||||
|
print("CONTRE-ARGUMENTATION :")
|
||||||
|
print("~" * 70)
|
||||||
|
print(text[:5000] if text else "(vide)")
|
||||||
|
if len(text) > 5000:
|
||||||
|
print(f"\n... [tronqué, {len(text)} chars au total]")
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dossiers = sys.argv[1:] if len(sys.argv) > 1 else DOSSIERS_TEST
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for name in dossiers:
|
||||||
|
try:
|
||||||
|
metrics = test_dossier(name)
|
||||||
|
results.append(metrics)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Erreur sur %s", name)
|
||||||
|
results.append({"name": name, "error": str(e)})
|
||||||
|
|
||||||
|
# Résumé final
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("RÉSUMÉ — FORMAT TIM")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"{'Dossier':<20} {'Fmt':>5} {'Tier':>4} {'Temps':>6} {'Chars':>6} {'Moyens':>7} {'Bio':>4} {'ND':>3} {'Refs':>5} {'RAG':>4} {'G.Fix':>5} {'G.Mv':>4} {'G.Txt':>5} {'G.Sc':>4}")
|
||||||
|
print("-" * 105)
|
||||||
|
for r in results:
|
||||||
|
if "error" in r:
|
||||||
|
print(f"{r['name']:<20} ERREUR: {r['error']}")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{r['name']:<20} "
|
||||||
|
f"{r.get('format', '?'):>5} "
|
||||||
|
f"{r.get('tier', '?'):>4} "
|
||||||
|
f"{r['elapsed_s']:>5.1f}s "
|
||||||
|
f"{r['text_len']:>6} "
|
||||||
|
f"{r.get('moyens_count', 0):>7} "
|
||||||
|
f"{r.get('confrontation_count', 0):>4} "
|
||||||
|
f"{r.get('codes_nd_count', 0):>3} "
|
||||||
|
f"{r.get('refs_count', 0):>5} "
|
||||||
|
f"{r['rag_sources']:>4} "
|
||||||
|
f"{r.get('guardian_bio_corrections', 0):>5} "
|
||||||
|
f"{r.get('guardian_codes_moved', 0):>4} "
|
||||||
|
f"{r.get('guardian_text_replacements', 0):>5} "
|
||||||
|
f"{str(r.get('guardian_score_factuel', 'N/A')):>4}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user