From 540e0cb4009747984134bcab285ae980c282d321 Mon Sep 17 00:00:00 2001 From: dom Date: Thu, 19 Feb 2026 20:51:52 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20architecture=20multi-mod=C3=A8les=20LLM?= =?UTF-8?q?=20+=20externalisation=20des=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout OLLAMA_MODELS (coding/cpam/validation/qc) dans config.py avec get_model() - Paramètre role= dans call_ollama() pour dispatch par rôle - Cache Ollama : modèle stocké par entrée (migration auto de l'ancien format) - 7 prompts externalisés dans src/prompts/templates.py (format str.format) - Viewer : admin multi-modèles, endpoint PDF avec redaction, source texte - Documentation prompts dans docs/prompts.md Co-Authored-By: Claude Opus 4.6 --- docs/prompts.md | 155 ++++++++++++++ requirements.txt | 1 + src/config.py | 13 ++ src/control/cpam_response.py | 175 ++++------------ src/medical/cim10_extractor.py | 35 +--- src/medical/ollama_cache.py | 72 +++++-- src/medical/ollama_client.py | 8 +- src/medical/rag_search.py | 154 +++----------- src/prompts/__init__.py | 21 ++ src/prompts/templates.py | 336 +++++++++++++++++++++++++++++++ src/viewer/app.py | 64 +++++- src/viewer/pdf_redactor.py | 154 ++++++++++++++ src/viewer/templates/base.html | 33 ++- src/viewer/templates/detail.html | 117 +++++++++-- tests/test_cpam_response.py | 6 +- tests/test_ollama_cache.py | 88 +++++++- tests/test_viewer.py | 142 +++++++++++++ 17 files changed, 1221 insertions(+), 353 deletions(-) create mode 100644 docs/prompts.md create mode 100644 src/prompts/__init__.py create mode 100644 src/prompts/templates.py create mode 100644 src/viewer/pdf_redactor.py diff --git a/docs/prompts.md b/docs/prompts.md new file mode 100644 index 0000000..4ed27bb --- /dev/null +++ b/docs/prompts.md @@ -0,0 +1,155 @@ +# Prompts LLM — Pipeline T2A + +Ce document récapitule les 7 prompts externalisés dans `src/prompts/templates.py`. + +Chaque template utilise `str.format(**kwargs)` — les accolades JSON sont échappées via `{{` / `}}`. + +--- + +## 1. CODING_CIM10 — Codage CIM-10 (DP / DAS) + +| Paramètre | Valeur | +|-----------|--------| +| **Rôle** | `coding` | +| **Température** | 0.1 | +| **Max tokens** | 2 500 | +| **Appelé par** | `rag_search.py → _build_prompt()` | + +**Variables** : `texte`, `type_diag`, `ctx_str`, `sources_text` + +**Fonction** : Code un diagnostic en CIM-10 à partir des sources RAG. Le LLM joue un médecin DIM et doit discriminer entre codes candidats en citant les règles d'inclusion/exclusion. + +**Sortie JSON** : `analyse_clinique`, `codes_candidats`, `discrimination`, `regle_pmsi`, `code`, `confidence`, `justification`, `preuves_cliniques[]` + +--- + +## 2. CODING_CCAM — Codage CCAM (actes) + +| Paramètre | Valeur | +|-----------|--------| +| **Rôle** | `coding` | +| **Température** | 0.1 | +| **Max tokens** | 2 500 | +| **Appelé par** | `rag_search.py → _build_prompt_ccam()` | + +**Variables** : `texte`, `ctx_str`, `sources_text` + +**Fonction** : Code un acte chirurgical/médical en CCAM (4 lettres + 3 chiffres). Vérifie activité, regroupement, et tarif secteur 1. + +**Sortie JSON** : `analyse_acte`, `codes_candidats`, `discrimination`, `code`, `confidence`, `justification` + +--- + +## 3. DAS_EXTRACTION — Extraction DAS supplémentaires + +| Paramètre | Valeur | +|-----------|--------| +| **Rôle** | `coding` | +| **Température** | 0.1 | +| **Max tokens** | 2 000 | +| **Appelé par** | `rag_search.py → _build_prompt_das_extraction()` | + +**Variables** : `dp_texte`, `existing_str`, `ctx_str`, `text_medical` + +**Fonction** : Identifie les DAS non encore codés dans le texte médical. Exclut les doublons, les symptômes expliqués par un diagnostic précis, et les antécédents non pertinents. Attention aux valeurs biologiques normales. + +**Sortie JSON** : `diagnostics_supplementaires[{texte, code_cim10, justification}]` + +--- + +## 4. QC_VALIDATION — Validation qualité post-codage + +| Paramètre | Valeur | +|-----------|--------| +| **Rôle** | `qc` | +| **Température** | 0.1 | +| **Max tokens** | 2 500 | +| **Appelé par** | `cim10_extractor.py → _validate_justifications()` | + +**Variables** : `ctx_str`, `codes_section` + +**Fonction** : Contrôle qualité du codage complet. Vérifie pour chaque code : preuve clinique, spécificité, conflits/redondances. Peut recommander de maintenir, reclasser ou supprimer un code. + +**Sortie JSON** : `validations[{numero, code, verdict, confidence_recommandee, commentaire}]`, `alertes_globales[]` + +--- + +## 5. CPAM_EXTRACTION — Passe 1 : extraction structurée + +| Paramètre | Valeur | +|-----------|--------| +| **Rôle** | `cpam` | +| **Température** | 0.0 | +| **Max tokens** | 1 500 | +| **Appelé par** | `cpam_response.py → _extraction_pass()` | + +**Variables** : `dp_str`, `das_str`, `tagged_text`, `titre`, `arg_ucr`, `decision_ucr`, `dp_ucr_line`, `da_ucr_line` + +**Fonction** : Comprend la contestation CPAM sans argumenter. Extrait les éléments cliniques pertinents, les points d'accord potentiels, et identifie les codes en jeu. + +**Sortie JSON** : `comprehension_contestation`, `elements_cliniques_pertinents[{tag, pertinence}]`, `points_accord_potentiels[]`, `codes_en_jeu{dp_etablissement, dp_ucr, difference_cle}` + +--- + +## 6. CPAM_ARGUMENTATION — Passe 2 : contre-argumentation (3 axes) + +| Paramètre | Valeur | +|-----------|--------| +| **Rôle** | `cpam` | +| **Température** | 0.1 | +| **Max tokens** | 4 000 | +| **Appelé par** | `cpam_response.py → _build_cpam_prompt()` | + +**Variables** : `dossier_str`, `asymetrie_str`, `tagged_str`, `titre`, `arg_ucr`, `decision_ucr`, `codes_str`, `definitions_str`, `sources_text`, `extraction_str` + +**Fonction** : Produit la contre-argumentation CPAM complète selon 3 axes : +1. **Axe médical** — bien-fondé clinique avec citations exactes du dossier (tags `[XX-N]`) +2. **Axe asymétrie d'information** — éléments que la CPAM n'avait pas (biologie, imagerie, traitements) +3. **Axe réglementaire** — erreurs d'interprétation avec citations verbatim des sources + +**Contraintes clés** : +- Doit reconnaître au moins un point valide de la CPAM (crédibilité) +- Codes CIM-10 toujours cités explicitement avec libellé +- Références au format `[Document - page N]` + citation verbatim +- Prend en compte âge, mode d'entrée, durée de séjour + +**Sortie JSON** : `analyse_contestation`, `points_accord`, `contre_arguments_medicaux`, `preuves_dossier[{ref, element, valeur, signification}]`, `contre_arguments_asymetrie`, `contre_arguments_reglementaires`, `references[{document, page, citation}]`, `conclusion` + +--- + +## 7. CPAM_ADVERSARIAL — Passe 3 : validation adversariale + +| Paramètre | Valeur | +|-----------|--------| +| **Rôle** | `validation` | +| **Température** | 0.0 | +| **Max tokens** | 800 | +| **Appelé par** | `cpam_response.py → _validate_adversarial()` | + +**Variables** : `response_json`, `factual_section`, `normes_section`, `dp_ucr_line`, `da_ucr_line` + +**Fonction** : Relecture critique de la contre-argumentation générée. Vérifie : +1. Les valeurs citées existent dans les éléments factuels +2. Les qualifications bio (élevée/basse) respectent les normes de référence +3. La conclusion est cohérente avec l'argumentation +4. Les points d'accord ne contredisent pas les contre-arguments +5. Les codes CIM-10 de la conclusion sont cohérents + +**Point architectural** : ce prompt utilise le rôle `validation` (modèle **différent** du générateur) pour garantir une vérification indépendante. + +**Sortie JSON** : `coherent` (bool), `erreurs[]`, `score_confiance` (0-10) + +--- + +## Architecture multi-modèles + +Les rôles LLM sont définis dans `src/config.py` : + +| Rôle | Modèle par défaut | Variable d'env | +|------|-------------------|----------------| +| `coding` | `gemma3:27b-cloud` | `T2A_MODEL_CODING` | +| `cpam` | `deepseek-v3.2:cloud` | `T2A_MODEL_CPAM` | +| `validation` | `deepseek-v3.2:cloud` | `T2A_MODEL_VALIDATION` | +| `qc` | `gemma3:12b` | `T2A_MODEL_QC` | + +Le rôle `validation` utilise volontairement un modèle différent du `cpam` pour éviter l'auto-validation. diff --git a/requirements.txt b/requirements.txt index db44d54..19023de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ flask>=3.0.0 python-dotenv>=1.0.0 openpyxl>=3.0.0 pandas>=2.0.0 +PyMuPDF>=1.24.0 diff --git a/src/config.py b/src/config.py index 964afe0..4b1680f 100644 --- a/src/config.py +++ b/src/config.py @@ -40,6 +40,19 @@ OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "120")) OLLAMA_CACHE_PATH = BASE_DIR / "data" / "ollama_cache.json" OLLAMA_MAX_PARALLEL = int(os.environ.get("OLLAMA_MAX_PARALLEL", "2")) +# Rôles LLM — modèle dédié par tâche, surchargeable par variable d'env +OLLAMA_MODELS: dict[str, str] = { + "coding": os.environ.get("T2A_MODEL_CODING", "gemma3:27b-cloud"), + "cpam": os.environ.get("T2A_MODEL_CPAM", "deepseek-v3.2:cloud"), + "validation": os.environ.get("T2A_MODEL_VALIDATION", "deepseek-v3.2:cloud"), + "qc": os.environ.get("T2A_MODEL_QC", "gemma3:12b"), +} + + +def get_model(role: str) -> str: + """Résout le modèle pour un rôle donné, avec fallback sur OLLAMA_MODEL.""" + return OLLAMA_MODELS.get(role, OLLAMA_MODEL) + # --- Configuration RUM / établissement --- diff --git a/src/control/cpam_response.py b/src/control/cpam_response.py index 3b47ce2..78f6d05 100644 --- a/src/control/cpam_response.py +++ b/src/control/cpam_response.py @@ -9,6 +9,7 @@ from ..config import ControleCPAM, DossierMedical, RAGSource from ..medical.cim10_dict import normalize_code, validate_code from ..medical.cim10_extractor import BIO_NORMALS from ..medical.ollama_client import call_anthropic, call_ollama +from ..prompts import CPAM_EXTRACTION, CPAM_ARGUMENTATION, CPAM_ADVERSARIAL logger = logging.getLogger(__name__) @@ -602,88 +603,18 @@ def _build_cpam_prompt( + "\n".join(ext_lines) ) - prompt = 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. - -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. - -DOSSIER MÉDICAL DE L'ÉTABLISSEMENT : -{dossier_str} -{asymetrie_str} -{tagged_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} -{definitions_str} - -SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) : -{sources_text} -{extraction_str} - -CONSIGNES : - -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 - -É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 en utilisant les tags [XX-N] fournis (ex: [BIO-1] CRP 180 mg/L) -- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies -- Ne mentionne AUCUN élément qui ne figure pas dans les éléments référencés ci-dessus - -AXE ASYMÉTRIE D'INFORMATION : -- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis -- Pour CHAQUE élément clinique pertinent, cite les VALEURS EXACTES et explique leur signification clinique -- Démontre en quoi ces éléments complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté -- Ne mentionne AUCUN élément qui n'est pas dans le dossier fourni - -MISE EN FORME : -- Structure chaque section avec des tirets pour lister les arguments distincts -- Un argument par puce, avec la preuve ou la référence associée - -AXE RÉGLEMENTAIRE : -- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle -- Confronte le raisonnement CPAM au texte EXACT des sources fournies -- Format OBLIGATOIRE pour chaque référence : [Document - page N] suivi d'une CITATION VERBATIM du passage pertinent -- INTERDICTION ABSOLUE de citer une référence qui ne figure pas dans les sources fournies ci-dessus -- Si aucune source pertinente n'est disponible → écrire explicitement "Pas de source réglementaire disponible" -- Relève les contradictions entre l'argumentation CPAM et les règles officielles - -Réponds UNIQUEMENT avec un objet JSON au format suivant : -{{ - "analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base", - "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", "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 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é" -}}""" + prompt = CPAM_ARGUMENTATION.format( + dossier_str=dossier_str, + asymetrie_str=asymetrie_str, + tagged_str=tagged_str, + titre=controle.titre, + arg_ucr=controle.arg_ucr, + decision_ucr=controle.decision_ucr, + codes_str=codes_str, + definitions_str=definitions_str, + sources_text=sources_text, + extraction_str=extraction_str, + ) return prompt, tag_map @@ -845,35 +776,19 @@ def _validate_adversarial( normes_lines.append(f" {test}: {lo}-{hi}") normes_section = "NORMES BIOLOGIQUES DE RÉFÉRENCE :\n" + "\n".join(normes_lines) - prompt = f"""Tu es un relecteur critique. Vérifie la cohérence de cette contre-argumentation CPAM. + dp_ucr_line = f"DP UCR : {controle.dp_ucr}" if controle.dp_ucr else "" + da_ucr_line = f"DA UCR : {controle.da_ucr}" if controle.da_ucr else "" -RÉPONSE GÉNÉRÉE : -{response_json} - -{factual_section} - -{normes_section} - -CODES CONTESTÉS : -{f"DP UCR : {controle.dp_ucr}" if controle.dp_ucr else ""} -{f"DA UCR : {controle.da_ucr}" if controle.da_ucr else ""} - -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 - -Réponds UNIQUEMENT en JSON : -{{ - "coherent": true ou false, - "erreurs": ["description précise de chaque incohérence trouvée"], - "score_confiance": 0 à 10 -}}""" + prompt = CPAM_ADVERSARIAL.format( + response_json=response_json, + factual_section=factual_section, + normes_section=normes_section, + dp_ucr_line=dp_ucr_line, + da_ucr_line=da_ucr_line, + ) logger.debug(" Validation adversariale") - result = call_ollama(prompt, temperature=0.0, max_tokens=800) + result = call_ollama(prompt, temperature=0.0, max_tokens=800, role="validation") if result is None: result = call_anthropic(prompt, temperature=0.0, max_tokens=800) if result is None: @@ -924,36 +839,22 @@ def _extraction_pass( # Contexte tagué (réutilise la même fonction) tagged_text, _ = _build_tagged_context(dossier) - prompt = f"""Tu es un médecin DIM expert. Analyse cette contestation CPAM sans argumenter. + dp_ucr_line = f"DP proposé UCR : {controle.dp_ucr}" if controle.dp_ucr else "" + da_ucr_line = f"DA proposés UCR : {controle.da_ucr}" if controle.da_ucr else "" -DOSSIER : -- DP : {dp_str or "Non extrait"} -- DAS : {das_str or "Aucun"} -{tagged_text} - -CONTESTATION CPAM : -Titre : {controle.titre} -Argument : {controle.arg_ucr} -Décision : {controle.decision_ucr} -{f"DP proposé UCR : {controle.dp_ucr}" if controle.dp_ucr else ""} -{f"DA proposés UCR : {controle.da_ucr}" if controle.da_ucr else ""} - -Réponds UNIQUEMENT en JSON : -{{ - "comprehension_contestation": "Résumé factuel : que conteste la CPAM et pourquoi", - "elements_cliniques_pertinents": [ - {{"tag": "BIO-1 ou texte libre", "pertinence": "en quoi cet élément est pertinent pour le codage contesté"}} - ], - "points_accord_potentiels": ["points où la CPAM a partiellement raison"], - "codes_en_jeu": {{ - "dp_etablissement": "code + libellé", - "dp_ucr": "code + libellé si proposé", - "difference_cle": "explication de la différence entre les deux codages" - }} -}}""" + prompt = CPAM_EXTRACTION.format( + dp_str=dp_str or "Non extrait", + das_str=das_str or "Aucun", + tagged_text=tagged_text, + titre=controle.titre, + arg_ucr=controle.arg_ucr, + decision_ucr=controle.decision_ucr, + dp_ucr_line=dp_ucr_line, + da_ucr_line=da_ucr_line, + ) logger.debug(" Passe 1 — extraction structurée") - result = call_ollama(prompt, temperature=0.0, max_tokens=1500) + result = call_ollama(prompt, temperature=0.0, max_tokens=1500, role="cpam") if result is None: result = call_anthropic(prompt, temperature=0.0, max_tokens=1500) if result is not None: @@ -990,8 +891,8 @@ def generate_cpam_response( # 3. Construction du prompt (passe 2 — argumentation) prompt, tag_map = _build_cpam_prompt(dossier, controle, sources, extraction) - # 4. Appel LLM — Ollama (modèle par défaut) > Haiku fallback - result = call_ollama(prompt, temperature=0.1, max_tokens=4000) + # 4. Appel LLM — Ollama (modèle CPAM dédié) > Haiku fallback + result = call_ollama(prompt, temperature=0.1, max_tokens=4000, role="cpam") if result is not None: logger.info(" Contre-argumentation via Ollama") else: diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index 1c2136e..cb131e0 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -168,13 +168,13 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None: try: from .rag_search import extract_das_llm from .ollama_cache import OllamaCache - from ..config import OLLAMA_CACHE_PATH, OLLAMA_MODEL + from ..config import OLLAMA_CACHE_PATH, get_model except ImportError: logger.warning("Module RAG non disponible pour l'extraction DAS LLM") return try: - cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL) + cache = OllamaCache(OLLAMA_CACHE_PATH, get_model("coding")) # Construire le contexte contexte = { @@ -1126,6 +1126,7 @@ def _validate_justifications(dossier: DossierMedical) -> None: try: from .ollama_client import call_ollama from .clinical_context import build_enriched_context, format_enriched_context + from ..prompts import QC_VALIDATION except ImportError: logger.warning("Module clinical_context non disponible pour la validation QC") return @@ -1152,36 +1153,10 @@ def _validate_justifications(dossier: DossierMedical) -> None: ctx = build_enriched_context(dossier) ctx_str = format_enriched_context(ctx) - prompt = f"""Tu es un médecin DIM contrôleur qualité PMSI. -Vérifie la cohérence et la justification de ce codage complet. - -DOSSIER CLINIQUE : -{ctx_str} - -CODAGE À VALIDER : -{codes_section} - -Pour CHAQUE code, vérifie : -1. Existe-t-il une preuve clinique concrète dans le dossier ? -2. Le code est-il le plus spécifique possible ? -3. Y a-t-il des conflits ou redondances avec d'autres codes ? - -Réponds avec un JSON : -{{ - "validations": [ - {{ - "numero": 1, - "code": "X99.9", - "verdict": "maintenir|reclasser|supprimer", - "confidence_recommandee": "high|medium|low", - "commentaire": "explication courte" - }} - ], - "alertes_globales": ["..."] -}}""" + prompt = QC_VALIDATION.format(ctx_str=ctx_str, codes_section=codes_section) try: - result = call_ollama(prompt, temperature=0.1, max_tokens=2500) + result = call_ollama(prompt, temperature=0.1, max_tokens=2500, role="qc") except Exception: logger.warning("Erreur lors de l'appel Ollama pour validation QC", exc_info=True) return diff --git a/src/medical/ollama_cache.py b/src/medical/ollama_cache.py index caf6c5f..4c232b4 100644 --- a/src/medical/ollama_cache.py +++ b/src/medical/ollama_cache.py @@ -14,32 +14,58 @@ class OllamaCache: """Cache JSON persistant pour éviter les appels Ollama redondants. Clé = (texte_diagnostic_normalisé, type). - Le modèle Ollama est stocké dans les métadonnées : si le modèle change, - le cache est automatiquement invalidé. + Le modèle Ollama est stocké PAR ENTRÉE : un cache hit ne se produit + que si le modèle correspond à celui demandé. + + Backward compat : si le fichier contient l'ancien format (modèle global), + les entrées sont migrées à la lecture avec le modèle global comme modèle + par entrée. """ - def __init__(self, cache_path: Path, model: str): + def __init__(self, cache_path: Path, model: str | None = None): self._path = cache_path - self._model = model + self._default_model = model self._lock = threading.Lock() self._data: dict[str, dict] = {} self._dirty = False self._load() def _load(self) -> None: - """Charge le cache depuis le disque.""" + """Charge le cache depuis le disque (avec migration ancien format).""" if not self._path.exists(): logger.info("Cache Ollama : nouveau cache (%s)", self._path) return try: raw = json.loads(self._path.read_text(encoding="utf-8")) - if raw.get("model") != self._model: - logger.info( - "Cache Ollama : modèle changé (%s → %s), cache invalidé", - raw.get("model"), self._model, - ) + + # Détection ancien format : clé "model" globale + "entries" sans "model" par entrée + global_model = raw.get("model") + entries = raw.get("entries", {}) + + if not entries: return - self._data = raw.get("entries", {}) + + # Vérifier si c'est l'ancien format (entrées sans clé "model") + sample_entry = next(iter(entries.values()), None) + is_old_format = sample_entry is not None and "model" not in sample_entry + + if is_old_format: + if global_model: + # Migrer : injecter le modèle global dans chaque entrée + logger.info( + "Cache Ollama : migration ancien format → modèle par entrée (%s, %d entrées)", + global_model, len(entries), + ) + for key, value in entries.items(): + self._data[key] = {"model": global_model, "result": value} + self._dirty = True # Réécrire au prochain save() + else: + logger.warning("Cache Ollama : ancien format sans modèle global, cache ignoré") + return + else: + # Nouveau format : chaque entrée a déjà {"model": ..., "result": ...} + self._data = entries + logger.info("Cache Ollama : %d entrées chargées", len(self._data)) except (json.JSONDecodeError, KeyError) as e: logger.warning("Cache Ollama : fichier corrompu (%s), réinitialisé", e) @@ -50,17 +76,24 @@ class OllamaCache: """Construit une clé normalisée.""" return f"{diag_type}::{texte.strip().lower()}" - def get(self, texte: str, diag_type: str) -> dict | None: - """Récupère un résultat caché, ou None si absent.""" + def get(self, texte: str, diag_type: str, model: str | None = None) -> dict | None: + """Récupère un résultat caché, ou None si absent ou modèle différent.""" key = self._make_key(texte, diag_type) + use_model = model or self._default_model with self._lock: - return self._data.get(key) + entry = self._data.get(key) + if entry is None: + return None + if use_model and entry.get("model") != use_model: + return None + return entry.get("result") - def put(self, texte: str, diag_type: str, result: dict) -> None: - """Stocke un résultat dans le cache.""" + def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None: + """Stocke un résultat dans le cache avec le modèle associé.""" key = self._make_key(texte, diag_type) + use_model = model or self._default_model or "unknown" with self._lock: - self._data[key] = result + self._data[key] = {"model": use_model, "result": result} self._dirty = True def save(self) -> None: @@ -69,10 +102,7 @@ class OllamaCache: if not self._dirty: return self._path.parent.mkdir(parents=True, exist_ok=True) - payload = { - "model": self._model, - "entries": self._data, - } + payload = {"entries": self._data} self._path.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", diff --git a/src/medical/ollama_client.py b/src/medical/ollama_client.py index 199e649..e972da4 100644 --- a/src/medical/ollama_client.py +++ b/src/medical/ollama_client.py @@ -8,7 +8,7 @@ import os import requests -from ..config import OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT +from ..config import OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT, get_model logger = logging.getLogger(__name__) @@ -83,6 +83,7 @@ def call_ollama( temperature: float = 0.1, max_tokens: int = 2500, model: str | None = None, + role: str | None = None, timeout: int | None = None, ) -> dict | None: """Appelle Ollama en mode JSON natif, avec fallback Anthropic si indisponible. @@ -91,13 +92,14 @@ def call_ollama( prompt: Le prompt à envoyer. temperature: Température de génération (défaut: 0.1). max_tokens: Nombre max de tokens (défaut: 2500). - model: Modèle Ollama à utiliser (défaut: OLLAMA_MODEL global). + model: Modèle Ollama explicite (prioritaire sur role). + role: Rôle LLM (coding, cpam, validation, qc) → résout le modèle via config. timeout: Timeout en secondes (défaut: OLLAMA_TIMEOUT global). Returns: Le dict JSON parsé, ou None en cas d'erreur. """ - use_model = model or OLLAMA_MODEL + use_model = model or (get_model(role) if role else OLLAMA_MODEL) use_timeout = timeout or OLLAMA_TIMEOUT for attempt in range(2): try: diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index a0c69df..b0182f3 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -8,8 +8,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from ..config import ( ActeCCAM, Diagnostic, DossierMedical, PreuveClinique, RAGSource, - OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL, - EMBEDDING_MODEL, RERANKER_MODEL, + OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, + EMBEDDING_MODEL, RERANKER_MODEL, get_model, ) from .cim10_dict import normalize_code, validate_code as cim10_validate, fallback_parent_code from .cim10_extractor import BIO_NORMALS @@ -17,6 +17,7 @@ from .clinical_context import build_enriched_context, format_enriched_context from .ccam_dict import validate_code as ccam_validate from .ollama_client import call_ollama, parse_json_response from .ollama_cache import OllamaCache +from ..prompts import CODING_CIM10, CODING_CCAM, DAS_EXTRACTION logger = logging.getLogger(__name__) @@ -357,8 +358,8 @@ def _format_contexte(contexte: dict) -> str: return "\n".join(lines) if lines else "Non précisé" -def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool = True) -> str: - """Construit le prompt expert DIM avec raisonnement structuré.""" +def _format_sources(sources: list[dict]) -> str: + """Formate les sources RAG pour injection dans un prompt.""" sources_text = "" for i, src in enumerate(sources, 1): doc_name = { @@ -373,91 +374,27 @@ def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n" sources_text += (src.get("extrait", "")[:800]) + "\n\n" + return sources_text + +def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool = True) -> str: + """Construit le prompt expert DIM avec raisonnement structuré.""" type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)" - ctx_str = format_enriched_context(contexte) - - return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. -Tu dois coder le diagnostic suivant en respectant STRICTEMENT les règles de l'ATIH. - -RÈGLES IMPÉRATIVES : -- Le code doit provenir UNIQUEMENT des sources CIM-10 fournies -- Distingue la DESCRIPTION CLINIQUE (ce que le médecin écrit) de la LOGIQUE DE CODAGE (ce que l'ATIH impose) -- Privilégie le code le plus SPÉCIFIQUE disponible (4e ou 5e caractère) -- Vérifie les notes d'inclusion/exclusion de chaque code candidat -- Si le diagnostic est un DP, il doit refléter le motif principal de prise en charge du séjour -- Si c'est un DAS, il doit avoir mobilisé des ressources supplémentaires pendant le séjour -- EXCLUSION SYMPTÔME : Si le diagnostic est un symptôme (R00-R99) et qu'un diagnostic précis (Chapitres I-XIV, A00-N99) expliquant ce symptôme est présent, le symptôme ne doit PAS être codé comme DAS - -DIAGNOSTIC À CODER : "{texte}" -TYPE : {type_diag} - -CONTEXTE CLINIQUE : -{ctx_str} - -SOURCES CIM-10 : -{sources_text} -Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : -{{ - "analyse_clinique": "que signifie ce diagnostic sur le plan médical", - "codes_candidats": "quels codes CIM-10 des sources sont compatibles", - "discrimination": "pourquoi choisir ce code plutôt qu'un autre (inclusions/exclusions, spécificité)", - "regle_pmsi": "conformité aux règles PMSI pour un {type_diag} (guide méthodologique)", - "code": "X99.9", - "confidence": "high ou medium ou low", - "justification": "explication courte en français", - "preuves_cliniques": [ - {{"type": "biologie|imagerie|traitement|acte|clinique", "element": "élément concret du dossier", "interpretation": "signification clinique justifiant le code"}} - ] -}}""" + return CODING_CIM10.format( + texte=texte, + type_diag=type_diag, + ctx_str=format_enriched_context(contexte), + sources_text=_format_sources(sources), + ) def _build_prompt_ccam(texte: str, sources: list[dict], contexte: dict) -> str: """Construit le prompt expert DIM pour le codage CCAM avec raisonnement structuré.""" - 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["document"], src["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" - - ctx_str = format_enriched_context(contexte) - - return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage CCAM PMSI. -Tu dois coder l'acte chirurgical/médical suivant en respectant STRICTEMENT la nomenclature CCAM. - -RÈGLES IMPÉRATIVES : -- Le code doit provenir UNIQUEMENT des sources CCAM fournies -- Un code CCAM est composé de 4 lettres + 3 chiffres (ex: HMFC004) -- Vérifie l'activité (1=acte technique, 4=anesthésie) et le regroupement -- Tiens compte du tarif secteur 1 pour valider la cohérence -- Si plusieurs codes sont possibles, choisis le plus spécifique à l'acte décrit -- En cas de doute, indique confidence "low" plutôt que de proposer un code inadapté - -ACTE À CODER : "{texte}" - -CONTEXTE CLINIQUE : -{ctx_str} - -SOURCES CCAM : -{sources_text} -Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : -{{ - "analyse_acte": "que décrit cet acte sur le plan technique/chirurgical", - "codes_candidats": "quels codes CCAM des sources sont compatibles", - "discrimination": "pourquoi choisir ce code plutôt qu'un autre (activité, regroupement, tarif)", - "code": "ABCD123", - "confidence": "high ou medium ou low", - "justification": "explication courte en français" -}}""" + return CODING_CCAM.format( + texte=texte, + ctx_str=format_enriched_context(contexte), + sources_text=_format_sources(sources), + ) def _parse_ollama_response(raw: str) -> dict | None: @@ -481,7 +418,7 @@ def _parse_ollama_response(raw: str) -> dict | None: def _call_ollama(prompt: str) -> dict | None: """Appelle Ollama (mode JSON) et parse la réponse avec reconstitution du raisonnement.""" - result = call_ollama(prompt, temperature=0.1, max_tokens=2500) + result = call_ollama(prompt, temperature=0.1, max_tokens=2500, role="coding") if result is None: return None # Reconstituer le raisonnement structuré @@ -666,45 +603,12 @@ def enrich_acte(acte: ActeCCAM, contexte: dict, cache: OllamaCache | None = None def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[str], dp_texte: str) -> str: """Construit le prompt pour l'extraction LLM de DAS supplémentaires.""" - ctx_str = format_enriched_context(contexte) - existing_str = "\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun" - - return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. -Analyse le texte médical suivant et identifie les diagnostics associés significatifs (DAS) qui n'ont PAS encore été codés. - -RÈGLES IMPÉRATIVES : -- Un DAS doit avoir mobilisé des ressources supplémentaires pendant le séjour -- Ne PAS proposer de doublons avec les DAS déjà codés ci-dessous -- Ne PAS proposer le diagnostic principal comme DAS -- Ne PAS coder les symptômes (R00-R99) si un diagnostic précis les explique -- Ne PAS coder les antécédents non pertinents pour le séjour -- Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère) -- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte -- ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale. - -DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"} - -DAS DÉJÀ CODÉS : -{existing_str} - -CONTEXTE CLINIQUE : -{ctx_str} - -TEXTE MÉDICAL : -{text[:4000]} - -Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : -{{ - "diagnostics_supplementaires": [ - {{ - "texte": "description du diagnostic", - "code_cim10": "X99.9", - "justification": "pourquoi ce DAS est pertinent pour le séjour" - }} - ] -}} - -Si aucun DAS supplémentaire n'est pertinent, retourne : {{"diagnostics_supplementaires": []}}""" + return DAS_EXTRACTION.format( + dp_texte=dp_texte or "Non identifié", + existing_str="\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun", + ctx_str=format_enriched_context(contexte), + text_medical=text[:4000], + ) def extract_das_llm( @@ -741,7 +645,7 @@ def extract_das_llm( # Construire le prompt et appeler Ollama prompt = _build_prompt_das_extraction(text, contexte, existing_das, dp_texte) - result = call_ollama(prompt, temperature=0.1, max_tokens=2000) + result = call_ollama(prompt, temperature=0.1, max_tokens=2000, role="coding") if result is None: logger.warning("Extraction DAS LLM : Ollama non disponible") @@ -766,7 +670,7 @@ def enrich_dossier(dossier: DossierMedical) -> None: Utilise un cache persistant et parallélise les appels Ollama pour les DAS et actes CCAM (max_workers = OLLAMA_MAX_PARALLEL). """ - cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL) + cache = OllamaCache(OLLAMA_CACHE_PATH, get_model("coding")) contexte = build_enriched_context(dossier) diff --git a/src/prompts/__init__.py b/src/prompts/__init__.py new file mode 100644 index 0000000..92501ca --- /dev/null +++ b/src/prompts/__init__.py @@ -0,0 +1,21 @@ +"""Prompts LLM externalisés — templates réutilisables pour le pipeline T2A.""" + +from .templates import ( + CODING_CIM10, + CODING_CCAM, + DAS_EXTRACTION, + QC_VALIDATION, + CPAM_EXTRACTION, + CPAM_ARGUMENTATION, + CPAM_ADVERSARIAL, +) + +__all__ = [ + "CODING_CIM10", + "CODING_CCAM", + "DAS_EXTRACTION", + "QC_VALIDATION", + "CPAM_EXTRACTION", + "CPAM_ARGUMENTATION", + "CPAM_ADVERSARIAL", +] diff --git a/src/prompts/templates.py b/src/prompts/templates.py new file mode 100644 index 0000000..220f8fd --- /dev/null +++ b/src/prompts/templates.py @@ -0,0 +1,336 @@ +"""Templates de prompts LLM pour le pipeline T2A. + +Chaque template est une chaîne avec des placeholders {variable} compatibles +avec str.format(**kwargs). Les accolades littérales (JSON) sont échappées +via {{ et }}. + +Convention de nommage : + CODING_* → rôle "coding" (codage CIM-10, CCAM) + QC_* → rôle "qc" (validation qualité) + CPAM_* → rôle "cpam" ou "validation" (contre-argumentation CPAM) +""" + +# --------------------------------------------------------------------------- +# 1. Codage CIM-10 (DP / DAS) +# --------------------------------------------------------------------------- +# Rôle : coding | Temperature : 0.1 | Max tokens : 2500 +# Fichier d'origine : src/medical/rag_search.py → _build_prompt() +# Variables : texte, type_diag, ctx_str, sources_text + +CODING_CIM10 = """\ +Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. +Tu dois coder le diagnostic suivant en respectant STRICTEMENT les règles de l'ATIH. + +RÈGLES IMPÉRATIVES : +- Le code doit provenir UNIQUEMENT des sources CIM-10 fournies +- Distingue la DESCRIPTION CLINIQUE (ce que le médecin écrit) de la LOGIQUE DE CODAGE (ce que l'ATIH impose) +- Privilégie le code le plus SPÉCIFIQUE disponible (4e ou 5e caractère) +- Vérifie les notes d'inclusion/exclusion de chaque code candidat +- Si le diagnostic est un DP, il doit refléter le motif principal de prise en charge du séjour +- Si c'est un DAS, il doit avoir mobilisé des ressources supplémentaires pendant le séjour +- EXCLUSION SYMPTÔME : Si le diagnostic est un symptôme (R00-R99) et qu'un diagnostic précis (Chapitres I-XIV, A00-N99) expliquant ce symptôme est présent, le symptôme ne doit PAS être codé comme DAS + +DIAGNOSTIC À CODER : "{texte}" +TYPE : {type_diag} + +CONTEXTE CLINIQUE : +{ctx_str} + +SOURCES CIM-10 : +{sources_text}\ +Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : +{{ + "analyse_clinique": "que signifie ce diagnostic sur le plan médical", + "codes_candidats": "quels codes CIM-10 des sources sont compatibles", + "discrimination": "pourquoi choisir ce code plutôt qu'un autre (inclusions/exclusions, spécificité)", + "regle_pmsi": "conformité aux règles PMSI pour un {type_diag} (guide méthodologique)", + "code": "X99.9", + "confidence": "high ou medium ou low", + "justification": "explication courte en français", + "preuves_cliniques": [ + {{"type": "biologie|imagerie|traitement|acte|clinique", "element": "élément concret du dossier", "interpretation": "signification clinique justifiant le code"}} + ] +}}""" + +# --------------------------------------------------------------------------- +# 2. Codage CCAM (actes chirurgicaux / médicaux) +# --------------------------------------------------------------------------- +# Rôle : coding | Temperature : 0.1 | Max tokens : 2500 +# Fichier d'origine : src/medical/rag_search.py → _build_prompt_ccam() +# Variables : texte, ctx_str, sources_text + +CODING_CCAM = """\ +Tu es un médecin DIM (Département d'Information Médicale) expert en codage CCAM PMSI. +Tu dois coder l'acte chirurgical/médical suivant en respectant STRICTEMENT la nomenclature CCAM. + +RÈGLES IMPÉRATIVES : +- Le code doit provenir UNIQUEMENT des sources CCAM fournies +- Un code CCAM est composé de 4 lettres + 3 chiffres (ex: HMFC004) +- Vérifie l'activité (1=acte technique, 4=anesthésie) et le regroupement +- Tiens compte du tarif secteur 1 pour valider la cohérence +- Si plusieurs codes sont possibles, choisis le plus spécifique à l'acte décrit +- En cas de doute, indique confidence "low" plutôt que de proposer un code inadapté + +ACTE À CODER : "{texte}" + +CONTEXTE CLINIQUE : +{ctx_str} + +SOURCES CCAM : +{sources_text}\ +Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : +{{ + "analyse_acte": "que décrit cet acte sur le plan technique/chirurgical", + "codes_candidats": "quels codes CCAM des sources sont compatibles", + "discrimination": "pourquoi choisir ce code plutôt qu'un autre (activité, regroupement, tarif)", + "code": "ABCD123", + "confidence": "high ou medium ou low", + "justification": "explication courte en français" +}}""" + +# --------------------------------------------------------------------------- +# 3. Extraction DAS supplémentaires (pass LLM) +# --------------------------------------------------------------------------- +# Rôle : coding | Temperature : 0.1 | Max tokens : 2000 +# Fichier d'origine : src/medical/rag_search.py → _build_prompt_das_extraction() +# Variables : dp_texte, existing_str, ctx_str, text_medical + +DAS_EXTRACTION = """\ +Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. +Analyse le texte médical suivant et identifie les diagnostics associés significatifs (DAS) qui n'ont PAS encore été codés. + +RÈGLES IMPÉRATIVES : +- Un DAS doit avoir mobilisé des ressources supplémentaires pendant le séjour +- Ne PAS proposer de doublons avec les DAS déjà codés ci-dessous +- Ne PAS proposer le diagnostic principal comme DAS +- Ne PAS coder les symptômes (R00-R99) si un diagnostic précis les explique +- Ne PAS coder les antécédents non pertinents pour le séjour +- Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère) +- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte +- ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale. + +DIAGNOSTIC PRINCIPAL : {dp_texte} + +DAS DÉJÀ CODÉS : +{existing_str} + +CONTEXTE CLINIQUE : +{ctx_str} + +TEXTE MÉDICAL : +{text_medical} + +Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : +{{ + "diagnostics_supplementaires": [ + {{ + "texte": "description du diagnostic", + "code_cim10": "X99.9", + "justification": "pourquoi ce DAS est pertinent pour le séjour" + }} + ] +}} + +Si aucun DAS supplémentaire n'est pertinent, retourne : {{"diagnostics_supplementaires": []}}""" + +# --------------------------------------------------------------------------- +# 4. Validation QC batch (contrôle qualité post-codage) +# --------------------------------------------------------------------------- +# Rôle : qc | Temperature : 0.1 | Max tokens : 2500 +# Fichier d'origine : src/medical/cim10_extractor.py → _validate_justifications() +# Variables : ctx_str, codes_section + +QC_VALIDATION = """\ +Tu es un médecin DIM contrôleur qualité PMSI. +Vérifie la cohérence et la justification de ce codage complet. + +DOSSIER CLINIQUE : +{ctx_str} + +CODAGE À VALIDER : +{codes_section} + +Pour CHAQUE code, vérifie : +1. Existe-t-il une preuve clinique concrète dans le dossier ? +2. Le code est-il le plus spécifique possible ? +3. Y a-t-il des conflits ou redondances avec d'autres codes ? + +Réponds avec un JSON : +{{ + "validations": [ + {{ + "numero": 1, + "code": "X99.9", + "verdict": "maintenir|reclasser|supprimer", + "confidence_recommandee": "high|medium|low", + "commentaire": "explication courte" + }} + ], + "alertes_globales": ["..."] +}}""" + +# --------------------------------------------------------------------------- +# 5. CPAM passe 1 — extraction structurée (compréhension) +# --------------------------------------------------------------------------- +# Rôle : cpam | Temperature : 0.0 | Max tokens : 1500 +# Fichier d'origine : src/control/cpam_response.py → _extraction_pass() +# Variables : dp_str, das_str, tagged_text, titre, arg_ucr, decision_ucr, +# dp_ucr_line, da_ucr_line + +CPAM_EXTRACTION = """\ +Tu es un médecin DIM expert. Analyse cette contestation CPAM sans argumenter. + +DOSSIER : +- DP : {dp_str} +- DAS : {das_str} +{tagged_text} + +CONTESTATION CPAM : +Titre : {titre} +Argument : {arg_ucr} +Décision : {decision_ucr} +{dp_ucr_line} +{da_ucr_line} + +Réponds UNIQUEMENT en JSON : +{{ + "comprehension_contestation": "Résumé factuel : que conteste la CPAM et pourquoi", + "elements_cliniques_pertinents": [ + {{"tag": "BIO-1 ou texte libre", "pertinence": "en quoi cet élément est pertinent pour le codage contesté"}} + ], + "points_accord_potentiels": ["points où la CPAM a partiellement raison"], + "codes_en_jeu": {{ + "dp_etablissement": "code + libellé", + "dp_ucr": "code + libellé si proposé", + "difference_cle": "explication de la différence entre les deux codages" + }} +}}""" + +# --------------------------------------------------------------------------- +# 6. CPAM passe 2 — contre-argumentation (3 axes) +# --------------------------------------------------------------------------- +# Rôle : cpam | Temperature : 0.1 | Max tokens : 4000 +# Fichier d'origine : src/control/cpam_response.py → _build_cpam_prompt() +# Variables : dossier_str, asymetrie_str, tagged_str, titre, arg_ucr, +# decision_ucr, codes_str, definitions_str, sources_text, +# extraction_str + +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. + +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. + +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. + +DOSSIER MÉDICAL DE L'ÉTABLISSEMENT : +{dossier_str} +{asymetrie_str} +{tagged_str} + +OBJET DU DÉSACCORD : {titre} + +ARGUMENTATION DE LA CPAM (UCR) : +{arg_ucr} + +DÉCISION UCR : {decision_ucr} + +CODES CONTESTÉS : +{codes_str} +{definitions_str} + +SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) : +{sources_text} +{extraction_str} + +CONSIGNES : + +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 + +É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 en utilisant les tags [XX-N] fournis (ex: [BIO-1] CRP 180 mg/L) +- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies +- Ne mentionne AUCUN élément qui ne figure pas dans les éléments référencés ci-dessus + +AXE ASYMÉTRIE D'INFORMATION : +- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis +- Pour CHAQUE élément clinique pertinent, cite les VALEURS EXACTES et explique leur signification clinique +- Démontre en quoi ces éléments complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté +- Ne mentionne AUCUN élément qui n'est pas dans le dossier fourni + +MISE EN FORME : +- Structure chaque section avec des tirets pour lister les arguments distincts +- Un argument par puce, avec la preuve ou la référence associée + +AXE RÉGLEMENTAIRE : +- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle +- Confronte le raisonnement CPAM au texte EXACT des sources fournies +- Format OBLIGATOIRE pour chaque référence : [Document - page N] suivi d'une CITATION VERBATIM du passage pertinent +- INTERDICTION ABSOLUE de citer une référence qui ne figure pas dans les sources fournies ci-dessus +- Si aucune source pertinente n'est disponible → écrire explicitement "Pas de source réglementaire disponible" +- Relève les contradictions entre l'argumentation CPAM et les règles officielles + +Réponds UNIQUEMENT avec un objet JSON au format suivant : +{{ + "analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base", + "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", "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 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é" +}}""" + +# --------------------------------------------------------------------------- +# 7. CPAM passe 3 — validation adversariale (relecture critique) +# --------------------------------------------------------------------------- +# Rôle : validation | Temperature : 0.0 | Max tokens : 800 +# Fichier d'origine : src/control/cpam_response.py → _validate_adversarial() +# Variables : response_json, factual_section, normes_section, +# dp_ucr_line, da_ucr_line + +CPAM_ADVERSARIAL = """\ +Tu es un relecteur critique. Vérifie la cohérence de cette contre-argumentation CPAM. + +RÉPONSE GÉNÉRÉE : +{response_json} + +{factual_section} + +{normes_section} + +CODES CONTESTÉS : +{dp_ucr_line} +{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 + +Réponds UNIQUEMENT en JSON : +{{ + "coherent": true ou false, + "erreurs": ["description précise de chaque incohérence trouvée"], + "score_confiance": 0 à 10 +}}""" diff --git a/src/viewer/app.py b/src/viewer/app.py index ddfeaab..75f4001 100644 --- a/src/viewer/app.py +++ b/src/viewer/app.py @@ -8,7 +8,7 @@ import re from pathlib import Path import requests -from flask import Flask, abort, render_template, request, jsonify +from flask import Flask, Response, abort, render_template, request, jsonify from markupsafe import Markup from werkzeug.utils import secure_filename @@ -16,7 +16,8 @@ from werkzeug.utils import secure_filename from collections import Counter from ..config import ( - ANONYMIZED_DIR, STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical, + ANONYMIZED_DIR, STRUCTURED_DIR, INPUT_DIR, REPORTS_DIR, + OLLAMA_URL, CCAM_DICT_PATH, DossierMedical, ALLOWED_EXTENSIONS, UPLOAD_MAX_SIZE_MB, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH, ) @@ -463,17 +464,30 @@ def create_app() -> Flask: @app.route("/admin/models", methods=["GET"]) def list_models(): models = fetch_ollama_models() - return jsonify({"models": models, "current": cfg.OLLAMA_MODEL}) + return jsonify({ + "models": models, + "current": cfg.OLLAMA_MODEL, + "roles": dict(cfg.OLLAMA_MODELS), + }) @app.route("/admin/models", methods=["POST"]) def set_model(): data = request.get_json(silent=True) or {} + role = data.get("role", "").strip() new_model = data.get("model", "").strip() if not new_model: return jsonify({"error": "Champ 'model' requis"}), 400 - cfg.OLLAMA_MODEL = new_model - logger.info("Modèle Ollama changé : %s", new_model) - return jsonify({"ok": True, "model": cfg.OLLAMA_MODEL}) + if role: + if role not in cfg.OLLAMA_MODELS: + return jsonify({"error": f"Rôle inconnu : {role}"}), 400 + cfg.OLLAMA_MODELS[role] = new_model + logger.info("Modèle Ollama rôle '%s' changé : %s", role, new_model) + return jsonify({"ok": True, "role": role, "model": new_model}) + else: + # Backward compat : changer le modèle global (fallback) + cfg.OLLAMA_MODEL = new_model + logger.info("Modèle Ollama global changé : %s", new_model) + return jsonify({"ok": True, "model": cfg.OLLAMA_MODEL}) @app.route("/reprocess/", methods=["POST"]) def reprocess(filepath: str): @@ -615,6 +629,44 @@ def create_app() -> Flask: logger.warning("Impossible de lire %s", txt_path) return jsonify(result) + # ------------------------------------------------------------------ + # API PDF caviardé + # ------------------------------------------------------------------ + + @app.route("/api/pdf//") + def serve_redacted_pdf(dossier_id: str, filename: str): + """Sert un PDF avec les données personnelles caviardées (rectangles noirs). + + Query params optionnels : + - highlight : texte à surligner en jaune + - page : numéro de page (1-indexed) pour cibler le surlignage + """ + from .pdf_redactor import load_entities_from_report, redact_pdf, highlight_text + + # Sécurité path traversal + safe_dir = (INPUT_DIR / dossier_id).resolve() + if not safe_dir.is_relative_to(INPUT_DIR.resolve()): + abort(403) + + pdf_path = safe_dir / filename + if not pdf_path.exists() or pdf_path.suffix.lower() != ".pdf": + abort(404) + + # Charger les entités depuis le rapport d'anonymisation + stem = Path(filename).stem.replace(" ", "_") + report_path = REPORTS_DIR / dossier_id / f"{stem}_report.json" + entities = load_entities_from_report(report_path) if report_path.exists() else set() + + pdf_bytes = redact_pdf(pdf_path, entities) + + # Surlignage optionnel + highlight = request.args.get("highlight", "") + page_num = request.args.get("page", type=int) + if highlight: + pdf_bytes = highlight_text(pdf_bytes, highlight, page_num) + + return Response(pdf_bytes, mimetype="application/pdf") + # ------------------------------------------------------------------ # Routes admin référentiels # ------------------------------------------------------------------ diff --git a/src/viewer/pdf_redactor.py b/src/viewer/pdf_redactor.py new file mode 100644 index 0000000..864bd50 --- /dev/null +++ b/src/viewer/pdf_redactor.py @@ -0,0 +1,154 @@ +"""Caviardage PDF à la volée — remplace les entités NER par des rectangles noirs.""" + +from __future__ import annotations + +import json +import logging +import time +import unicodedata +from pathlib import Path + +import fitz # PyMuPDF + +logger = logging.getLogger(__name__) + +# Cache simple : (pdf_path, frozenset(entities)) -> (bytes, timestamp) +_pdf_cache: dict[tuple[str, frozenset[str]], tuple[bytes, float]] = {} +_CACHE_TTL_S = 300 # 5 minutes + + +def load_entities_from_report(report_path: Path) -> set[str]: + """Extrait les entités uniques à caviarder depuis le rapport d'anonymisation.""" + data = json.loads(report_path.read_text(encoding="utf-8")) + entities: set[str] = set() + for e in data.get("entities_found", []): + orig = e.get("original", "") + # Ignorer les pseudonymes et les chaînes trop courtes + if not orig.startswith("[") and len(orig) >= 2: + entities.add(orig) + return entities + + +def redact_pdf(pdf_path: Path, entities: set[str]) -> bytes: + """Ouvre un PDF, caviarde toutes les occurrences des entités, retourne les bytes.""" + cache_key = (str(pdf_path), frozenset(entities)) + + # Vérifier le cache + if cache_key in _pdf_cache: + cached_bytes, cached_time = _pdf_cache[cache_key] + if time.time() - cached_time < _CACHE_TTL_S: + return cached_bytes + + doc = fitz.open(str(pdf_path)) + try: + for page in doc: + for entity in entities: + rects = page.search_for(entity) + for rect in rects: + page.add_redact_annot(rect, fill=(0, 0, 0)) + page.apply_redactions() + pdf_bytes = doc.tobytes() + finally: + doc.close() + + # Mettre en cache + _pdf_cache[cache_key] = (pdf_bytes, time.time()) + + # Nettoyer les entrées expirées + now = time.time() + expired = [k for k, (_, t) in _pdf_cache.items() if now - t >= _CACHE_TTL_S] + for k in expired: + _pdf_cache.pop(k, None) + + return pdf_bytes + + +def _strip_accents(s: str) -> str: + """Retire les accents d'une chaîne (é→e, è→e, etc.).""" + nfkd = unicodedata.normalize("NFD", s) + return "".join(c for c in nfkd if unicodedata.category(c) != "Mn") + + +def _add_highlight(page, rects) -> None: + """Ajoute des annotations highlight jaunes sur une liste de rectangles.""" + for rect in rects: + annot = page.add_highlight_annot(rect) + annot.set_colors(stroke=(1, 0.95, 0)) # jaune + annot.update() + + +def highlight_text(pdf_bytes: bytes, text: str, page_num: int | None = None) -> bytes: + """Ajoute un surlignage jaune sur les occurrences d'un texte dans le PDF. + + Appliqué après le caviardage (sur les bytes déjà caviardés). + Si page_num est fourni (1-indexed), cherche uniquement sur cette page. + + Le texte reçu est typiquement le nom du diagnostic/item médical (court, + une seule ligne) — pas l'excerpt brut qui est multi-lignes et bruité. + """ + if not text or len(text) < 3: + return pdf_bytes + + # Nettoyer le texte : retirer les "..." ajoutés par extract_excerpt() + clean = text.strip() + if clean.startswith("..."): + clean = clean[3:] + if clean.endswith("..."): + clean = clean[:-3] + clean = clean.strip() + if len(clean) < 3: + return pdf_bytes + + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + try: + pages = [doc[page_num - 1]] if page_num and 0 < page_num <= len(doc) else list(doc) + + single_line = " ".join(clean.split()) + found = False + + # Essai 1 : texte exact + for page in pages: + rects = page.search_for(single_line) + if rects: + _add_highlight(page, rects) + found = True + break + + # Essai 2 : fallback accents — le texte du diagnostic peut manquer + # d'accents ("renale") alors que le PDF les a ("rénale") + if not found: + page_text_cache: dict[int, str] = {} + for page in pages: + page_text = page.get_text() + page_text_cache[page.number] = page_text + # Chercher dans le texte normalisé (sans accents) du PDF + page_text_stripped = _strip_accents(page_text) + search_stripped = _strip_accents(single_line) + idx = page_text_stripped.lower().find(search_stripped.lower()) + if idx >= 0: + # Extraire le texte original (avec accents) à cette position + original_match = page_text[idx:idx + len(search_stripped)] + # Chercher ce texte exact dans le PDF + rects = page.search_for(original_match) + if rects: + _add_highlight(page, rects) + found = True + break + + # Essai 3 : si multi-lignes, chercher ligne par ligne + if not found and "\n" in clean: + for line in clean.split("\n"): + line = line.strip() + if len(line) >= 10: + for page in pages: + rects = page.search_for(line) + if rects: + _add_highlight(page, rects) + found = True + break + if found: + break + + return doc.tobytes() + finally: + doc.close() diff --git a/src/viewer/templates/base.html b/src/viewer/templates/base.html index fc48ba1..5b690e2 100644 --- a/src/viewer/templates/base.html +++ b/src/viewer/templates/base.html @@ -263,13 +263,21 @@ #source-modal-inner { background: #fff; border-radius: 12px; - max-width: 900px; + max-width: 95vw; + width: 95vw; margin: 0 auto; - max-height: 90vh; + max-height: 95vh; + height: 95vh; display: flex; flex-direction: column; box-shadow: 0 8px 30px rgba(0,0,0,0.2); } + #source-modal-inner.source-modal-text { + max-width: 900px; + width: auto; + max-height: 90vh; + height: auto; + } #source-header { padding: 1rem 1.25rem; border-bottom: 1px solid #e2e8f0; @@ -290,6 +298,11 @@ word-break: break-word; color: #334155; } + #source-content.source-content-pdf { + padding: 0; + white-space: normal; + overflow: hidden; + } #source-content mark { background: #fef08a; padding: 2px 0; @@ -306,6 +319,22 @@ font-weight: 600; } #source-close-btn:hover { background: #475569; } + + /* PDF file picker buttons */ + .src-file-btn { + display: inline-block; + padding: 0.35rem 0.75rem; + border-radius: 6px; + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #1e293b; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + } + .src-file-btn:hover { background: #e2e8f0; border-color: #3b82f6; } + .src-file-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } diff --git a/src/viewer/templates/detail.html b/src/viewer/templates/detail.html index 2b12a17..53e77a8 100644 --- a/src/viewer/templates/detail.html +++ b/src/viewer/templates/detail.html @@ -289,7 +289,7 @@

Diagnostic principal

{{ dp.texte }} - {% if dp.source_page %}{% endif %} + {% if dp.source_page %}{% endif %}
{% if dp.cim10_suggestion %} {{ dp.cim10_suggestion }} @@ -358,7 +358,7 @@ {{ das.source }} {% endif %} {% if das.source_page %} - + {% endif %} @@ -430,7 +430,7 @@
{{ alerte }}
{% endfor %} - {% if a.source_page %}{% endif %} + {% if a.source_page %}{% endif %} {% endfor %} @@ -450,7 +450,7 @@ {{ b.test }} {{ b.valeur or '' }} {% if b.anomalie %}Oui{% else %}—{% endif %} - {% if b.source_page %}{% endif %} + {% if b.source_page %}{% endif %} {% endfor %} @@ -466,7 +466,7 @@
{{ img.type }} {% if img.score %} — Score : {{ img.score }}{% endif %} - {% if img.source_page %}{% endif %} + {% if img.source_page %}{% endif %} {% if img.conclusion %}
{{ img.conclusion }}
{% endif %} @@ -487,7 +487,7 @@ {{ t.medicament }} {{ t.posologie or '' }} {% if t.code_atc %}{{ t.code_atc }}{% endif %} - {% if t.source_page %}{% endif %} + {% if t.source_page %}{% endif %} {% endfor %} @@ -501,7 +501,7 @@

Antécédents ({{ dossier.antecedents|length }})

    {% for a in dossier.antecedents %} -
  • {{ a.texte }}{% if a.source_page %} {% endif %}
  • +
  • {{ a.texte }}{% if a.source_page %} {% endif %}
  • {% endfor %}
@@ -513,7 +513,7 @@

Complications ({{ dossier.complications|length }})

    {% for c in dossier.complications %} -
  • {{ c.texte }}{% if c.source_page %} {% endif %}
  • +
  • {{ c.texte }}{% if c.source_page %} {% endif %}
  • {% endfor %}
@@ -536,36 +536,109 @@