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:
155
docs/prompts.md
Normal file
155
docs/prompts.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
21
src/prompts/__init__.py
Normal 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
336
src/prompts/templates.py
Normal 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
|
||||
}}"""
|
||||
@@ -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
154
src/viewer/pdf_redactor.py
Normal 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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {}}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user