feat: architecture multi-modèles LLM + externalisation des prompts

- 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 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-19 20:51:52 +01:00
parent 5c8c2817ec
commit 540e0cb400
17 changed files with 1221 additions and 353 deletions

155
docs/prompts.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

21
src/prompts/__init__.py Normal file
View File

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

336
src/prompts/templates.py Normal file
View File

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

View File

@@ -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/<path:filepath>", 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/<path:dossier_id>/<filename>")
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
# ------------------------------------------------------------------

154
src/viewer/pdf_redactor.py Normal file
View File

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

View File

@@ -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; }
</style>
</head>
<body>

View File

@@ -289,7 +289,7 @@
<h3>Diagnostic principal</h3>
<div style="font-size:0.95rem;margin-bottom:0.5rem;">
{{ dp.texte }}
{% if dp.source_page %}<button class="src-btn" data-excerpt="{{ dp.source_excerpt|default('',true)|e }}" data-page="{{ dp.source_page }}">p.{{ dp.source_page }}</button>{% endif %}
{% if dp.source_page %}<button class="src-btn" data-texte="{{ dp.texte|e }}" data-excerpt="{{ dp.source_excerpt|default('',true)|e }}" data-page="{{ dp.source_page }}">p.{{ dp.source_page }}</button>{% endif %}
</div>
{% if dp.cim10_suggestion %}
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
@@ -358,7 +358,7 @@
<span class="badge" style="background:#e0e7ff;color:#3730a3;font-size:0.7rem;">{{ das.source }}</span>
{% endif %}
{% if das.source_page %}
<button class="src-btn" data-excerpt="{{ das.source_excerpt|default('',true)|e }}" data-page="{{ das.source_page }}">p.{{ das.source_page }}</button>
<button class="src-btn" data-texte="{{ das.texte|e }}" data-excerpt="{{ das.source_excerpt|default('',true)|e }}" data-page="{{ das.source_page }}">p.{{ das.source_page }}</button>
{% endif %}
</td>
<td style="font-size:0.8rem;color:#475569;">
@@ -430,7 +430,7 @@
<div style="font-size:0.7rem;color:#dc2626;">{{ alerte }}</div>
{% endfor %}
</td>
<td>{% if a.source_page %}<button class="src-btn" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</td>
<td>{% if a.source_page %}<button class="src-btn" data-texte="{{ a.texte|e }}" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -450,7 +450,7 @@
<td>{{ b.test }}</td>
<td>{{ b.valeur or '' }}</td>
<td>{% if b.anomalie %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Oui</span>{% else %}—{% endif %}</td>
<td>{% if b.source_page %}<button class="src-btn" data-excerpt="{{ b.source_excerpt|default('',true)|e }}" data-page="{{ b.source_page }}">p.{{ b.source_page }}</button>{% endif %}</td>
<td>{% if b.source_page %}<button class="src-btn" data-texte="{{ b.test|e }}" data-excerpt="{{ b.source_excerpt|default('',true)|e }}" data-page="{{ b.source_page }}">p.{{ b.source_page }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -466,7 +466,7 @@
<div style="margin-bottom:0.5rem;">
<strong>{{ img.type }}</strong>
{% if img.score %} — Score : {{ img.score }}{% endif %}
{% if img.source_page %}<button class="src-btn" data-excerpt="{{ img.source_excerpt|default('',true)|e }}" data-page="{{ img.source_page }}">p.{{ img.source_page }}</button>{% endif %}
{% if img.source_page %}<button class="src-btn" data-texte="{{ img.type|e }}" data-excerpt="{{ img.source_excerpt|default('',true)|e }}" data-page="{{ img.source_page }}">p.{{ img.source_page }}</button>{% endif %}
{% if img.conclusion %}
<div style="font-size:0.85rem;color:#475569;">{{ img.conclusion }}</div>
{% endif %}
@@ -487,7 +487,7 @@
<td>{{ t.medicament }}</td>
<td>{{ t.posologie or '' }}</td>
<td>{% if t.code_atc %}<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ t.code_atc }}</span>{% endif %}</td>
<td>{% if t.source_page %}<button class="src-btn" data-excerpt="{{ t.source_excerpt|default('',true)|e }}" data-page="{{ t.source_page }}">p.{{ t.source_page }}</button>{% endif %}</td>
<td>{% if t.source_page %}<button class="src-btn" data-texte="{{ t.medicament|e }}" data-excerpt="{{ t.source_excerpt|default('',true)|e }}" data-page="{{ t.source_page }}">p.{{ t.source_page }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -501,7 +501,7 @@
<h3>Antécédents ({{ dossier.antecedents|length }})</h3>
<ul class="bullet">
{% for a in dossier.antecedents %}
<li>{{ a.texte }}{% if a.source_page %} <button class="src-btn" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</li>
<li>{{ a.texte }}{% if a.source_page %} <button class="src-btn" data-texte="{{ a.texte|e }}" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</li>
{% endfor %}
</ul>
</div>
@@ -513,7 +513,7 @@
<h3>Complications ({{ dossier.complications|length }})</h3>
<ul class="bullet">
{% for c in dossier.complications %}
<li>{{ c.texte }}{% if c.source_page %} <button class="src-btn" data-excerpt="{{ c.source_excerpt|default('',true)|e }}" data-page="{{ c.source_page }}">p.{{ c.source_page }}</button>{% endif %}</li>
<li>{{ c.texte }}{% if c.source_page %} <button class="src-btn" data-texte="{{ c.texte|e }}" data-excerpt="{{ c.source_excerpt|default('',true)|e }}" data-page="{{ c.source_page }}">p.{{ c.source_page }}</button>{% endif %}</li>
{% endfor %}
</ul>
</div>
@@ -536,36 +536,109 @@
<script>
/* --- Source modal --- */
let _sourceCache = null;
function getDossierId() {
// filepath = "103_23056749/103_23056749_fusionne_cim10.json"
// dossier_id = "103_23056749"
const _dossierId = (function() {
const fp = {{ filepath|tojson }};
const parts = fp.split('/');
return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
}
})();
const _sourceFiles = {{ dossier.source_files|tojson }};
function getDossierId() { return _dossierId; }
async function loadSourceTexts() {
if (_sourceCache !== null) return _sourceCache;
const dossierId = getDossierId();
if (!dossierId) { _sourceCache = {}; return _sourceCache; }
if (!_dossierId) { _sourceCache = {}; return _sourceCache; }
try {
const resp = await fetch('/api/source-text/' + dossierId);
const resp = await fetch('/api/source-text/' + _dossierId);
if (resp.ok) { _sourceCache = await resp.json(); }
else { _sourceCache = {}; }
} catch (e) { _sourceCache = {}; }
return _sourceCache;
}
async function showSource(excerpt, page) {
/* Teste si le PDF caviardé est disponible (HEAD request) */
async function pdfAvailable(dossierId, filename) {
try {
const resp = await fetch('/api/pdf/' + dossierId + '/' + encodeURIComponent(filename), {method: 'HEAD'});
return resp.ok;
} catch (e) { return false; }
}
/* Construit l'URL du PDF avec highlight + page */
function buildPdfUrl(dossierId, filename, page, excerpt) {
let url = '/api/pdf/' + dossierId + '/' + encodeURIComponent(filename);
const params = [];
if (excerpt) params.push('highlight=' + encodeURIComponent(excerpt));
if (page) params.push('page=' + page);
if (params.length) url += '?' + params.join('&');
url += '#page=' + (page || 1);
return url;
}
/* Affiche un PDF dans l'iframe */
function loadPdf(dossierId, filename, page, excerpt) {
const content = document.getElementById('source-content');
const url = buildPdfUrl(dossierId, filename, page, excerpt);
content.className = 'source-content-pdf';
content.innerHTML = '<iframe src="' + url + '" style="width:100%;height:100%;border:none;"></iframe>';
// Marquer le bouton actif
document.querySelectorAll('.src-file-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.src-file-btn').forEach(b => {
if (b.textContent === filename) b.classList.add('active');
});
}
/* Affiche le modal source — PDF caviardé si disponible, sinon fallback texte */
async function showSource(excerpt, page, texte) {
// Pour le surlignage PDF, on utilise le texte du diagnostic (pas l'excerpt brut)
const highlightText = texte || excerpt;
const modal = document.getElementById('source-modal');
const modalInner = document.getElementById('source-modal-inner');
const content = document.getElementById('source-content');
const title = document.getElementById('source-title');
title.textContent = 'Document source — Page ' + page;
content.innerHTML = '<em style="color:#94a3b8;">Chargement...</em>';
content.className = '';
modalInner.className = '';
modal.style.display = 'block';
// Essayer le mode PDF
if (_sourceFiles && _sourceFiles.length > 0 && _dossierId) {
const firstFile = _sourceFiles[0];
const available = await pdfAvailable(_dossierId, firstFile);
if (available) {
modalInner.className = '';
if (_sourceFiles.length === 1) {
loadPdf(_dossierId, firstFile, page, highlightText);
} else {
// Multi-PDF : boutons de sélection + iframe
const safeHighlight = (highlightText || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
let html = '<div style="padding:0.5rem 0.75rem;border-bottom:1px solid #e2e8f0;display:flex;gap:0.5rem;flex-wrap:wrap;">';
_sourceFiles.forEach(function(f) {
const safeF = f.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
html += '<button class="src-file-btn" onclick="loadPdf(\'' + _dossierId + '\', \'' + safeF + '\', ' + page + ', \'' + safeHighlight + '\')">' + f + '</button>';
});
html += '</div>';
html += '<iframe id="pdf-frame" style="width:100%;flex:1;border:none;"></iframe>';
content.className = 'source-content-pdf';
content.style.display = 'flex';
content.style.flexDirection = 'column';
content.innerHTML = html;
// Charger le premier PDF
const iframe = content.querySelector('iframe');
iframe.src = buildPdfUrl(_dossierId, firstFile, page, highlightText);
content.querySelector('.src-file-btn').classList.add('active');
}
return;
}
}
// Fallback : mode texte (ancien comportement)
modalInner.className = 'source-modal-text';
content.className = '';
content.style.display = '';
const texts = await loadSourceTexts();
const allText = Object.values(texts).join('\n\n--- ---\n\n');
@@ -583,7 +656,6 @@ async function showSource(excerpt, page) {
// Chercher l'extrait dans le texte et le surligner
if (searchText.length > 10) {
let idx = allText.indexOf(searchText);
// Fallback : chercher un morceau central (résiste mieux à l'anonymisation)
if (idx < 0 && searchText.length > 60) {
const mid = Math.floor(searchText.length / 2);
searchText = searchText.substring(mid - 30, mid + 30);
@@ -600,7 +672,6 @@ async function showSource(excerpt, page) {
mark.id = 'source-highlight';
content.appendChild(mark);
content.appendChild(document.createTextNode(after));
// Scroll vers le surlignage
setTimeout(() => {
const el = document.getElementById('source-highlight');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -609,11 +680,15 @@ async function showSource(excerpt, page) {
}
}
// Fallback : afficher le texte brut sans surlignage
content.textContent = allText;
}
function closeSource() {
const content = document.getElementById('source-content');
// Détruire l'iframe pour stopper le chargement PDF
content.innerHTML = '';
content.style.display = '';
content.className = '';
document.getElementById('source-modal').style.display = 'none';
}
@@ -631,7 +706,7 @@ document.addEventListener('keydown', function(e) {
document.addEventListener('click', function(e) {
const btn = e.target.closest('.src-btn');
if (btn && btn.dataset.page) {
showSource(btn.dataset.excerpt || '', parseInt(btn.dataset.page));
showSource(btn.dataset.excerpt || '', parseInt(btn.dataset.page), btn.dataset.texte || '');
}
});

View File

@@ -407,7 +407,7 @@ class TestGenerateResponse:
]
call_count = {"n": 0}
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000):
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
return {"comprehension_contestation": "Extraction...", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
@@ -1155,7 +1155,7 @@ class TestExtractionPass:
"""L'orchestrateur appelle extraction + argumentation + validation."""
call_count = {"n": 0}
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000):
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
return {
@@ -1249,7 +1249,7 @@ class TestValidateAdversarial:
"""Incohérences détectées → avertissements dans le texte formaté."""
call_count = {"n": 0}
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000):
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
return {"comprehension_contestation": "Extraction", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}

View File

@@ -49,7 +49,30 @@ class TestOllamaCache:
cache.save()
assert not path.exists()
def test_model_change_invalidates(self, tmp_path):
def test_model_per_entry_different_model_miss(self, tmp_path):
"""Un get avec un modèle différent de celui du put retourne None."""
cache = OllamaCache(tmp_path / "cache.json", "gemma3:12b")
cache.put("HTA", "das", {"code": "I10"})
# Même cache, mais demande avec un modèle différent
assert cache.get("HTA", "das", model="llama3:8b") is None
def test_model_per_entry_same_model_hit(self, tmp_path):
"""Un get avec le même modèle retourne le résultat."""
cache = OllamaCache(tmp_path / "cache.json", "gemma3:12b")
cache.put("HTA", "das", {"code": "I10"})
assert cache.get("HTA", "das", model="gemma3:12b") == {"code": "I10"}
def test_model_per_entry_explicit_put_model(self, tmp_path):
"""put() avec model= explicite stocke ce modèle."""
cache = OllamaCache(tmp_path / "cache.json", "gemma3:12b")
cache.put("HTA", "das", {"code": "I10"}, model="llama3:8b")
# Le default model ne matche pas
assert cache.get("HTA", "das") is None
# Le modèle explicite matche
assert cache.get("HTA", "das", model="llama3:8b") == {"code": "I10"}
def test_save_reload_different_model_miss(self, tmp_path):
"""Après save/reload, les entrées gardent leur modèle."""
path = tmp_path / "cache.json"
cache = OllamaCache(path, "gemma3:12b")
cache.put("HTA", "das", {"code": "I10"})
@@ -57,7 +80,16 @@ class TestOllamaCache:
cache2 = OllamaCache(path, "llama3:8b")
assert cache2.get("HTA", "das") is None
assert len(cache2) == 0
def test_save_reload_same_model_hit(self, tmp_path):
"""Après save/reload avec le même modèle, le hit fonctionne."""
path = tmp_path / "cache.json"
cache = OllamaCache(path, "gemma3:12b")
cache.put("HTA", "das", {"code": "I10"})
cache.save()
cache2 = OllamaCache(path, "gemma3:12b")
assert cache2.get("HTA", "das") == {"code": "I10"}
def test_corrupted_file(self, tmp_path):
path = tmp_path / "cache.json"
@@ -95,14 +127,60 @@ class TestOllamaCache:
assert not errors
assert len(cache) == 20
def test_json_format(self, tmp_path):
"""Le fichier JSON contient le modèle et les entrées."""
def test_json_format_new(self, tmp_path):
"""Le nouveau format JSON contient entries avec model par entrée."""
path = tmp_path / "cache.json"
cache = OllamaCache(path, "gemma3:12b")
cache.put("HTA", "das", {"code": "I10"})
cache.save()
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["model"] == "gemma3:12b"
assert "model" not in raw # plus de modèle global
assert "entries" in raw
assert len(raw["entries"]) == 1
entry = next(iter(raw["entries"].values()))
assert entry["model"] == "gemma3:12b"
assert entry["result"] == {"code": "I10"}
def test_backward_compat_old_format_migration(self, tmp_path):
"""L'ancien format (model global, entrées sans model) est migré correctement."""
path = tmp_path / "cache.json"
# Écrire un fichier avec l'ancien format
old_data = {
"model": "gemma3:12b",
"entries": {
"das::hta": {"code": "I10"},
"dp::diabète type 2": {"code": "E11.9"},
},
}
path.write_text(json.dumps(old_data), encoding="utf-8")
# Charger avec le même modèle → doit migrer
cache = OllamaCache(path, "gemma3:12b")
assert len(cache) == 2
assert cache.get("HTA", "das") == {"code": "I10"}
assert cache.get("diabète type 2", "dp") == {"code": "E11.9"}
# Sauvegarder et vérifier le nouveau format
cache.save()
raw = json.loads(path.read_text(encoding="utf-8"))
assert "model" not in raw # plus de modèle global
entry = raw["entries"]["das::hta"]
assert entry["model"] == "gemma3:12b"
assert entry["result"] == {"code": "I10"}
def test_backward_compat_old_format_wrong_model(self, tmp_path):
"""L'ancien format migré garde le modèle d'origine, pas celui du constructeur."""
path = tmp_path / "cache.json"
old_data = {
"model": "gemma3:12b",
"entries": {
"das::hta": {"code": "I10"},
},
}
path.write_text(json.dumps(old_data), encoding="utf-8")
# Charger avec un modèle différent → entrée a le modèle d'origine
cache = OllamaCache(path, "llama3:8b")
assert cache.get("HTA", "das") is None # llama3:8b != gemma3:12b
assert cache.get("HTA", "das", model="gemma3:12b") == {"code": "I10"}

View File

@@ -1,8 +1,12 @@
"""Tests pour le viewer Flask."""
import json
import pytest
from pathlib import Path
from unittest.mock import patch
from src.viewer.app import create_app, compute_group_stats, severity_badge, format_duration, format_cpam_text
from src.viewer.pdf_redactor import load_entities_from_report, redact_pdf, highlight_text
from src.config import DossierMedical, Diagnostic, ActeCCAM
@@ -155,3 +159,141 @@ class TestSourceTextEndpoint:
"""Path traversal bloqué."""
response = client.get("/api/source-text/../../etc")
assert response.status_code in (403, 404)
class TestPdfRedactorUnit:
def test_load_entities_from_report(self, tmp_path):
"""Charge les entités depuis un rapport JSON."""
report = {
"source_file": "test.pdf",
"entities_found": [
{"original": "Jean Dupont", "replacement": "[NOM_1]", "source": "ner", "category": "person"},
{"original": "12345678901", "replacement": "[RPPS_1]", "source": "regex", "category": "rpps"},
{"original": "A", "replacement": "[X]", "source": "ner", "category": "person"}, # trop court
{"original": "[NOM_1]", "replacement": "[NOM_1]", "source": "ner", "category": "person"}, # pseudonyme
],
}
report_path = tmp_path / "test_report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
entities = load_entities_from_report(report_path)
assert "Jean Dupont" in entities
assert "12345678901" in entities
assert "A" not in entities # trop court
assert "[NOM_1]" not in entities # pseudonyme
def test_redact_pdf_produces_bytes(self, tmp_path):
"""redact_pdf retourne des bytes PDF valides."""
import fitz
# Créer un PDF de test avec du texte
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "Jean Dupont est le patient.", fontsize=12)
pdf_path = tmp_path / "test.pdf"
doc.save(str(pdf_path))
doc.close()
result = redact_pdf(pdf_path, {"Jean Dupont"})
assert isinstance(result, bytes)
assert len(result) > 0
# Vérifier que c'est bien un PDF
assert result[:5] == b"%PDF-"
# Vérifier que le texte caviardé n'est plus présent
doc2 = fitz.open(stream=result, filetype="pdf")
text = doc2[0].get_text()
doc2.close()
assert "Jean Dupont" not in text
def test_highlight_text_adds_annotation(self, tmp_path):
"""highlight_text ajoute une annotation de surlignage."""
import fitz
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "CRP elevee a 180 mg/L", fontsize=12)
pdf_bytes = doc.tobytes()
doc.close()
result = highlight_text(pdf_bytes, "CRP elevee", page_num=1)
assert isinstance(result, bytes)
# Le PDF avec surlignage doit être différent de l'original
assert result != pdf_bytes
# Vérifier qu'au moins une annotation existe sur la page
doc2 = fitz.open(stream=result, filetype="pdf")
page2 = doc2[0]
annot_count = 0
for annot in page2.annots():
annot_count += 1
doc2.close()
assert annot_count >= 1
def test_highlight_text_empty_excerpt(self, tmp_path):
"""highlight_text avec texte vide retourne le PDF inchangé."""
import fitz
doc = fitz.open()
doc.new_page()
pdf_bytes = doc.tobytes()
doc.close()
result = highlight_text(pdf_bytes, "")
assert result == pdf_bytes
def test_highlight_text_ellipsis_cleaned(self, tmp_path):
"""highlight_text nettoie les ... de l'excerpt."""
import fitz
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "Patient present une infection urinaire", fontsize=12)
pdf_bytes = doc.tobytes()
doc.close()
result = highlight_text(pdf_bytes, "...infection urinaire...", page_num=1)
doc2 = fitz.open(stream=result, filetype="pdf")
annots = list(doc2[0].annots())
doc2.close()
assert len(annots) >= 1
def test_highlight_text_multiline_excerpt(self, tmp_path):
"""highlight_text fonctionne avec un excerpt multi-lignes (cas réel)."""
import fitz
doc = fitz.open()
page = doc.new_page()
# Simuler un PDF avec plusieurs lignes de texte
page.insert_text((72, 72), "Motif d'hospitalisation: Lombofessalgie", fontsize=12)
page.insert_text((72, 92), "chez patiente suivie pour spondylarthrite", fontsize=12)
page.insert_text((72, 112), "Praticien hospitalier", fontsize=12)
page.insert_text((72, 132), "Antecedents medicaux importants", fontsize=12)
pdf_bytes = doc.tobytes()
doc.close()
# Excerpt multi-lignes typique (comme dans les vrais dossiers)
multiline_excerpt = (
"...Motif d'hospitalisation: Lombofessalgie\n"
"chez patiente suivie pour spondylarthrite\n"
"Praticien hospitalier\n"
"Antecedents medicaux importants..."
)
result = highlight_text(pdf_bytes, multiline_excerpt, page_num=1)
assert result != pdf_bytes
doc2 = fitz.open(stream=result, filetype="pdf")
annot_count = 0
for annot in doc2[0].annots():
annot_count += 1
doc2.close()
assert annot_count >= 1
class TestPdfEndpoint:
def test_pdf_404_nonexistent(self, client):
"""Un PDF inexistant retourne 404."""
response = client.get("/api/pdf/nonexistent_dossier/nonexistent.pdf")
assert response.status_code == 404
def test_pdf_security_path_traversal(self, client):
"""Path traversal bloqué."""
response = client.get("/api/pdf/../../etc/passwd.pdf")
assert response.status_code in (403, 404)
def test_pdf_non_pdf_extension(self, client):
"""Un fichier non-PDF retourne 404."""
response = client.get("/api/pdf/some_dossier/file.txt")
assert response.status_code == 404