- Résolution des libellés CIM-10 pour les codes contestés (dp_ucr, da_ucr, dr_ucr) - Fallback DP depuis dp_ucr quand le pipeline n'extrait pas de diagnostic principal - Troncature arg_ucr augmentée de 200 à 500 chars pour conserver les citations de règles - Requête RAG 4 : définitions CIM-10 (inclusion/exclusion) des codes contestés - Requête RAG 5 : extraction et recherche des règles nommées (RègleT7, Annexe, etc.) - Cap résultats RAG de 10 à 12 pour absorber les nouvelles requêtes - Reprocess viewer : pipeline complet (fusion + GHM + CPAM) pour dossiers multi-PDF - Affichage structuré response_data dans le viewer (analyse, preuves, références) - 7 nouveaux tests CPAM, 6 nouveaux tests viewer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
535 lines
22 KiB
Python
535 lines
22 KiB
Python
"""Génération de contre-argumentation pour les contrôles CPAM via RAG + Ollama."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
|
|
from ..config import ControleCPAM, DossierMedical, RAGSource
|
|
from ..medical.cim10_dict import normalize_code, validate_code
|
|
from ..medical.ollama_client import call_anthropic, call_ollama
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _search_rag_for_control(controle: ControleCPAM, dossier: DossierMedical) -> list[dict]:
|
|
"""Recherche RAG ciblée pour le sujet du désaccord.
|
|
|
|
Effectue 2-3 recherches ciblées au lieu d'une requête fourre-tout :
|
|
1. Codes contestés → règles de codage spécifiques
|
|
2. Argument CPAM → passages Guide Méthodo contradictoires
|
|
3. Contexte clinique (optionnel) → définitions CIM-10 des codes en jeu
|
|
"""
|
|
try:
|
|
from ..medical.rag_search import search_similar_cpam
|
|
except Exception:
|
|
logger.warning("Index RAG non disponible pour la contre-argumentation")
|
|
return []
|
|
|
|
all_results: list[dict] = []
|
|
|
|
# Requête 1 — Codes contestés (règles de codage)
|
|
if controle.dp_ucr or controle.da_ucr:
|
|
query_parts = []
|
|
if controle.dp_ucr:
|
|
query_parts.append(f"règles codage {controle.dp_ucr} diagnostic principal")
|
|
if controle.da_ucr:
|
|
query_parts.append(f"diagnostic associé significatif {controle.da_ucr} CMA")
|
|
query_codes = " ".join(query_parts)
|
|
results_codes = search_similar_cpam(query_codes, top_k=6)
|
|
logger.debug(" RAG requête codes : %d résultats", len(results_codes))
|
|
all_results.extend(results_codes)
|
|
|
|
# Requête 2 — Argument CPAM (recherche dans le Guide Méthodo)
|
|
query_parts_arg = []
|
|
if controle.titre:
|
|
query_parts_arg.append(controle.titre)
|
|
arg_short = controle.arg_ucr[:500] if controle.arg_ucr else ""
|
|
if arg_short:
|
|
query_parts_arg.append(arg_short)
|
|
query_arg = " ".join(query_parts_arg)
|
|
if query_arg.strip():
|
|
results_arg = search_similar_cpam(query_arg, top_k=6)
|
|
logger.debug(" RAG requête argument : %d résultats", len(results_arg))
|
|
all_results.extend(results_arg)
|
|
|
|
# Requête 3 — Contexte clinique (définitions CIM-10 des codes en jeu)
|
|
if controle.da_ucr and dossier.diagnostic_principal:
|
|
dp_text = dossier.diagnostic_principal.texte
|
|
das_texts = [
|
|
d.texte for d in dossier.diagnostics_associes
|
|
if d.cim10_suggestion and controle.da_ucr
|
|
and d.cim10_suggestion in controle.da_ucr
|
|
]
|
|
if das_texts:
|
|
query_clinique = f"{dp_text} {' '.join(das_texts)}"
|
|
results_clinique = search_similar_cpam(query_clinique, top_k=4)
|
|
logger.debug(" RAG requête clinique : %d résultats", len(results_clinique))
|
|
all_results.extend(results_clinique)
|
|
|
|
# Requête 4 — Définitions CIM-10 des codes contestés
|
|
contested_codes = []
|
|
for field in (controle.dp_ucr, controle.da_ucr, controle.dr_ucr):
|
|
if field:
|
|
contested_codes.extend(re.split(r"[,;\s]+", field.strip()))
|
|
for raw_code in contested_codes:
|
|
raw_code = raw_code.strip()
|
|
if not raw_code:
|
|
continue
|
|
norm = normalize_code(raw_code)
|
|
is_valid, label = validate_code(norm)
|
|
if is_valid and label:
|
|
query_def = f"CIM-10 {norm} {label} définition inclusion exclusion"
|
|
else:
|
|
query_def = f"CIM-10 {norm} définition codage"
|
|
results_def = search_similar_cpam(query_def, top_k=3)
|
|
logger.debug(" RAG requête CIM-10 %s : %d résultats", norm, len(results_def))
|
|
all_results.extend(results_def)
|
|
|
|
# Requête 5 — Règles explicitement citées dans l'argument CPAM
|
|
if controle.arg_ucr:
|
|
rule_patterns = [
|
|
r'(?:R[eè]gle\s*T?\s*\d+)',
|
|
r'(?:Annexe[\s-]*\d+[A-Za-z]*)',
|
|
r'(?:Situation de soins?\s+[^.]{5,40})',
|
|
]
|
|
rules_found = []
|
|
for pattern in rule_patterns:
|
|
rules_found.extend(re.findall(pattern, controle.arg_ucr, re.IGNORECASE))
|
|
if rules_found:
|
|
rules_unique = list(dict.fromkeys(rules_found))
|
|
query_rules = " ".join(rules_unique) + " guide méthodologique codage PMSI"
|
|
results_rules = search_similar_cpam(query_rules, top_k=4)
|
|
logger.debug(" RAG requête règles (%s) : %d résultats",
|
|
", ".join(rules_unique), len(results_rules))
|
|
all_results.extend(results_rules)
|
|
|
|
if not all_results:
|
|
return []
|
|
|
|
# Fusion : dédupliquer par (document, code, page), garder le meilleur score
|
|
seen: dict[tuple, dict] = {}
|
|
for r in all_results:
|
|
key = (r.get("document"), r.get("code"), r.get("page"))
|
|
if key in seen:
|
|
if r["score"] > seen[key]["score"]:
|
|
seen[key] = r
|
|
else:
|
|
seen[key] = r
|
|
|
|
merged = sorted(seen.values(), key=lambda r: r["score"], reverse=True)
|
|
return merged[:12]
|
|
|
|
|
|
def _get_code_label(code_str: str) -> str:
|
|
"""Résout le libellé CIM-10 pour un ou plusieurs codes."""
|
|
codes = re.split(r"[,;\s]+", code_str.strip())
|
|
labels = []
|
|
for raw in codes:
|
|
raw = raw.strip()
|
|
if not raw:
|
|
continue
|
|
norm = normalize_code(raw)
|
|
is_valid, label = validate_code(norm)
|
|
if is_valid and label:
|
|
labels.append(f"{norm} — {label}")
|
|
else:
|
|
labels.append(norm)
|
|
if not labels:
|
|
return ""
|
|
if len(labels) == 1:
|
|
parts = labels[0].split(" — ", 1)
|
|
return f" — {parts[1]}" if len(parts) > 1 else ""
|
|
return "\n " + "\n ".join(labels)
|
|
|
|
|
|
def _build_cpam_prompt(
|
|
dossier: DossierMedical,
|
|
controle: ControleCPAM,
|
|
sources: list[dict],
|
|
) -> str:
|
|
"""Construit le prompt pour la contre-argumentation CPAM."""
|
|
# Résumé du dossier médical
|
|
dossier_lines = []
|
|
|
|
if dossier.diagnostic_principal:
|
|
dp = dossier.diagnostic_principal
|
|
dp_code = f" ({dp.cim10_suggestion})" if dp.cim10_suggestion else ""
|
|
dossier_lines.append(f"- DP : {dp.texte}{dp_code}")
|
|
elif controle.dp_ucr:
|
|
dp_label = _get_code_label(controle.dp_ucr)
|
|
dossier_lines.append(
|
|
f"- DP : code {controle.dp_ucr}{dp_label} "
|
|
f"(codé par l'établissement, contesté par la CPAM)"
|
|
)
|
|
|
|
if dossier.diagnostics_associes:
|
|
das_parts = []
|
|
for das in dossier.diagnostics_associes:
|
|
code = f" ({das.cim10_suggestion})" if das.cim10_suggestion else ""
|
|
das_parts.append(f"{das.texte}{code}")
|
|
dossier_lines.append(f"- DAS : {', '.join(das_parts)}")
|
|
|
|
if dossier.actes_ccam:
|
|
actes = [f"{a.texte} ({a.code_ccam_suggestion})" if a.code_ccam_suggestion else a.texte
|
|
for a in dossier.actes_ccam]
|
|
dossier_lines.append(f"- Actes CCAM : {', '.join(actes)}")
|
|
|
|
sejour = dossier.sejour
|
|
if sejour.duree_sejour is not None:
|
|
dossier_lines.append(f"- Durée séjour : {sejour.duree_sejour} jours")
|
|
if sejour.sexe or sejour.age is not None:
|
|
patient_info = []
|
|
if sejour.sexe:
|
|
patient_info.append(sejour.sexe)
|
|
if sejour.age is not None:
|
|
patient_info.append(f"{sejour.age} ans")
|
|
dossier_lines.append(f"- Patient : {', '.join(patient_info)}")
|
|
if sejour.imc is not None:
|
|
dossier_lines.append(f"- IMC : {sejour.imc}")
|
|
|
|
if dossier.biologie_cle:
|
|
bio = [f"{b.test}: {b.valeur}" for b in dossier.biologie_cle[:5] if b.valeur]
|
|
if bio:
|
|
dossier_lines.append(f"- Biologie clé : {', '.join(bio)}")
|
|
|
|
if dossier.imagerie:
|
|
img_parts = []
|
|
for im in dossier.imagerie:
|
|
conclusion = f" — {im.conclusion}" if im.conclusion else ""
|
|
img_parts.append(f"{im.type}{conclusion}")
|
|
dossier_lines.append(f"- Imagerie : {', '.join(img_parts)}")
|
|
|
|
if dossier.traitements_sortie:
|
|
trt_parts = []
|
|
for t in dossier.traitements_sortie[:10]:
|
|
posologie = f" {t.posologie}" if t.posologie else ""
|
|
trt_parts.append(f"{t.medicament}{posologie}")
|
|
dossier_lines.append(f"- Traitements de sortie : {', '.join(trt_parts)}")
|
|
|
|
if dossier.antecedents:
|
|
dossier_lines.append(f"- Antécédents : {', '.join(dossier.antecedents[:10])}")
|
|
|
|
if dossier.complications:
|
|
dossier_lines.append(f"- Complications : {', '.join(dossier.complications)}")
|
|
|
|
dossier_str = "\n".join(dossier_lines) if dossier_lines else "Non disponible"
|
|
|
|
# Section asymétrie : éléments que la CPAM n'avait pas
|
|
asymetrie_lines = []
|
|
|
|
if dossier.biologie_cle:
|
|
bio_details = []
|
|
for b in dossier.biologie_cle if len(dossier.biologie_cle) <= 10 else dossier.biologie_cle[:10]:
|
|
anomalie = " (anormale)" if b.anomalie else ""
|
|
if b.valeur:
|
|
bio_details.append(f"{b.test}: {b.valeur}{anomalie}")
|
|
if bio_details:
|
|
asymetrie_lines.append(f"- Biologie : {', '.join(bio_details)}")
|
|
|
|
if dossier.imagerie:
|
|
img_details = []
|
|
for im in dossier.imagerie:
|
|
conclusion = f" — {im.conclusion}" if im.conclusion else ""
|
|
img_details.append(f"{im.type}{conclusion}")
|
|
if img_details:
|
|
asymetrie_lines.append(f"- Imagerie : {', '.join(img_details)}")
|
|
|
|
if dossier.traitements_sortie:
|
|
trt_details = []
|
|
for t in dossier.traitements_sortie[:10]:
|
|
posologie = f" {t.posologie}" if t.posologie else ""
|
|
trt_details.append(f"{t.medicament}{posologie}")
|
|
if trt_details:
|
|
asymetrie_lines.append(f"- Traitements : {', '.join(trt_details)}")
|
|
|
|
if dossier.actes_ccam:
|
|
actes_details = [
|
|
f"{a.texte} ({a.code_ccam_suggestion})" if a.code_ccam_suggestion else a.texte
|
|
for a in dossier.actes_ccam
|
|
]
|
|
if actes_details:
|
|
asymetrie_lines.append(f"- Actes CCAM : {', '.join(actes_details)}")
|
|
|
|
asymetrie_str = ""
|
|
if asymetrie_lines:
|
|
asymetrie_str = (
|
|
"\n\nÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM "
|
|
"(l'UCR n'a eu que le CRH et les codes) :\n"
|
|
+ "\n".join(asymetrie_lines)
|
|
)
|
|
|
|
# Codes contestés par la CPAM (avec libellés CIM-10 résolus)
|
|
codes_contestes = []
|
|
if controle.dp_ucr:
|
|
codes_contestes.append(f"DP proposé par UCR : {controle.dp_ucr}{_get_code_label(controle.dp_ucr)}")
|
|
if controle.da_ucr:
|
|
codes_contestes.append(f"DA proposés par UCR : {controle.da_ucr}{_get_code_label(controle.da_ucr)}")
|
|
if controle.dr_ucr:
|
|
codes_contestes.append(f"DR proposé par UCR : {controle.dr_ucr}{_get_code_label(controle.dr_ucr)}")
|
|
if controle.actes_ucr:
|
|
codes_contestes.append(f"Actes proposés par UCR : {controle.actes_ucr}")
|
|
codes_str = "\n".join(codes_contestes) if codes_contestes else "Aucun code spécifique proposé"
|
|
|
|
# Sources RAG
|
|
sources_text = ""
|
|
for i, src in enumerate(sources, 1):
|
|
doc_name = {
|
|
"cim10": "CIM-10 FR 2026",
|
|
"cim10_alpha": "CIM-10 Index Alphabétique 2026",
|
|
"guide_methodo": "Guide Méthodologique MCO 2026",
|
|
"ccam": "CCAM PMSI V4 2025",
|
|
}.get(src.get("document", ""), src.get("document", ""))
|
|
|
|
code_info = f" (code: {src['code']})" if src.get("code") else ""
|
|
page_info = f" [page {src['page']}]" if src.get("page") else ""
|
|
|
|
sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n"
|
|
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
|
|
|
|
return f"""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.
|
|
|
|
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.
|
|
|
|
DOSSIER MÉDICAL DE L'ÉTABLISSEMENT :
|
|
{dossier_str}
|
|
{asymetrie_str}
|
|
|
|
OBJET DU DÉSACCORD : {controle.titre}
|
|
|
|
ARGUMENTATION DE LA CPAM (UCR) :
|
|
{controle.arg_ucr}
|
|
|
|
DÉCISION UCR : {controle.decision_ucr}
|
|
|
|
CODES CONTESTÉS :
|
|
{codes_str}
|
|
|
|
SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
|
|
{sources_text}
|
|
|
|
CONSIGNES :
|
|
|
|
É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
|
|
|
|
AXE MÉDICAL :
|
|
- Analyse le bien-fondé médical du codage de l'établissement
|
|
- CITE les éléments cliniques EXACTS du dossier : valeurs bio précises (ex: CRP 180 mg/L), résultats imagerie verbatim, traitements avec molécules et posologies
|
|
- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
|
|
- Ne mentionne que les éléments réellement présents dans le dossier fourni
|
|
|
|
AXE ASYMÉTRIE D'INFORMATION :
|
|
- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis
|
|
- Pour CHAQUE élément clinique pertinent, cite les VALEURS EXACTES et explique leur signification clinique
|
|
- Démontre en quoi ces éléments complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté
|
|
- Ne mentionne AUCUN élément qui n'est pas dans le dossier fourni
|
|
|
|
MISE EN FORME :
|
|
- Structure chaque section avec des tirets pour lister les arguments distincts
|
|
- Un argument par puce, avec la preuve ou la référence associée
|
|
|
|
AXE RÉGLEMENTAIRE :
|
|
- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle
|
|
- Confronte le raisonnement CPAM au texte EXACT des sources fournies
|
|
- Format OBLIGATOIRE pour chaque référence : [Document - page N] suivi d'une CITATION VERBATIM du passage pertinent
|
|
- INTERDICTION ABSOLUE de citer une référence qui ne figure pas dans les sources fournies ci-dessus
|
|
- Si aucune source pertinente n'est disponible → écrire explicitement "Pas de source réglementaire disponible"
|
|
- Relève les contradictions entre l'argumentation CPAM et les règles officielles
|
|
|
|
Réponds UNIQUEMENT avec un objet JSON au format suivant :
|
|
{{
|
|
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
|
|
"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": [
|
|
{{"element": "biologie|imagerie|traitement|acte|clinique", "valeur": "valeur exacte du dossier", "signification": "explication clinique"}}
|
|
],
|
|
"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 : points reconnus à la CPAM, mais pourquoi le codage initial est néanmoins justifié"
|
|
}}"""
|
|
|
|
|
|
def _validate_references(parsed: dict, sources: list[dict]) -> list[str]:
|
|
"""Vérifie que les références citées correspondent aux sources RAG fournies.
|
|
|
|
Returns:
|
|
Liste d'avertissements pour les références non vérifiables.
|
|
"""
|
|
warnings = []
|
|
refs = parsed.get("references")
|
|
if not refs or not isinstance(refs, list):
|
|
return warnings
|
|
|
|
# Construire un set des documents sources disponibles
|
|
source_docs = set()
|
|
for src in sources:
|
|
doc_name = src.get("document", "")
|
|
source_docs.add(doc_name)
|
|
# Ajouter les noms lisibles aussi
|
|
readable = {
|
|
"cim10": "CIM-10 FR 2026",
|
|
"cim10_alpha": "CIM-10 Index Alphabétique 2026",
|
|
"guide_methodo": "Guide Méthodologique MCO 2026",
|
|
"ccam": "CCAM PMSI V4 2025",
|
|
}.get(doc_name, "")
|
|
if readable:
|
|
source_docs.add(readable)
|
|
source_docs.add(readable.lower())
|
|
|
|
if not source_docs:
|
|
return warnings
|
|
|
|
for ref in refs:
|
|
if not isinstance(ref, dict):
|
|
continue
|
|
doc = ref.get("document", "")
|
|
if doc and not any(sd in doc.lower() or doc.lower() in sd.lower() for sd in source_docs if sd):
|
|
warnings.append(f"Référence non vérifiable : {doc}")
|
|
logger.warning("CPAM : référence non vérifiable « %s »", doc)
|
|
|
|
return warnings
|
|
|
|
|
|
def _format_response(parsed: dict, ref_warnings: list[str] | None = None) -> str:
|
|
"""Formate la réponse LLM en texte lisible."""
|
|
sections = []
|
|
|
|
analyse = parsed.get("analyse_contestation")
|
|
if analyse:
|
|
sections.append(f"ANALYSE DE LA CONTESTATION\n{analyse}")
|
|
|
|
accord = parsed.get("points_accord")
|
|
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
|
|
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 = parsed.get("preuves_dossier")
|
|
if preuves and isinstance(preuves, list):
|
|
preuves_lines = []
|
|
for p in preuves:
|
|
if isinstance(p, dict):
|
|
elem = p.get("element", "")
|
|
valeur = p.get("valeur", "")
|
|
signif = p.get("signification", "")
|
|
preuves_lines.append(f"- [{elem}] {valeur} → {signif}")
|
|
if preuves_lines:
|
|
sections.append(f"PREUVES DU DOSSIER\n" + "\n".join(preuves_lines))
|
|
|
|
contre_asym = parsed.get("contre_arguments_asymetrie")
|
|
if contre_asym:
|
|
sections.append(f"ASYMÉTRIE D'INFORMATION\n{contre_asym}")
|
|
|
|
contre_regl = parsed.get("contre_arguments_reglementaires")
|
|
if contre_regl:
|
|
sections.append(f"CONTRE-ARGUMENTS RÉGLEMENTAIRES\n{contre_regl}")
|
|
|
|
# Fallback : ancien champ unique (réponses en cache existantes)
|
|
if not contre_med and not contre_asym and not contre_regl:
|
|
contre = parsed.get("contre_arguments")
|
|
if contre:
|
|
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
|
|
|
|
# Références structurées (nouveau format liste) ou ancien format string
|
|
refs = parsed.get("references")
|
|
if refs:
|
|
if isinstance(refs, list):
|
|
ref_lines = []
|
|
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}")
|
|
if ref_lines:
|
|
sections.append(f"REFERENCES\n" + "\n".join(ref_lines))
|
|
else:
|
|
sections.append(f"REFERENCES\n{refs}")
|
|
|
|
conclusion = parsed.get("conclusion")
|
|
if conclusion:
|
|
sections.append(f"CONCLUSION\n{conclusion}")
|
|
|
|
# Avertissements sur les références non vérifiables
|
|
if ref_warnings:
|
|
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
|
|
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")
|
|
|
|
return "\n\n".join(sections)
|
|
|
|
|
|
def generate_cpam_response(
|
|
dossier: DossierMedical,
|
|
controle: ControleCPAM,
|
|
) -> tuple[str, dict | None, list[RAGSource]]:
|
|
"""Génère une contre-argumentation pour un contrôle CPAM.
|
|
|
|
Args:
|
|
dossier: Le dossier médical analysé.
|
|
controle: Le contrôle CPAM à contester.
|
|
|
|
Returns:
|
|
Tuple (texte de contre-argumentation, dict LLM structuré ou None, sources RAG utilisées).
|
|
"""
|
|
logger.info("CPAM : génération contre-argumentation pour OGC %d — %s",
|
|
controle.numero_ogc, controle.titre)
|
|
|
|
# 1. Recherche RAG ciblée
|
|
sources = _search_rag_for_control(controle, dossier)
|
|
logger.info(" RAG : %d sources trouvées", len(sources))
|
|
|
|
# 2. Construction du prompt
|
|
prompt = _build_cpam_prompt(dossier, controle, sources)
|
|
|
|
# 3. Appel LLM — Ollama (modèle par défaut) > Haiku fallback
|
|
result = call_ollama(prompt, temperature=0.1, max_tokens=4000)
|
|
if result is not None:
|
|
logger.info(" Contre-argumentation via Ollama")
|
|
else:
|
|
logger.info(" Ollama indisponible → fallback Anthropic Haiku")
|
|
result = call_anthropic(prompt, temperature=0.1, max_tokens=4000)
|
|
if result is not None:
|
|
logger.info(" Contre-argumentation via Anthropic Haiku")
|
|
|
|
# 4. Conversion des sources RAG
|
|
rag_sources = [
|
|
RAGSource(
|
|
document=s.get("document", ""),
|
|
page=s.get("page"),
|
|
code=s.get("code"),
|
|
extrait=s.get("extrait", "")[:200],
|
|
)
|
|
for s in sources
|
|
]
|
|
|
|
if result is None:
|
|
logger.warning(" LLM non disponible — contre-argumentation non générée")
|
|
return "", None, rag_sources
|
|
|
|
# 5. Validation des références
|
|
ref_warnings = _validate_references(result, sources)
|
|
if ref_warnings:
|
|
logger.warning(" CPAM : %d référence(s) non vérifiable(s)", len(ref_warnings))
|
|
|
|
# 6. Formater la réponse
|
|
text = _format_response(result, ref_warnings)
|
|
logger.info(" Contre-argumentation générée (%d caractères)", len(text))
|
|
|
|
return text, result, rag_sources
|