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
|
python-dotenv>=1.0.0
|
||||||
openpyxl>=3.0.0
|
openpyxl>=3.0.0
|
||||||
pandas>=2.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_CACHE_PATH = BASE_DIR / "data" / "ollama_cache.json"
|
||||||
OLLAMA_MAX_PARALLEL = int(os.environ.get("OLLAMA_MAX_PARALLEL", "2"))
|
OLLAMA_MAX_PARALLEL = int(os.environ.get("OLLAMA_MAX_PARALLEL", "2"))
|
||||||
|
|
||||||
|
# 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 ---
|
# --- 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_dict import normalize_code, validate_code
|
||||||
from ..medical.cim10_extractor import BIO_NORMALS
|
from ..medical.cim10_extractor import BIO_NORMALS
|
||||||
from ..medical.ollama_client import call_anthropic, call_ollama
|
from ..medical.ollama_client import call_anthropic, call_ollama
|
||||||
|
from ..prompts import CPAM_EXTRACTION, CPAM_ARGUMENTATION, CPAM_ADVERSARIAL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -602,88 +603,18 @@ def _build_cpam_prompt(
|
|||||||
+ "\n".join(ext_lines)
|
+ "\n".join(ext_lines)
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = f"""Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A.
|
prompt = CPAM_ARGUMENTATION.format(
|
||||||
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.
|
dossier_str=dossier_str,
|
||||||
|
asymetrie_str=asymetrie_str,
|
||||||
IMPORTANT — CRÉDIBILITÉ DE L'ANALYSE :
|
tagged_str=tagged_str,
|
||||||
Une contre-argumentation crédible reconnaît TOUJOURS au moins un point valide dans le raisonnement adverse.
|
titre=controle.titre,
|
||||||
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.
|
arg_ucr=controle.arg_ucr,
|
||||||
|
decision_ucr=controle.decision_ucr,
|
||||||
IMPORTANT — CODES CIM-10 :
|
codes_str=codes_str,
|
||||||
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).
|
definitions_str=definitions_str,
|
||||||
Chaque argument doit désigner précisément quel code est défendu ou contesté, avec son libellé complet.
|
sources_text=sources_text,
|
||||||
|
extraction_str=extraction_str,
|
||||||
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é"
|
|
||||||
}}"""
|
|
||||||
return prompt, tag_map
|
return prompt, tag_map
|
||||||
|
|
||||||
|
|
||||||
@@ -845,35 +776,19 @@ def _validate_adversarial(
|
|||||||
normes_lines.append(f" {test}: {lo}-{hi}")
|
normes_lines.append(f" {test}: {lo}-{hi}")
|
||||||
normes_section = "NORMES BIOLOGIQUES DE RÉFÉRENCE :\n" + "\n".join(normes_lines)
|
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 :
|
prompt = CPAM_ADVERSARIAL.format(
|
||||||
{response_json}
|
response_json=response_json,
|
||||||
|
factual_section=factual_section,
|
||||||
{factual_section}
|
normes_section=normes_section,
|
||||||
|
dp_ucr_line=dp_ucr_line,
|
||||||
{normes_section}
|
da_ucr_line=da_ucr_line,
|
||||||
|
)
|
||||||
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
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
logger.debug(" Validation adversariale")
|
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:
|
if result is None:
|
||||||
result = call_anthropic(prompt, temperature=0.0, max_tokens=800)
|
result = call_anthropic(prompt, temperature=0.0, max_tokens=800)
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -924,36 +839,22 @@ def _extraction_pass(
|
|||||||
# Contexte tagué (réutilise la même fonction)
|
# Contexte tagué (réutilise la même fonction)
|
||||||
tagged_text, _ = _build_tagged_context(dossier)
|
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 :
|
prompt = CPAM_EXTRACTION.format(
|
||||||
- DP : {dp_str or "Non extrait"}
|
dp_str=dp_str or "Non extrait",
|
||||||
- DAS : {das_str or "Aucun"}
|
das_str=das_str or "Aucun",
|
||||||
{tagged_text}
|
tagged_text=tagged_text,
|
||||||
|
titre=controle.titre,
|
||||||
CONTESTATION CPAM :
|
arg_ucr=controle.arg_ucr,
|
||||||
Titre : {controle.titre}
|
decision_ucr=controle.decision_ucr,
|
||||||
Argument : {controle.arg_ucr}
|
dp_ucr_line=dp_ucr_line,
|
||||||
Décision : {controle.decision_ucr}
|
da_ucr_line=da_ucr_line,
|
||||||
{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"
|
|
||||||
}}
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
logger.debug(" Passe 1 — extraction structurée")
|
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:
|
if result is None:
|
||||||
result = call_anthropic(prompt, temperature=0.0, max_tokens=1500)
|
result = call_anthropic(prompt, temperature=0.0, max_tokens=1500)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -990,8 +891,8 @@ def generate_cpam_response(
|
|||||||
# 3. Construction du prompt (passe 2 — argumentation)
|
# 3. Construction du prompt (passe 2 — argumentation)
|
||||||
prompt, tag_map = _build_cpam_prompt(dossier, controle, sources, extraction)
|
prompt, tag_map = _build_cpam_prompt(dossier, controle, sources, extraction)
|
||||||
|
|
||||||
# 4. Appel LLM — Ollama (modèle par défaut) > Haiku fallback
|
# 4. Appel LLM — Ollama (modèle CPAM dédié) > Haiku fallback
|
||||||
result = call_ollama(prompt, temperature=0.1, max_tokens=4000)
|
result = call_ollama(prompt, temperature=0.1, max_tokens=4000, role="cpam")
|
||||||
if result is not None:
|
if result is not None:
|
||||||
logger.info(" Contre-argumentation via Ollama")
|
logger.info(" Contre-argumentation via Ollama")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -168,13 +168,13 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
|
|||||||
try:
|
try:
|
||||||
from .rag_search import extract_das_llm
|
from .rag_search import extract_das_llm
|
||||||
from .ollama_cache import OllamaCache
|
from .ollama_cache import OllamaCache
|
||||||
from ..config import OLLAMA_CACHE_PATH, OLLAMA_MODEL
|
from ..config import OLLAMA_CACHE_PATH, get_model
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("Module RAG non disponible pour l'extraction DAS LLM")
|
logger.warning("Module RAG non disponible pour l'extraction DAS LLM")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL)
|
cache = OllamaCache(OLLAMA_CACHE_PATH, get_model("coding"))
|
||||||
|
|
||||||
# Construire le contexte
|
# Construire le contexte
|
||||||
contexte = {
|
contexte = {
|
||||||
@@ -1126,6 +1126,7 @@ def _validate_justifications(dossier: DossierMedical) -> None:
|
|||||||
try:
|
try:
|
||||||
from .ollama_client import call_ollama
|
from .ollama_client import call_ollama
|
||||||
from .clinical_context import build_enriched_context, format_enriched_context
|
from .clinical_context import build_enriched_context, format_enriched_context
|
||||||
|
from ..prompts import QC_VALIDATION
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("Module clinical_context non disponible pour la validation QC")
|
logger.warning("Module clinical_context non disponible pour la validation QC")
|
||||||
return
|
return
|
||||||
@@ -1152,36 +1153,10 @@ def _validate_justifications(dossier: DossierMedical) -> None:
|
|||||||
ctx = build_enriched_context(dossier)
|
ctx = build_enriched_context(dossier)
|
||||||
ctx_str = format_enriched_context(ctx)
|
ctx_str = format_enriched_context(ctx)
|
||||||
|
|
||||||
prompt = f"""Tu es un médecin DIM contrôleur qualité PMSI.
|
prompt = QC_VALIDATION.format(ctx_str=ctx_str, codes_section=codes_section)
|
||||||
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": ["..."]
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
try:
|
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:
|
except Exception:
|
||||||
logger.warning("Erreur lors de l'appel Ollama pour validation QC", exc_info=True)
|
logger.warning("Erreur lors de l'appel Ollama pour validation QC", exc_info=True)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,32 +14,58 @@ class OllamaCache:
|
|||||||
"""Cache JSON persistant pour éviter les appels Ollama redondants.
|
"""Cache JSON persistant pour éviter les appels Ollama redondants.
|
||||||
|
|
||||||
Clé = (texte_diagnostic_normalisé, type).
|
Clé = (texte_diagnostic_normalisé, type).
|
||||||
Le modèle Ollama est stocké dans les métadonnées : si le modèle change,
|
Le modèle Ollama est stocké PAR ENTRÉE : un cache hit ne se produit
|
||||||
le cache est automatiquement invalidé.
|
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._path = cache_path
|
||||||
self._model = model
|
self._default_model = model
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._data: dict[str, dict] = {}
|
self._data: dict[str, dict] = {}
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _load(self) -> None:
|
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():
|
if not self._path.exists():
|
||||||
logger.info("Cache Ollama : nouveau cache (%s)", self._path)
|
logger.info("Cache Ollama : nouveau cache (%s)", self._path)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||||
if raw.get("model") != self._model:
|
|
||||||
logger.info(
|
# Détection ancien format : clé "model" globale + "entries" sans "model" par entrée
|
||||||
"Cache Ollama : modèle changé (%s → %s), cache invalidé",
|
global_model = raw.get("model")
|
||||||
raw.get("model"), self._model,
|
entries = raw.get("entries", {})
|
||||||
)
|
|
||||||
|
if not entries:
|
||||||
return
|
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))
|
logger.info("Cache Ollama : %d entrées chargées", len(self._data))
|
||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
logger.warning("Cache Ollama : fichier corrompu (%s), réinitialisé", e)
|
logger.warning("Cache Ollama : fichier corrompu (%s), réinitialisé", e)
|
||||||
@@ -50,17 +76,24 @@ class OllamaCache:
|
|||||||
"""Construit une clé normalisée."""
|
"""Construit une clé normalisée."""
|
||||||
return f"{diag_type}::{texte.strip().lower()}"
|
return f"{diag_type}::{texte.strip().lower()}"
|
||||||
|
|
||||||
def get(self, texte: str, diag_type: str) -> dict | None:
|
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."""
|
"""Récupère un résultat caché, ou None si absent ou modèle différent."""
|
||||||
key = self._make_key(texte, diag_type)
|
key = self._make_key(texte, diag_type)
|
||||||
|
use_model = model or self._default_model
|
||||||
with self._lock:
|
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:
|
def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None:
|
||||||
"""Stocke un résultat dans le cache."""
|
"""Stocke un résultat dans le cache avec le modèle associé."""
|
||||||
key = self._make_key(texte, diag_type)
|
key = self._make_key(texte, diag_type)
|
||||||
|
use_model = model or self._default_model or "unknown"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._data[key] = result
|
self._data[key] = {"model": use_model, "result": result}
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
@@ -69,10 +102,7 @@ class OllamaCache:
|
|||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
return
|
return
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
payload = {
|
payload = {"entries": self._data}
|
||||||
"model": self._model,
|
|
||||||
"entries": self._data,
|
|
||||||
}
|
|
||||||
self._path.write_text(
|
self._path.write_text(
|
||||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
|
|
||||||
import requests
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -83,6 +83,7 @@ def call_ollama(
|
|||||||
temperature: float = 0.1,
|
temperature: float = 0.1,
|
||||||
max_tokens: int = 2500,
|
max_tokens: int = 2500,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Appelle Ollama en mode JSON natif, avec fallback Anthropic si indisponible.
|
"""Appelle Ollama en mode JSON natif, avec fallback Anthropic si indisponible.
|
||||||
@@ -91,13 +92,14 @@ def call_ollama(
|
|||||||
prompt: Le prompt à envoyer.
|
prompt: Le prompt à envoyer.
|
||||||
temperature: Température de génération (défaut: 0.1).
|
temperature: Température de génération (défaut: 0.1).
|
||||||
max_tokens: Nombre max de tokens (défaut: 2500).
|
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).
|
timeout: Timeout en secondes (défaut: OLLAMA_TIMEOUT global).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Le dict JSON parsé, ou None en cas d'erreur.
|
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
|
use_timeout = timeout or OLLAMA_TIMEOUT
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|||||||
|
|
||||||
from ..config import (
|
from ..config import (
|
||||||
ActeCCAM, Diagnostic, DossierMedical, PreuveClinique, RAGSource,
|
ActeCCAM, Diagnostic, DossierMedical, PreuveClinique, RAGSource,
|
||||||
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL,
|
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL,
|
||||||
EMBEDDING_MODEL, RERANKER_MODEL,
|
EMBEDDING_MODEL, RERANKER_MODEL, get_model,
|
||||||
)
|
)
|
||||||
from .cim10_dict import normalize_code, validate_code as cim10_validate, fallback_parent_code
|
from .cim10_dict import normalize_code, validate_code as cim10_validate, fallback_parent_code
|
||||||
from .cim10_extractor import BIO_NORMALS
|
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 .ccam_dict import validate_code as ccam_validate
|
||||||
from .ollama_client import call_ollama, parse_json_response
|
from .ollama_client import call_ollama, parse_json_response
|
||||||
from .ollama_cache import OllamaCache
|
from .ollama_cache import OllamaCache
|
||||||
|
from ..prompts import CODING_CIM10, CODING_CCAM, DAS_EXTRACTION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -357,8 +358,8 @@ def _format_contexte(contexte: dict) -> str:
|
|||||||
return "\n".join(lines) if lines else "Non précisé"
|
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:
|
def _format_sources(sources: list[dict]) -> str:
|
||||||
"""Construit le prompt expert DIM avec raisonnement structuré."""
|
"""Formate les sources RAG pour injection dans un prompt."""
|
||||||
sources_text = ""
|
sources_text = ""
|
||||||
for i, src in enumerate(sources, 1):
|
for i, src in enumerate(sources, 1):
|
||||||
doc_name = {
|
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 += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n"
|
||||||
sources_text += (src.get("extrait", "")[:800]) + "\n\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)"
|
type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)"
|
||||||
ctx_str = format_enriched_context(contexte)
|
return CODING_CIM10.format(
|
||||||
|
texte=texte,
|
||||||
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI.
|
type_diag=type_diag,
|
||||||
Tu dois coder le diagnostic suivant en respectant STRICTEMENT les règles de l'ATIH.
|
ctx_str=format_enriched_context(contexte),
|
||||||
|
sources_text=_format_sources(sources),
|
||||||
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"}}
|
|
||||||
]
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt_ccam(texte: str, sources: list[dict], contexte: dict) -> str:
|
def _build_prompt_ccam(texte: str, sources: list[dict], contexte: dict) -> str:
|
||||||
"""Construit le prompt expert DIM pour le codage CCAM avec raisonnement structuré."""
|
"""Construit le prompt expert DIM pour le codage CCAM avec raisonnement structuré."""
|
||||||
sources_text = ""
|
return CODING_CCAM.format(
|
||||||
for i, src in enumerate(sources, 1):
|
texte=texte,
|
||||||
doc_name = {
|
ctx_str=format_enriched_context(contexte),
|
||||||
"cim10": "CIM-10 FR 2026",
|
sources_text=_format_sources(sources),
|
||||||
"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"
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ollama_response(raw: str) -> dict | None:
|
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:
|
def _call_ollama(prompt: str) -> dict | None:
|
||||||
"""Appelle Ollama (mode JSON) et parse la réponse avec reconstitution du raisonnement."""
|
"""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:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
# Reconstituer le raisonnement structuré
|
# 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:
|
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."""
|
"""Construit le prompt pour l'extraction LLM de DAS supplémentaires."""
|
||||||
ctx_str = format_enriched_context(contexte)
|
return DAS_EXTRACTION.format(
|
||||||
existing_str = "\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun"
|
dp_texte=dp_texte or "Non identifié",
|
||||||
|
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.
|
ctx_str=format_enriched_context(contexte),
|
||||||
Analyse le texte médical suivant et identifie les diagnostics associés significatifs (DAS) qui n'ont PAS encore été codés.
|
text_medical=text[:4000],
|
||||||
|
)
|
||||||
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": []}}"""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_das_llm(
|
def extract_das_llm(
|
||||||
@@ -741,7 +645,7 @@ def extract_das_llm(
|
|||||||
|
|
||||||
# Construire le prompt et appeler Ollama
|
# Construire le prompt et appeler Ollama
|
||||||
prompt = _build_prompt_das_extraction(text, contexte, existing_das, dp_texte)
|
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:
|
if result is None:
|
||||||
logger.warning("Extraction DAS LLM : Ollama non disponible")
|
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
|
Utilise un cache persistant et parallélise les appels Ollama
|
||||||
pour les DAS et actes CCAM (max_workers = OLLAMA_MAX_PARALLEL).
|
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)
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
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 markupsafe import Markup
|
||||||
|
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@@ -16,7 +16,8 @@ from werkzeug.utils import secure_filename
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from ..config import (
|
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,
|
ALLOWED_EXTENSIONS, UPLOAD_MAX_SIZE_MB,
|
||||||
CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH,
|
CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH,
|
||||||
)
|
)
|
||||||
@@ -463,16 +464,29 @@ def create_app() -> Flask:
|
|||||||
@app.route("/admin/models", methods=["GET"])
|
@app.route("/admin/models", methods=["GET"])
|
||||||
def list_models():
|
def list_models():
|
||||||
models = fetch_ollama_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"])
|
@app.route("/admin/models", methods=["POST"])
|
||||||
def set_model():
|
def set_model():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
role = data.get("role", "").strip()
|
||||||
new_model = data.get("model", "").strip()
|
new_model = data.get("model", "").strip()
|
||||||
if not new_model:
|
if not new_model:
|
||||||
return jsonify({"error": "Champ 'model' requis"}), 400
|
return jsonify({"error": "Champ 'model' requis"}), 400
|
||||||
|
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
|
cfg.OLLAMA_MODEL = new_model
|
||||||
logger.info("Modèle Ollama changé : %s", new_model)
|
logger.info("Modèle Ollama global changé : %s", new_model)
|
||||||
return jsonify({"ok": True, "model": cfg.OLLAMA_MODEL})
|
return jsonify({"ok": True, "model": cfg.OLLAMA_MODEL})
|
||||||
|
|
||||||
@app.route("/reprocess/<path:filepath>", methods=["POST"])
|
@app.route("/reprocess/<path:filepath>", methods=["POST"])
|
||||||
@@ -615,6 +629,44 @@ def create_app() -> Flask:
|
|||||||
logger.warning("Impossible de lire %s", txt_path)
|
logger.warning("Impossible de lire %s", txt_path)
|
||||||
return jsonify(result)
|
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
|
# 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 {
|
#source-modal-inner {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
max-width: 900px;
|
max-width: 95vw;
|
||||||
|
width: 95vw;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-height: 90vh;
|
max-height: 95vh;
|
||||||
|
height: 95vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
|
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 {
|
#source-header {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
@@ -290,6 +298,11 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
#source-content.source-content-pdf {
|
||||||
|
padding: 0;
|
||||||
|
white-space: normal;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
#source-content mark {
|
#source-content mark {
|
||||||
background: #fef08a;
|
background: #fef08a;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
@@ -306,6 +319,22 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
#source-close-btn:hover { background: #475569; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -289,7 +289,7 @@
|
|||||||
<h3>Diagnostic principal</h3>
|
<h3>Diagnostic principal</h3>
|
||||||
<div style="font-size:0.95rem;margin-bottom:0.5rem;">
|
<div style="font-size:0.95rem;margin-bottom:0.5rem;">
|
||||||
{{ dp.texte }}
|
{{ 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>
|
</div>
|
||||||
{% if dp.cim10_suggestion %}
|
{% if dp.cim10_suggestion %}
|
||||||
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
|
<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>
|
<span class="badge" style="background:#e0e7ff;color:#3730a3;font-size:0.7rem;">{{ das.source }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if das.source_page %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:0.8rem;color:#475569;">
|
<td style="font-size:0.8rem;color:#475569;">
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
<div style="font-size:0.7rem;color:#dc2626;">{{ alerte }}</div>
|
<div style="font-size:0.7rem;color:#dc2626;">{{ alerte }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
<td>{{ b.test }}</td>
|
<td>{{ b.test }}</td>
|
||||||
<td>{{ b.valeur or '' }}</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.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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -466,7 +466,7 @@
|
|||||||
<div style="margin-bottom:0.5rem;">
|
<div style="margin-bottom:0.5rem;">
|
||||||
<strong>{{ img.type }}</strong>
|
<strong>{{ img.type }}</strong>
|
||||||
{% if img.score %} — Score : {{ img.score }}{% endif %}
|
{% 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 %}
|
{% if img.conclusion %}
|
||||||
<div style="font-size:0.85rem;color:#475569;">{{ img.conclusion }}</div>
|
<div style="font-size:0.85rem;color:#475569;">{{ img.conclusion }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
<td>{{ t.medicament }}</td>
|
<td>{{ t.medicament }}</td>
|
||||||
<td>{{ t.posologie or '' }}</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.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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -501,7 +501,7 @@
|
|||||||
<h3>Antécédents ({{ dossier.antecedents|length }})</h3>
|
<h3>Antécédents ({{ dossier.antecedents|length }})</h3>
|
||||||
<ul class="bullet">
|
<ul class="bullet">
|
||||||
{% for a in dossier.antecedents %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
<h3>Complications ({{ dossier.complications|length }})</h3>
|
<h3>Complications ({{ dossier.complications|length }})</h3>
|
||||||
<ul class="bullet">
|
<ul class="bullet">
|
||||||
{% for c in dossier.complications %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,36 +536,109 @@
|
|||||||
<script>
|
<script>
|
||||||
/* --- Source modal --- */
|
/* --- Source modal --- */
|
||||||
let _sourceCache = null;
|
let _sourceCache = null;
|
||||||
|
const _dossierId = (function() {
|
||||||
function getDossierId() {
|
|
||||||
// filepath = "103_23056749/103_23056749_fusionne_cim10.json"
|
|
||||||
// dossier_id = "103_23056749"
|
|
||||||
const fp = {{ filepath|tojson }};
|
const fp = {{ filepath|tojson }};
|
||||||
const parts = fp.split('/');
|
const parts = fp.split('/');
|
||||||
return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
|
return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
|
||||||
}
|
})();
|
||||||
|
const _sourceFiles = {{ dossier.source_files|tojson }};
|
||||||
|
|
||||||
|
function getDossierId() { return _dossierId; }
|
||||||
|
|
||||||
async function loadSourceTexts() {
|
async function loadSourceTexts() {
|
||||||
if (_sourceCache !== null) return _sourceCache;
|
if (_sourceCache !== null) return _sourceCache;
|
||||||
const dossierId = getDossierId();
|
if (!_dossierId) { _sourceCache = {}; return _sourceCache; }
|
||||||
if (!dossierId) { _sourceCache = {}; return _sourceCache; }
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/source-text/' + dossierId);
|
const resp = await fetch('/api/source-text/' + _dossierId);
|
||||||
if (resp.ok) { _sourceCache = await resp.json(); }
|
if (resp.ok) { _sourceCache = await resp.json(); }
|
||||||
else { _sourceCache = {}; }
|
else { _sourceCache = {}; }
|
||||||
} catch (e) { _sourceCache = {}; }
|
} catch (e) { _sourceCache = {}; }
|
||||||
return _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 modal = document.getElementById('source-modal');
|
||||||
|
const modalInner = document.getElementById('source-modal-inner');
|
||||||
const content = document.getElementById('source-content');
|
const content = document.getElementById('source-content');
|
||||||
const title = document.getElementById('source-title');
|
const title = document.getElementById('source-title');
|
||||||
|
|
||||||
title.textContent = 'Document source — Page ' + page;
|
title.textContent = 'Document source — Page ' + page;
|
||||||
content.innerHTML = '<em style="color:#94a3b8;">Chargement...</em>';
|
content.innerHTML = '<em style="color:#94a3b8;">Chargement...</em>';
|
||||||
|
content.className = '';
|
||||||
|
modalInner.className = '';
|
||||||
modal.style.display = 'block';
|
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 texts = await loadSourceTexts();
|
||||||
const allText = Object.values(texts).join('\n\n--- ---\n\n');
|
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
|
// Chercher l'extrait dans le texte et le surligner
|
||||||
if (searchText.length > 10) {
|
if (searchText.length > 10) {
|
||||||
let idx = allText.indexOf(searchText);
|
let idx = allText.indexOf(searchText);
|
||||||
// Fallback : chercher un morceau central (résiste mieux à l'anonymisation)
|
|
||||||
if (idx < 0 && searchText.length > 60) {
|
if (idx < 0 && searchText.length > 60) {
|
||||||
const mid = Math.floor(searchText.length / 2);
|
const mid = Math.floor(searchText.length / 2);
|
||||||
searchText = searchText.substring(mid - 30, mid + 30);
|
searchText = searchText.substring(mid - 30, mid + 30);
|
||||||
@@ -600,7 +672,6 @@ async function showSource(excerpt, page) {
|
|||||||
mark.id = 'source-highlight';
|
mark.id = 'source-highlight';
|
||||||
content.appendChild(mark);
|
content.appendChild(mark);
|
||||||
content.appendChild(document.createTextNode(after));
|
content.appendChild(document.createTextNode(after));
|
||||||
// Scroll vers le surlignage
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.getElementById('source-highlight');
|
const el = document.getElementById('source-highlight');
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
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;
|
content.textContent = allText;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSource() {
|
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';
|
document.getElementById('source-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,7 +706,7 @@ document.addEventListener('keydown', function(e) {
|
|||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
const btn = e.target.closest('.src-btn');
|
const btn = e.target.closest('.src-btn');
|
||||||
if (btn && btn.dataset.page) {
|
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}
|
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
|
call_count["n"] += 1
|
||||||
if call_count["n"] == 1:
|
if call_count["n"] == 1:
|
||||||
return {"comprehension_contestation": "Extraction...", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
|
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."""
|
"""L'orchestrateur appelle extraction + argumentation + validation."""
|
||||||
call_count = {"n": 0}
|
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
|
call_count["n"] += 1
|
||||||
if call_count["n"] == 1:
|
if call_count["n"] == 1:
|
||||||
return {
|
return {
|
||||||
@@ -1249,7 +1249,7 @@ class TestValidateAdversarial:
|
|||||||
"""Incohérences détectées → avertissements dans le texte formaté."""
|
"""Incohérences détectées → avertissements dans le texte formaté."""
|
||||||
call_count = {"n": 0}
|
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
|
call_count["n"] += 1
|
||||||
if call_count["n"] == 1:
|
if call_count["n"] == 1:
|
||||||
return {"comprehension_contestation": "Extraction", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
|
return {"comprehension_contestation": "Extraction", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
|
||||||
|
|||||||
@@ -49,7 +49,30 @@ class TestOllamaCache:
|
|||||||
cache.save()
|
cache.save()
|
||||||
assert not path.exists()
|
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"
|
path = tmp_path / "cache.json"
|
||||||
cache = OllamaCache(path, "gemma3:12b")
|
cache = OllamaCache(path, "gemma3:12b")
|
||||||
cache.put("HTA", "das", {"code": "I10"})
|
cache.put("HTA", "das", {"code": "I10"})
|
||||||
@@ -57,7 +80,16 @@ class TestOllamaCache:
|
|||||||
|
|
||||||
cache2 = OllamaCache(path, "llama3:8b")
|
cache2 = OllamaCache(path, "llama3:8b")
|
||||||
assert cache2.get("HTA", "das") is None
|
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):
|
def test_corrupted_file(self, tmp_path):
|
||||||
path = tmp_path / "cache.json"
|
path = tmp_path / "cache.json"
|
||||||
@@ -95,14 +127,60 @@ class TestOllamaCache:
|
|||||||
assert not errors
|
assert not errors
|
||||||
assert len(cache) == 20
|
assert len(cache) == 20
|
||||||
|
|
||||||
def test_json_format(self, tmp_path):
|
def test_json_format_new(self, tmp_path):
|
||||||
"""Le fichier JSON contient le modèle et les entrées."""
|
"""Le nouveau format JSON contient entries avec model par entrée."""
|
||||||
path = tmp_path / "cache.json"
|
path = tmp_path / "cache.json"
|
||||||
cache = OllamaCache(path, "gemma3:12b")
|
cache = OllamaCache(path, "gemma3:12b")
|
||||||
cache.put("HTA", "das", {"code": "I10"})
|
cache.put("HTA", "das", {"code": "I10"})
|
||||||
cache.save()
|
cache.save()
|
||||||
|
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
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 "entries" in raw
|
||||||
assert len(raw["entries"]) == 1
|
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."""
|
"""Tests pour le viewer Flask."""
|
||||||
|
|
||||||
|
import json
|
||||||
import pytest
|
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.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
|
from src.config import DossierMedical, Diagnostic, ActeCCAM
|
||||||
|
|
||||||
|
|
||||||
@@ -155,3 +159,141 @@ class TestSourceTextEndpoint:
|
|||||||
"""Path traversal bloqué."""
|
"""Path traversal bloqué."""
|
||||||
response = client.get("/api/source-text/../../etc")
|
response = client.get("/api/source-text/../../etc")
|
||||||
assert response.status_code in (403, 404)
|
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