feat: architecture multi-modèles LLM + quality engine + benchmark

- Multi-modèles : 4 rôles LLM (coding=gemma3:27b-cloud, cpam=gemma3:27b-cloud,
  validation=deepseek-v3.2:cloud, qc=gemma3:12b) avec get_model(role)
- Prompts externalisés : 7 templates dans src/prompts/templates.py
- Cache Ollama : modèle stocké par entrée (migration auto ancien format)
- call_ollama() : paramètre role= (priorité: model > role > global)
- Quality engine : veto_engine + decision_engine + rules_router (YAML)
- Benchmark qualité : scripts/benchmark_quality.py (A/B, métriques CIM-10)
- Fix biologie : valeurs qualitatives (troponine négative) non filtrées
- Fix CPAM : gemma3:27b-cloud au lieu de deepseek (JSON tronqué par thinking)
- CPAM max_tokens 4000→6000, viewer admin multi-modèles
- Benchmark 10 dossiers : 100% DAS valides, 10/10 CPAM, 243s/dossier

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-20 00:21:09 +01:00
parent 5c8c2817ec
commit 909e051cc9
39 changed files with 5092 additions and 574 deletions

View File

@@ -39,7 +39,7 @@ def normalize_text(text: str) -> str:
def build_dict() -> dict[str, str]:
"""Construit le dictionnaire CIM-10 depuis metadata.json et l'écrit dans data/cim10_dict.json.
"""Construit le dictionnaire CIM-10 depuis les métadonnées RAG.
Extrait le code et le label (première ligne de l'extrait, sans le préfixe code)
depuis chaque entrée CIM-10 du metadata.json existant.
@@ -47,10 +47,15 @@ def build_dict() -> dict[str, str]:
Returns:
Le dictionnaire code → label.
"""
metadata_path = RAG_INDEX_DIR / "metadata.json"
# Nouveau format : metadata_ref.json (fallback legacy : metadata.json)
metadata_path = RAG_INDEX_DIR / "metadata_ref.json"
if not metadata_path.exists():
logger.error("metadata.json non trouvé : %s", metadata_path)
return {}
legacy = RAG_INDEX_DIR / "metadata.json"
if legacy.exists():
metadata_path = legacy
else:
logger.error("Métadonnées RAG non trouvées : %s", metadata_path)
return {}
with open(metadata_path, encoding="utf-8") as f:
metadata = json.load(f)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import re
import unicodedata
from datetime import datetime
from typing import Optional
@@ -19,6 +20,7 @@ from ..config import (
Complication,
Diagnostic,
DossierMedical,
load_lab_value_sanity,
Imagerie,
Sejour,
Traitement,
@@ -168,13 +170,13 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
try:
from .rag_search import extract_das_llm
from .ollama_cache import OllamaCache
from ..config import OLLAMA_CACHE_PATH, OLLAMA_MODEL
from ..config import OLLAMA_CACHE_PATH, get_model
except ImportError:
logger.warning("Module RAG non disponible pour l'extraction DAS LLM")
return
try:
cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL)
cache = OllamaCache(OLLAMA_CACHE_PATH, get_model("coding"))
# Construire le contexte
contexte = {
@@ -684,37 +686,181 @@ def _match_drug_atc(med_name: str, drug_atc: dict[str, str]) -> Optional[str]:
return None
def _extract_biologie(text: str, dossier: DossierMedical) -> None:
"""Extrait les résultats biologiques clés.
Supporte les aliases (TGO/TGP, Hb), variantes d'unités (UI/L, µmol/L, g/dL),
et des tests additionnels (hémoglobine, plaquettes, leucocytes, créatinine).
def _norm_key(s: str) -> str:
"""Normalise une clé (minuscules, sans accents) pour index YAML."""
s = (s or "").strip().lower()
s = unicodedata.normalize("NFKD", s)
s = "".join(ch for ch in s if not unicodedata.combining(ch))
return re.sub(r"\s+", " ", s)
def _parse_float_and_token(raw: str) -> tuple[float | None, str | None]:
"""Parse un float et renvoie aussi le token numérique normalisé (avec '.')."""
if raw is None:
return None, None
s = str(raw).strip()
m = re.search(r"(-?\d+(?:[\.,]\d+)?)", s)
if not m:
return None, None
token = m.group(1).replace(",", ".")
try:
return float(token), token
except ValueError:
return None, None
def _sanitize_bio_value(test_name: str, raw_value: str, sanity_cfg: dict) -> tuple[str, float, str, str | None] | None:
"""Applique des garde-fous anti-artefacts (OCR/PDF).
Retour:
(token, value_float, quality, reason) ou None si non parsable.
quality: ok | suspect | discarded
"""
bio_patterns = [
(r"[Ll]ipas[ée]mie\s*(?:[àa=:])?\s*(\d+)\s*(?:UI/L|U/L)?", "Lipasémie", None),
(r"CRP\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll])?", "CRP", None),
(r"(?:ASAT|TGO)\s*[=:àa]?\s*([\d.,]+)\s*(?:N|U(?:I)?/L)?", "ASAT", None),
(r"(?:ALAT|TGP)\s*[=:àa]?\s*([\d.,]+)\s*(?:N|U(?:I)?/L)?", "ALAT", None),
(r"GGT\s*[=:àa]?\s*(\d+)\s*(?:U(?:I)?/L)?", "GGT", None),
(r"PAL\s*[=:àa]?\s*(\d+)\s*(?:U(?:I)?/L)?", "PAL", None),
(r"[Bb]ilirubine\s+(?:totale\s+)?[àa=:]\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Bilirubine totale", None),
(r"[Tt]roponine\s+(?:us\s+)?(n[ée]gative|positive|normale)", "Troponine", None),
(r"(?:[Hh][ée]moglobine|Hb)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/dL|g/L)?", "Hémoglobine", None),
(r"[Pp]laquettes?\s*[=:àa]?\s*(\d+(?:\s*000)?)\s*(?:/mm3|G/L)?", "Plaquettes", None),
(r"[Ll]eucocytes?\s*[=:àa]?\s*(\d+(?:\s*000)?)\s*(?:/mm3|G/L)?", "Leucocytes", None),
(r"[Cc]r[ée]atinine?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Créatinine", None),
val, token = _parse_float_and_token(raw_value)
if val is None or token is None:
return None
key = _norm_key(test_name)
tests_cfg = (sanity_cfg or {}).get("tests") or {}
cfg = tests_cfg.get(key) or {}
hard_min = cfg.get("hard_min")
hard_max = cfg.get("hard_max")
if hard_min is not None and val < float(hard_min):
return token, val, "discarded", f"Valeur hors bornes plausibles (<{hard_min})"
if hard_max is not None and val > float(hard_max):
return token, val, "discarded", f"Valeur hors bornes plausibles (>{hard_max})"
quality = "ok"
reason: str | None = None
suspect_cfg = cfg.get("suspect") or {}
single_digit_over = suspect_cfg.get("single_digit_over")
if single_digit_over is not None:
# Ex: potassium '8' au lieu de '4.8' (décimale perdue)
if re.fullmatch(r"\d", str(raw_value).strip()) and val >= float(single_digit_over):
quality = "suspect"
reason = f"Valeur à 1 chiffre (possible décimale perdue) : vérifier dans le CR"
return token, val, quality, reason
def _extract_biologie(text: str, dossier: DossierMedical) -> None:
"""Extrait des résultats biologiques clés.
Notes:
- Supporte des aliases (TGO/TGP, Hb, Na/K…)
- Capte plusieurs occurrences (utile pour valider/infirmer des diagnostics)
- Reste volontairement *simple* (regex sur texte extrait) : si une valeur est
uniquement dans un tableau PDF mal extrait, elle peut manquer.
"""
# (pattern, test_name)
bio_patterns: list[tuple[str, str]] = [
(r"[Ll]ipas[ée]mie\s*(?:[àa=:])?\s*(\d+)\s*(?:UI/L|U/L)?", "Lipasémie"),
(r"\bCRP\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll])?", "CRP"),
(r"(?:\bASAT\b|\bTGO\b)\s*[=:àa]?\s*([\d.,]+)\s*(?:N|U(?:I)?/L)?", "ASAT"),
(r"(?:\bALAT\b|\bTGP\b)\s*[=:àa]?\s*([\d.,]+)\s*(?:N|U(?:I)?/L)?", "ALAT"),
(r"\bGGT\b\s*[=:àa]?\s*(\d+)\s*(?:U(?:I)?/L)?", "GGT"),
(r"\bPAL\b\s*[=:àa]?\s*(\d+)\s*(?:U(?:I)?/L)?", "PAL"),
(r"[Bb]ilirubine\s+(?:totale\s+)?[àa=:]\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Bilirubine totale"),
# Ionogramme / électrolytes
(r"(?:[Ss]odium|[Nn]atr[ée]mie|(?<![A-Za-z])Na\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9]{2,3}(?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Sodium"),
(r"(?:[Pp]otassium|[Kk]ali[ée]mie|(?<![A-Za-z])K\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9](?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Potassium"),
(r"[Tt]roponine\s+(?:us\s+)?(n[ée]gative|positive|normale)", "Troponine"),
(r"(?:[Hh][ée]moglobine|\bHb\b)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/dL|g/L)?", "Hémoglobine"),
(r"[Pp]laquettes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Plaquettes"),
(r"[Ll]eucocytes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Leucocytes"),
(r"[Cc]r[ée]atinine?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Créatinine"),
]
for pattern, test_name, _ in bio_patterns:
m = re.search(pattern, text)
if m:
value = m.group(1)
anomalie = _is_abnormal(test_name, value)
dossier.biologie_cle.append(BiologieCle(
test=test_name,
valeur=value,
anomalie=anomalie,
))
# Anti-doublons + limite par test (évite d'exploser le JSON)
max_per_test = 6
counts: dict[str, int] = {}
seen: set[tuple[str, str]] = set()
sanity_cfg = load_lab_value_sanity()
policy = (sanity_cfg or {}).get("policy") or {}
drop_out_of_range = bool(policy.get("drop_out_of_range", True))
keep_suspect = bool(policy.get("keep_suspect", True))
for pattern, test_name in bio_patterns:
for m in re.finditer(pattern, text):
raw_value = (m.group(1) or "").strip()
if not raw_value:
continue
# Valeurs qualitatives (troponine négative/positive/normale) :
# pas de sanitization numérique.
if re.fullmatch(r"[a-zA-Zéèêëàâôûùïîç]+", raw_value):
key = (test_name, raw_value.lower())
if key in seen:
continue
seen.add(key)
counts[test_name] = counts.get(test_name, 0) + 1
if counts[test_name] > max_per_test:
break
anomalie = _is_abnormal(test_name, raw_value)
dossier.biologie_cle.append(
BiologieCle(
test=test_name,
valeur=raw_value,
valeur_num=None,
anomalie=anomalie,
quality="ok",
discard_reason=None,
)
)
continue
sanitized = _sanitize_bio_value(test_name, raw_value, sanity_cfg)
if sanitized is None:
continue
token, val_num, quality, reason = sanitized
if quality == "suspect" and not keep_suspect:
quality = "discarded"
reason = reason or "Valeur suspecte (policy keep_suspect=false)"
# Déduplication sur la valeur normalisée
key = (test_name, token)
if key in seen:
continue
seen.add(key)
counts[test_name] = counts.get(test_name, 0) + 1
if counts[test_name] > max_per_test:
break
if quality == "discarded":
# On garde la trace pour audit, sans polluer les règles qualité.
dossier.biologie_discarded.append(
{
"test": test_name,
"raw": raw_value,
"valeur": token,
"valeur_num": val_num,
"reason": reason,
}
)
if drop_out_of_range:
continue
anomalie = _is_abnormal(test_name, token)
dossier.biologie_cle.append(
BiologieCle(
test=test_name,
valeur=token,
valeur_num=val_num,
anomalie=anomalie,
quality=quality,
discard_reason=reason,
)
)
def _extract_imagerie(text: str, dossier: DossierMedical) -> None:
@@ -1013,6 +1159,9 @@ BIO_NORMALS: dict[str, tuple[float, float]] = {
"GGT": (0, 60),
"PAL": (0, 150),
"Bilirubine totale": (0, 17),
# Ionogramme (fallback adulte ; les règles de décision utilisent reference_ranges.yaml)
"Sodium": (135, 145),
"Potassium": (3.5, 5.0),
"Hémoglobine": (12, 17),
"Plaquettes": (150, 400),
"Leucocytes": (4, 10),
@@ -1152,36 +1301,11 @@ def _validate_justifications(dossier: DossierMedical) -> None:
ctx = build_enriched_context(dossier)
ctx_str = format_enriched_context(ctx)
prompt = f"""Tu es un médecin DIM contrôleur qualité PMSI.
Vérifie la cohérence et la justification de ce codage complet.
DOSSIER CLINIQUE :
{ctx_str}
CODAGE À VALIDER :
{codes_section}
Pour CHAQUE code, vérifie :
1. Existe-t-il une preuve clinique concrète dans le dossier ?
2. Le code est-il le plus spécifique possible ?
3. Y a-t-il des conflits ou redondances avec d'autres codes ?
Réponds avec un JSON :
{{
"validations": [
{{
"numero": 1,
"code": "X99.9",
"verdict": "maintenir|reclasser|supprimer",
"confidence_recommandee": "high|medium|low",
"commentaire": "explication courte"
}}
],
"alertes_globales": ["..."]
}}"""
from ..prompts import QC_VALIDATION
prompt = QC_VALIDATION.format(ctx_str=ctx_str, codes_section=codes_section)
try:
result = call_ollama(prompt, temperature=0.1, max_tokens=2500)
result = call_ollama(prompt, temperature=0.1, max_tokens=2500, role="qc")
except Exception:
logger.warning("Erreur lors de l'appel Ollama pour validation QC", exc_info=True)
return

View File

@@ -152,6 +152,12 @@ def _compute_severity(das_list: list) -> tuple[int, int, int]:
max_cma_level = 1
for das in das_list:
# Exclure les diagnostics "barrés" / retirés du calcul de sévérité
dec = getattr(das, "cim10_decision", None)
if getattr(das, "status", None) == "ruled_out":
continue
if dec is not None and getattr(dec, "action", None) in ("REMOVE", "RULED_OUT"):
continue
niveau_cma = getattr(das, "niveau_cma", None)
if niveau_cma and niveau_cma > 1:
max_cma_level = max(max_cma_level, niveau_cma)

View File

@@ -14,53 +14,79 @@ class OllamaCache:
"""Cache JSON persistant pour éviter les appels Ollama redondants.
Clé = (texte_diagnostic_normalisé, type).
Le modèle Ollama est stocké dans les métadonnées : si le modèle change,
le cache est automatiquement invalidé.
Le modèle Ollama est stocké PAR ENTRÉE : si le modèle change pour un rôle,
seules les entrées de cet ancien modèle sont invalides.
Migration automatique depuis l'ancien format (model global) au chargement.
"""
def __init__(self, cache_path: Path, model: str):
def __init__(self, cache_path: Path, model: str | None = None):
self._path = cache_path
self._model = model
self._default_model = model
self._lock = threading.Lock()
self._data: dict[str, dict] = {}
self._dirty = False
self._load()
def _load(self) -> None:
"""Charge le cache depuis le disque."""
"""Charge le cache depuis le disque, avec migration automatique."""
if not self._path.exists():
logger.info("Cache Ollama : nouveau cache (%s)", self._path)
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
if raw.get("model") != self._model:
logger.info(
"Cache Ollama : modèle changé (%s%s), cache invalidé",
raw.get("model"), self._model,
)
return
self._data = raw.get("entries", {})
logger.info("Cache Ollama : %d entrées chargées", len(self._data))
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Cache Ollama : fichier corrompu (%s), réinitialisé", e)
self._data = {}
return
entries = raw.get("entries", {})
# Détection ancien format : {"model": "...", "entries": {k: result_dict_sans_model}}
global_model = raw.get("model")
if global_model and entries:
first_val = next(iter(entries.values()), None)
if isinstance(first_val, dict) and "model" not in first_val:
# Migration : ancien format → nouveau (modèle par entrée)
logger.info(
"Cache Ollama : migration ancien format (model=%s) → modèle par entrée",
global_model,
)
migrated: dict[str, dict] = {}
for k, v in entries.items():
if isinstance(v, dict):
migrated[k] = {"model": global_model, "result": v}
self._data = migrated
self._dirty = True
logger.info("Cache Ollama : %d entrées migrées", len(migrated))
return
self._data = entries
logger.info("Cache Ollama : %d entrées chargées", len(self._data))
@staticmethod
def _make_key(texte: str, diag_type: str) -> str:
"""Construit une clé normalisée."""
return f"{diag_type}::{texte.strip().lower()}"
def get(self, texte: str, diag_type: str) -> dict | None:
"""Récupère un résultat caché, ou None si absent."""
def get(self, texte: str, diag_type: str, model: str | None = None) -> dict | None:
"""Récupère un résultat caché, ou None si absent ou modèle différent."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model
with self._lock:
return self._data.get(key)
entry = self._data.get(key)
if entry is None:
return None
if use_model and entry.get("model") != use_model:
return None
return entry.get("result")
def put(self, texte: str, diag_type: str, result: dict) -> None:
"""Stocke un résultat dans le cache."""
def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None:
"""Stocke un résultat dans le cache avec le modèle utilisé."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model
with self._lock:
self._data[key] = result
self._data[key] = {"model": use_model, "result": result}
self._dirty = True
def save(self) -> None:
@@ -69,10 +95,7 @@ class OllamaCache:
if not self._dirty:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"model": self._model,
"entries": self._data,
}
payload = {"entries": self._data}
self._path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",

View File

@@ -8,7 +8,7 @@ import os
import requests
from ..config import OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT
from ..config import OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT, get_model
logger = logging.getLogger(__name__)
@@ -84,6 +84,7 @@ def call_ollama(
max_tokens: int = 2500,
model: str | None = None,
timeout: int | None = None,
role: str | None = None,
) -> dict | None:
"""Appelle Ollama en mode JSON natif, avec fallback Anthropic si indisponible.
@@ -91,13 +92,14 @@ def call_ollama(
prompt: Le prompt à envoyer.
temperature: Température de génération (défaut: 0.1).
max_tokens: Nombre max de tokens (défaut: 2500).
model: Modèle Ollama à utiliser (défaut: OLLAMA_MODEL global).
model: Modèle Ollama à utiliser (prioritaire sur role).
timeout: Timeout en secondes (défaut: OLLAMA_TIMEOUT global).
role: Rôle LLM (coding, cpam, validation, qc) → résolu via get_model().
Returns:
Le dict JSON parsé, ou None en cas d'erreur.
"""
use_model = model or OLLAMA_MODEL
use_model = model or (get_model(role) if role else OLLAMA_MODEL)
use_timeout = timeout or OLLAMA_TIMEOUT
for attempt in range(2):
try:

View File

@@ -1,4 +1,13 @@
"""Indexation FAISS des documents de référence CIM-10 / Guide métho / CCAM."""
"""Indexation FAISS des documents de référence.
Objectif : éviter que des documents "procédure/méthodo" influencent le codage.
On maintient donc 2 index FAISS :
- ref : référentiels (CIM-10, CCAM, référentiels uploadés en ref:...)
- proc : procédures / guide méthodologique (guide_methodo + uploadés en proc:...)
Backwards compat : si les nouveaux fichiers n'existent pas, on retombe sur faiss.index.
"""
from __future__ import annotations
@@ -15,9 +24,8 @@ from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM
logger = logging.getLogger(__name__)
# Singleton pour l'index chargé en mémoire
_faiss_index = None
_metadata: list[dict] = []
# Singletons pour les index chargés en mémoire
_loaded: dict[str, tuple] = {}
@dataclass
@@ -28,6 +36,99 @@ class Chunk:
code: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers nettoyage / découpe
# ---------------------------------------------------------------------------
_RE_JUNK_LINE = re.compile(
r"^(?:\d{1,4}|page\s*\d{1,4}|\d{1,4}\s*/\s*\d{1,4})$",
re.IGNORECASE,
)
def _clean_lines(text: str) -> list[str]:
"""Nettoie des artefacts d'extraction PDF (en-têtes/pieds de page, numéros, etc.)."""
out: list[str] = []
for raw in (text or "").split("\n"):
line = (raw or "").strip().replace("\xa0", " ")
if not line:
continue
# pagination / bruit
if _RE_JUNK_LINE.match(line):
continue
# lignes ultra courtes non informatives
if len(line) <= 2:
continue
out.append(line)
return out
def _split_by_words(text: str, max_words: int = 380, overlap: int = 50) -> list[str]:
"""Découpe un texte long en fenêtres de mots avec recouvrement."""
words = (text or "").split()
if len(words) <= max_words:
return [text.strip()] if text.strip() else []
parts: list[str] = []
i = 0
step = max(1, max_words - overlap)
while i < len(words):
chunk = " ".join(words[i : i + max_words]).strip()
if chunk:
parts.append(chunk)
i += step
return parts
_PROC_KW = (
"procédure", "procedure", "méthodo", "methodo", "méthodologie", "methodologie",
"démarche", "demarche", "étape", "etape", "objectif", "recommand", "doit", "il faut",
"modalité", "modalite", "annexe", "document", "rappel", "consigne",
)
_CRIT_KW = (
"critère", "critere", "seuil", "score", "tableau", "cma", "ghm", "sévérité", "severite",
"inclusion", "exclusion", "diagnostic", "code", "comorbid", "majoration",
)
def _looks_procedural(text: str) -> bool:
"""Heuristique : détecte un chunk majoritairement 'procédural'.
Objectif : éviter que des passages 'process' (qui ne sont pas des critères ou définitions)
polluent l'index référentiel (ex. COCOA).
"""
t = (text or "").lower()
proc_hits = sum(1 for k in _PROC_KW if k in t)
crit_hits = sum(1 for k in _CRIT_KW if k in t)
# Si beaucoup de mots procéduraux et aucun signal de critères, on jette.
return proc_hits >= 5 and crit_hits == 0
def _paths(kind: str) -> tuple[Path, Path]:
"""Retourne (index_path, meta_path) pour un type d'index.
kind:
- "ref" : référentiels
- "proc" : procédures
- "all" : legacy (faiss.index)
"""
kind = (kind or "ref").lower()
if kind == "proc":
return (RAG_INDEX_DIR / "faiss_proc.index", RAG_INDEX_DIR / "metadata_proc.json")
if kind == "all":
return (RAG_INDEX_DIR / "faiss.index", RAG_INDEX_DIR / "metadata.json")
# ref (default)
return (RAG_INDEX_DIR / "faiss_ref.index", RAG_INDEX_DIR / "metadata_ref.json")
def _kind_for_chunk(chunk: Chunk) -> str:
"""Détermine le type d'index cible pour un chunk."""
doc = (chunk.document or "").lower()
if doc == "guide_methodo" or doc.startswith("proc:"):
return "proc"
return "ref"
# ---------------------------------------------------------------------------
# Chunking CIM-10
# ---------------------------------------------------------------------------
@@ -54,12 +155,13 @@ def _chunk_cim10(pdf_path: Path) -> list[Chunk]:
if current_subcode and current_subcode_text:
chunk_text = "\n".join(current_subcode_text)
if len(chunk_text.split()) >= 3:
chunks.append(Chunk(
text=chunk_text,
document="cim10",
page=current_subcode_page,
code=current_subcode,
))
for part in _split_by_words(chunk_text, max_words=260, overlap=40):
chunks.append(Chunk(
text=part,
document="cim10",
page=current_subcode_page,
code=current_subcode,
))
def _flush_code3():
"""Sauvegarde le chunk parent 3-char en cours."""
@@ -67,12 +169,13 @@ def _chunk_cim10(pdf_path: Path) -> list[Chunk]:
if current_code3 and current_code3_text:
chunk_text = "\n".join(current_code3_text)
if len(chunk_text.split()) >= 5:
chunks.append(Chunk(
text=chunk_text,
document="cim10",
page=current_code3_page,
code=current_code3,
))
for part in _split_by_words(chunk_text, max_words=320, overlap=50):
chunks.append(Chunk(
text=part,
document="cim10",
page=current_code3_page,
code=current_code3,
))
with pdfplumber.open(pdf_path) as pdf:
for page_num, page in enumerate(pdf.pages, start=1):
@@ -80,10 +183,7 @@ def _chunk_cim10(pdf_path: Path) -> list[Chunk]:
if not text:
continue
for line in text.split("\n"):
line = line.strip()
if not line:
continue
for line in _clean_lines(text):
m_sub = subcode_pattern.match(line)
m3 = code3_pattern.match(line)
@@ -146,10 +246,7 @@ def _chunk_guide_methodo(pdf_path: Path) -> list[Chunk]:
if not text:
continue
for line in text.split("\n"):
line = line.strip()
if not line:
continue
for line in _clean_lines(text):
is_title = False
for pat in title_patterns:
@@ -194,25 +291,27 @@ def _chunk_guide_methodo(pdf_path: Path) -> list[Chunk]:
for page_num, page in enumerate(pdf.pages, start=1):
text = page.extract_text()
if text:
page_texts.append(text)
page_texts.append("\n".join(_clean_lines(text)))
if len(page_texts) >= 3:
combined = "\n".join(page_texts)
if len(combined.split()) >= 20:
chunks.append(Chunk(
text=combined,
document="guide_methodo",
page=start_page,
))
for part in _split_by_words(combined, max_words=420, overlap=60):
chunks.append(Chunk(
text=part,
document="guide_methodo",
page=start_page,
))
page_texts = []
start_page = page_num + 1
if page_texts:
combined = "\n".join(page_texts)
if len(combined.split()) >= 20:
chunks.append(Chunk(
text=combined,
document="guide_methodo",
page=start_page,
))
for part in _split_by_words(combined, max_words=420, overlap=60):
chunks.append(Chunk(
text=part,
document="guide_methodo",
page=start_page,
))
logger.info("Guide Métho : %d chunks extraits", len(chunks))
return chunks
@@ -238,32 +337,33 @@ def _chunk_ccam(pdf_path: Path) -> list[Chunk]:
current_code: str | None = None
current_lines: list[str] = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
for line in _clean_lines(text):
m = ccam_pattern.match(line)
if m:
if current_code and current_lines:
chunks.append(Chunk(
text="\n".join(current_lines),
document="ccam",
page=page_num,
code=current_code,
))
joined = "\n".join(current_lines)
for part in _split_by_words(joined, max_words=320, overlap=40):
chunks.append(Chunk(
text=part,
document="ccam",
page=page_num,
code=current_code,
))
current_code = m.group(1)
current_lines = [line]
elif current_code:
current_lines.append(line)
if current_code and current_lines:
chunks.append(Chunk(
text="\n".join(current_lines),
document="ccam",
page=page_num,
code=current_code,
))
joined = "\n".join(current_lines)
for part in _split_by_words(joined, max_words=320, overlap=40):
chunks.append(Chunk(
text=part,
document="ccam",
page=page_num,
code=current_code,
))
# Fallback : si aucun code CCAM détecté, indexer par page
if not chunks:
@@ -351,10 +451,7 @@ def _chunk_cim10_alpha(pdf_path: Path) -> list[Chunk]:
if not in_alpha_section:
continue
for line in text.split("\n"):
line = line.strip()
if not line:
continue
for line in _clean_lines(text):
m = entry_pattern.match(line)
if m:
terme = m.group(1).strip()
@@ -376,7 +473,10 @@ def _chunk_cim10_alpha(pdf_path: Path) -> list[Chunk]:
# ---------------------------------------------------------------------------
def build_index(force: bool = False) -> None:
"""Construit l'index FAISS à partir des 3 PDFs de référence.
"""Construit les index FAISS à partir des PDFs de référence.
- ref : CIM-10 (+ index alpha) + CCAM
- proc : Guide méthodologique
Args:
force: Si True, reconstruit même si l'index existe déjà.
@@ -385,43 +485,48 @@ def build_index(force: bool = False) -> None:
import numpy as np
from sentence_transformers import SentenceTransformer
index_path = RAG_INDEX_DIR / "faiss.index"
meta_path = RAG_INDEX_DIR / "metadata.json"
ref_index_path, ref_meta_path = _paths("ref")
proc_index_path, proc_meta_path = _paths("proc")
if not force and index_path.exists() and meta_path.exists():
logger.info("Index FAISS déjà existant dans %s (use force=True pour reconstruire)", RAG_INDEX_DIR)
# Si tout existe déjà et pas de force
ref_ok = ref_index_path.exists() and ref_meta_path.exists()
proc_ok = proc_index_path.exists() and proc_meta_path.exists()
guide_expected = GUIDE_METHODO_PDF.exists()
if not force and ref_ok and ((not guide_expected) or proc_ok):
logger.info("Index FAISS déjà existants dans %s (use force=True pour reconstruire)", RAG_INDEX_DIR)
return
# Collecter tous les chunks
all_chunks: list[Chunk] = []
# Collecter les chunks
ref_chunks: list[Chunk] = []
proc_chunks: list[Chunk] = []
for pdf_path, chunk_fn in [
(CIM10_PDF, _chunk_cim10),
(GUIDE_METHODO_PDF, _chunk_guide_methodo),
]:
if pdf_path.exists():
all_chunks.extend(chunk_fn(pdf_path))
else:
logger.warning("PDF non trouvé : %s", pdf_path)
# CIM-10 (référentiel)
if CIM10_PDF.exists():
ref_chunks.extend(_chunk_cim10(CIM10_PDF))
ref_chunks.extend(_chunk_cim10_alpha(CIM10_PDF))
else:
logger.warning("PDF non trouvé : %s", CIM10_PDF)
# CCAM : priorité au dictionnaire JSON sur le PDF
# Guide méthodologique (procédures)
if GUIDE_METHODO_PDF.exists():
proc_chunks.extend(_chunk_guide_methodo(GUIDE_METHODO_PDF))
else:
logger.warning("PDF non trouvé : %s", GUIDE_METHODO_PDF)
# CCAM (référentiel)
ccam_dict_chunks = _chunk_ccam_from_dict()
if ccam_dict_chunks:
all_chunks.extend(ccam_dict_chunks)
ref_chunks.extend(ccam_dict_chunks)
elif CCAM_PDF.exists():
all_chunks.extend(_chunk_ccam(CCAM_PDF))
ref_chunks.extend(_chunk_ccam(CCAM_PDF))
else:
logger.warning("Ni dictionnaire CCAM ni PDF CCAM trouvé")
# CIM-10 index alphabétique (source additionnelle)
if CIM10_PDF.exists():
all_chunks.extend(_chunk_cim10_alpha(CIM10_PDF))
if not all_chunks:
if not ref_chunks and not proc_chunks:
logger.error("Aucun chunk extrait — vérifiez les chemins des PDFs")
return
logger.info("Total : %d chunks à indexer", len(all_chunks))
logger.info("Total ref : %d chunks | total proc : %d chunks", len(ref_chunks), len(proc_chunks))
# Embeddings — GPU si disponible
import torch
@@ -430,58 +535,72 @@ def build_index(force: bool = False) -> None:
model = SentenceTransformer(EMBEDDING_MODEL, device=_device)
model.max_seq_length = 512 # CamemBERT max position embeddings
texts = [c.text[:2000] for c in all_chunks] # Tronquer les chunks trop longs
logger.info("Calcul des embeddings pour %d chunks...", len(texts))
embeddings = model.encode(
texts, show_progress_bar=True, normalize_embeddings=True, batch_size=64,
)
embeddings = np.array(embeddings, dtype=np.float32)
def _write_index(chunks: list[Chunk], idx_path: Path, meta_path: Path, label: str) -> None:
if not chunks:
return
texts = [c.text[:2000] for c in chunks]
logger.info("Calcul des embeddings (%s) pour %d chunks...", label, len(texts))
embeddings = model.encode(texts, show_progress_bar=True, normalize_embeddings=True, batch_size=64)
embeddings = np.array(embeddings, dtype=np.float32)
dim = embeddings.shape[1]
index = faiss.IndexFlatIP(dim)
index.add(embeddings)
# Index FAISS (IndexFlatIP = cosine similarity avec vecteurs normalisés)
dim = embeddings.shape[1]
index = faiss.IndexFlatIP(dim)
index.add(embeddings)
RAG_INDEX_DIR.mkdir(parents=True, exist_ok=True)
faiss.write_index(index, str(idx_path))
# Sauvegarder
RAG_INDEX_DIR.mkdir(parents=True, exist_ok=True)
faiss.write_index(index, str(index_path))
metadata = [asdict(c) for c in chunks]
for m in metadata:
m["extrait"] = m.pop("text")[:800]
meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
metadata = [asdict(c) for c in all_chunks]
# Ne pas sauvegarder le texte complet dans metadata (trop lourd),
# garder un extrait de 800 chars (les sous-codes sont courts, besoin du contexte)
for m in metadata:
m["extrait"] = m.pop("text")[:800]
logger.info("Index FAISS sauvegardé (%s) : %s (%d vecteurs, dim=%d)", label, idx_path, len(chunks), dim)
meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index(ref_chunks, ref_index_path, ref_meta_path, "ref")
_write_index(proc_chunks, proc_index_path, proc_meta_path, "proc")
logger.info("Index FAISS sauvegardé : %s (%d vecteurs, dim=%d)", index_path, len(all_chunks), dim)
# Invalider les singletons
reset_index()
def get_index() -> tuple | None:
"""Charge l'index FAISS et les métadonnées (singleton lazy-loaded).
def get_index(kind: str = "ref") -> tuple | None:
"""Charge un index FAISS et ses métadonnées (singleton lazy-loaded).
Args:
kind: "ref" | "proc" | "all".
Returns:
Tuple (faiss_index, metadata_list) ou None si l'index n'existe pas.
"""
global _faiss_index, _metadata
kind = (kind or "ref").lower()
if _faiss_index is not None:
return _faiss_index, _metadata
if kind in _loaded:
return _loaded[kind]
index_path = RAG_INDEX_DIR / "faiss.index"
meta_path = RAG_INDEX_DIR / "metadata.json"
index_path, meta_path = _paths(kind)
# Backwards compat : si ref/proc absent, fallback sur all
if kind in ("ref", "proc") and (not index_path.exists() or not meta_path.exists()):
legacy_idx, legacy_meta = _paths("all")
if legacy_idx.exists() and legacy_meta.exists():
logger.warning("Index %s absent — fallback legacy faiss.index", kind)
index_path, meta_path = legacy_idx, legacy_meta
else:
logger.warning("Index FAISS non trouvé dans %s — lancez build_index() d'abord", RAG_INDEX_DIR)
return None
if not index_path.exists() or not meta_path.exists():
logger.warning("Index FAISS non trouvé dans %s — lancez build_index() d'abord", RAG_INDEX_DIR)
logger.warning("Index FAISS non trouvé (%s) dans %s — lancez build_index() d'abord", kind, RAG_INDEX_DIR)
return None
import faiss
_faiss_index = faiss.read_index(str(index_path))
_metadata = json.loads(meta_path.read_text(encoding="utf-8"))
faiss_index = faiss.read_index(str(index_path))
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
logger.info("Index FAISS chargé : %d vecteurs", _faiss_index.ntotal)
return _faiss_index, _metadata
logger.info("Index FAISS chargé (%s) : %d vecteurs", kind, faiss_index.ntotal)
_loaded[kind] = (faiss_index, metadata)
return _loaded[kind]
# ---------------------------------------------------------------------------
@@ -516,8 +635,15 @@ def chunk_user_file(file_path: Path, doc_name: str) -> list[Chunk]:
def _chunk_user_pdf(file_path: Path, doc_name: str) -> list[Chunk]:
"""Découpe un PDF utilisateur en chunks de 2 pages."""
"""Découpe un PDF utilisateur en chunks (par défaut 2 pages).
Spécial : pour certains référentiels (ex. COCOA), on préfère des chunks plus
fins (1 page) et on filtre les passages majoritairement procéduraux.
"""
chunks: list[Chunk] = []
doc_lower = (doc_name or "").lower()
is_cocoa = "cocoa" in doc_lower or "coco" in doc_lower
pages_per_chunk = 1 if is_cocoa else 2
try:
with pdfplumber.open(file_path) as pdf:
page_texts: list[str] = []
@@ -525,25 +651,32 @@ def _chunk_user_pdf(file_path: Path, doc_name: str) -> list[Chunk]:
for page_num, page in enumerate(pdf.pages, start=1):
text = page.extract_text()
if text:
page_texts.append(text)
if len(page_texts) >= 2:
combined = "\n".join(page_texts)
page_texts.append("\n".join(_clean_lines(text)))
if len(page_texts) >= pages_per_chunk:
combined = "\n".join(page_texts).strip()
if is_cocoa and _looks_procedural(combined):
# on ignore les chunks "process" sans signal de critères/définitions
page_texts = []
start_page = page_num + 1
continue
if len(combined.split()) >= 10:
chunks.append(Chunk(
text=combined,
document=doc_name,
page=start_page,
))
for part in _split_by_words(combined, max_words=420 if is_cocoa else 520, overlap=60):
chunks.append(Chunk(
text=part,
document=doc_name,
page=start_page,
))
page_texts = []
start_page = page_num + 1
if page_texts:
combined = "\n".join(page_texts)
if len(combined.split()) >= 10:
chunks.append(Chunk(
text=combined,
document=doc_name,
page=start_page,
))
combined = "\n".join(page_texts).strip()
if not (is_cocoa and _looks_procedural(combined)) and len(combined.split()) >= 10:
for part in _split_by_words(combined, max_words=420 if is_cocoa else 520, overlap=60):
chunks.append(Chunk(
text=part,
document=doc_name,
page=start_page,
))
except Exception:
logger.warning("Erreur lors du chunking PDF %s", file_path, exc_info=True)
logger.info("Référentiel PDF %s : %d chunks", doc_name, len(chunks))
@@ -614,8 +747,16 @@ def add_chunks_to_index(chunks: list[Chunk]) -> int:
import numpy as np
from .rag_search import _get_embed_model
index_path = RAG_INDEX_DIR / "faiss.index"
meta_path = RAG_INDEX_DIR / "metadata.json"
# Dans 99% des cas, on veut éviter de mélanger : on route vers ref/proc selon le préfixe.
# Si l'appelant veut forcer, il peut passer des chunks avec document="proc:...".
kind = _kind_for_chunk(chunks[0])
index_path, meta_path = _paths(kind)
# Backwards compat : si on n'a que l'ancien index, on l'utilise.
if not index_path.exists() or not meta_path.exists():
legacy_idx, legacy_meta = _paths("all")
if legacy_idx.exists() and legacy_meta.exists():
index_path, meta_path = legacy_idx, legacy_meta
# Charger l'index existant ou en créer un nouveau
if index_path.exists() and meta_path.exists():
@@ -658,7 +799,5 @@ def add_chunks_to_index(chunks: list[Chunk]) -> int:
def reset_index() -> None:
"""Invalide le singleton FAISS pour forcer le rechargement au prochain accès."""
global _faiss_index, _metadata
_faiss_index = None
_metadata = []
"""Invalide les singletons FAISS pour forcer le rechargement au prochain accès."""
_loaded.clear()

View File

@@ -8,7 +8,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from ..config import (
ActeCCAM, Diagnostic, DossierMedical, PreuveClinique, RAGSource,
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL,
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, get_model,
EMBEDDING_MODEL, RERANKER_MODEL,
)
from .cim10_dict import normalize_code, validate_code as cim10_validate, fallback_parent_code
@@ -17,6 +17,7 @@ from .clinical_context import build_enriched_context, format_enriched_context
from .ccam_dict import validate_code as ccam_validate
from .ollama_client import call_ollama, parse_json_response
from .ollama_cache import OllamaCache
from ..prompts import CODING_CIM10, CODING_CCAM, DAS_EXTRACTION
logger = logging.getLogger(__name__)
@@ -138,7 +139,8 @@ def search_similar(query: str, top_k: int = 10) -> list[dict]:
from .rag_index import get_index
import numpy as np
result = get_index()
# Codage CIM-10 : on interroge l'index "ref" (pas le guide méthodo).
result = get_index(kind="ref")
if result is None:
logger.warning("Index FAISS non disponible")
return []
@@ -163,17 +165,32 @@ def search_similar(query: str, top_k: int = 10) -> list[dict]:
meta["score"] = float(score)
raw_results.append(meta)
# Prioriser les sources CIM-10 (au moins 6 sur top_k)
cim10_results = [r for r in raw_results if r["document"] in ("cim10", "cim10_alpha")]
other_results = [r for r in raw_results if r["document"] not in ("cim10", "cim10_alpha")]
# Codage : on garde uniquement CIM-10 + index alpha + éventuels référentiels uploadés en ref:...
cim10_results = [r for r in raw_results if r.get("document") == "cim10"]
alpha_results = [r for r in raw_results if r.get("document") == "cim10_alpha"]
ref_uploads = [r for r in raw_results if str(r.get("document", "")).startswith("ref:")]
min_cim10 = min(6, len(cim10_results))
final = cim10_results[:min_cim10]
remaining_slots = top_k - len(final)
# Remplir le reste avec les meilleurs résultats (CIM-10 restants + autres)
remaining = cim10_results[min_cim10:] + other_results
remaining.sort(key=lambda r: r["score"], reverse=True)
final.extend(remaining[:remaining_slots])
cim10_results.sort(key=lambda r: r["score"], reverse=True)
alpha_results.sort(key=lambda r: r["score"], reverse=True)
ref_uploads.sort(key=lambda r: r["score"], reverse=True)
# Quotas : on veut garder le codage ancré sur CIM-10, tout en gardant un peu d'alpha et de ref.
q_cim10 = min(6, top_k)
q_alpha = 2 if top_k >= 10 else (1 if top_k >= 8 else 0)
q_alpha = min(q_alpha, max(0, top_k - q_cim10))
q_ref = max(0, top_k - q_cim10 - q_alpha)
q_ref = min(q_ref, 2) # éviter que les uploads 'ref:' prennent tout l'espace contexte
final: list[dict] = []
final.extend(cim10_results[:q_cim10])
final.extend(alpha_results[:q_alpha])
final.extend(ref_uploads[:q_ref])
# Compléter si on a moins que top_k (ex: pas assez d'alpha/ref)
if len(final) < top_k:
remaining = cim10_results[q_cim10:] + alpha_results[q_alpha:] + ref_uploads[q_ref:]
remaining.sort(key=lambda r: r["score"], reverse=True)
final.extend(remaining[: (top_k - len(final))])
return final
@@ -186,7 +203,8 @@ def search_similar_ccam(query: str, top_k: int = 8) -> list[dict]:
from .rag_index import get_index
import numpy as np
result = get_index()
# CCAM : index "ref".
result = get_index(kind="ref")
if result is None:
logger.warning("Index FAISS non disponible")
return []
@@ -236,30 +254,44 @@ def search_similar_cpam(query: str, top_k: int = 8) -> list[dict]:
from .rag_index import get_index
import numpy as np
result = get_index()
if result is None:
# Contexte CPAM : on veut des procédures (guide) + définitions référentielles (CIM-10).
proc = get_index(kind="proc")
ref = get_index(kind="ref")
if proc is None and ref is None:
logger.warning("Index FAISS non disponible")
return []
faiss_index, metadata = result
model = _get_embed_model()
query_vec = model.encode([query], normalize_embeddings=True)
query_vec = np.array(query_vec, dtype=np.float32)
# Fetch élargi pour compenser le filtrage agressif
fetch_k = min(top_k * 3, faiss_index.ntotal)
scores, indices = faiss_index.search(query_vec, fetch_k)
def _search_one(result_tuple, fetch_mult: int) -> list[dict]:
if result_tuple is None:
return []
faiss_index, metadata = result_tuple
fetch_k = min(top_k * fetch_mult, faiss_index.ntotal)
scores, indices = faiss_index.search(query_vec, fetch_k)
out = []
for score, idx in zip(scores[0], indices[0]):
if idx < 0:
continue
if float(score) < _MIN_SCORE_CPAM:
continue
meta = metadata[idx].copy()
meta["score"] = float(score)
out.append(meta)
return out
raw_results = []
for score, idx in zip(scores[0], indices[0]):
if idx < 0:
continue
if float(score) < _MIN_SCORE_CPAM:
continue
meta = metadata[idx].copy()
meta["score"] = float(score)
raw_results.append(meta)
raw_proc = _search_one(proc, fetch_mult=3)
raw_ref = _search_one(ref, fetch_mult=3)
# Filtrer clairement :
# - proc : guide_methodo + uploads proc:
raw_proc = [r for r in raw_proc if r.get("document") == "guide_methodo" or str(r.get("document", "")).startswith("proc:")]
# - ref : CIM-10 + index alpha + uploads ref:
raw_ref = [r for r in raw_ref if r.get("document") in ("cim10", "cim10_alpha") or str(r.get("document", "")).startswith("ref:")]
raw_results = raw_proc + raw_ref
# Dédupliquer par code CIM-10 (garder meilleur score par code)
seen_codes: dict[str, dict] = {}
@@ -281,8 +313,11 @@ def search_similar_cpam(query: str, top_k: int = 8) -> list[dict]:
reranked = _rerank(query, deduped, top_k=len(deduped))
# Prioriser le Guide Méthodologique (min 3 résultats)
guide_results = [r for r in reranked if r["document"] == "guide_methodo"]
other_results = [r for r in reranked if r["document"] != "guide_methodo"]
guide_results = [r for r in reranked if r.get("document") == "guide_methodo" or str(r.get("document", "")).startswith("proc:")]
other_results = [
r for r in reranked
if not (r.get("document") == "guide_methodo" or str(r.get("document", "")).startswith("proc:"))
]
min_guide = min(3, len(guide_results))
final = guide_results[:min_guide]
@@ -357,107 +392,55 @@ def _format_contexte(contexte: dict) -> str:
return "\n".join(lines) if lines else "Non précisé"
def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool = True) -> str:
"""Construit le prompt expert DIM avec raisonnement structuré."""
def _format_sources(sources: list[dict]) -> str:
"""Formate les sources RAG pour injection dans un prompt."""
sources_text = ""
for i, src in enumerate(sources, 1):
doc_name = {
"cim10": "CIM-10 FR 2026",
"cim10_alpha": "CIM-10 Index Alphabétique 2026",
"guide_methodo": "Guide Méthodologique MCO 2026",
"ccam": "CCAM PMSI V4 2025",
}.get(src["document"], src["document"])
doc_raw = str(src.get("document", ""))
if doc_raw.startswith("ref:"):
doc_name = f"Référentiel uploadé : {doc_raw[4:]}"
elif doc_raw.startswith("proc:"):
doc_name = f"Procédure uploadée : {doc_raw[5:]}"
else:
doc_name = {
"cim10": "CIM-10 FR 2026",
"cim10_alpha": "CIM-10 Index Alphabétique 2026",
"guide_methodo": "Guide Méthodologique MCO 2026",
"ccam": "CCAM PMSI V4 2025",
}.get(doc_raw, doc_raw)
code_info = f" (code: {src['code']})" if src.get("code") else ""
page_info = f" [page {src['page']}]" if src.get("page") else ""
sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n"
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
return sources_text
def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool = True) -> str:
"""Construit le prompt expert DIM avec raisonnement structuré."""
type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)"
ctx_str = format_enriched_context(contexte)
sources_text = _format_sources(sources)
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI.
Tu dois coder le diagnostic suivant en respectant STRICTEMENT les règles de l'ATIH.
RÈGLES IMPÉRATIVES :
- Le code doit provenir UNIQUEMENT des sources CIM-10 fournies
- Distingue la DESCRIPTION CLINIQUE (ce que le médecin écrit) de la LOGIQUE DE CODAGE (ce que l'ATIH impose)
- Privilégie le code le plus SPÉCIFIQUE disponible (4e ou 5e caractère)
- Vérifie les notes d'inclusion/exclusion de chaque code candidat
- Si le diagnostic est un DP, il doit refléter le motif principal de prise en charge du séjour
- Si c'est un DAS, il doit avoir mobilisé des ressources supplémentaires pendant le séjour
- EXCLUSION SYMPTÔME : Si le diagnostic est un symptôme (R00-R99) et qu'un diagnostic précis (Chapitres I-XIV, A00-N99) expliquant ce symptôme est présent, le symptôme ne doit PAS être codé comme DAS
DIAGNOSTIC À CODER : "{texte}"
TYPE : {type_diag}
CONTEXTE CLINIQUE :
{ctx_str}
SOURCES CIM-10 :
{sources_text}
Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
{{
"analyse_clinique": "que signifie ce diagnostic sur le plan médical",
"codes_candidats": "quels codes CIM-10 des sources sont compatibles",
"discrimination": "pourquoi choisir ce code plutôt qu'un autre (inclusions/exclusions, spécificité)",
"regle_pmsi": "conformité aux règles PMSI pour un {type_diag} (guide méthodologique)",
"code": "X99.9",
"confidence": "high ou medium ou low",
"justification": "explication courte en français",
"preuves_cliniques": [
{{"type": "biologie|imagerie|traitement|acte|clinique", "element": "élément concret du dossier", "interpretation": "signification clinique justifiant le code"}}
]
}}"""
return CODING_CIM10.format(
texte=texte,
type_diag=type_diag,
ctx_str=ctx_str,
sources_text=sources_text,
)
def _build_prompt_ccam(texte: str, sources: list[dict], contexte: dict) -> str:
"""Construit le prompt expert DIM pour le codage CCAM avec raisonnement structuré."""
sources_text = ""
for i, src in enumerate(sources, 1):
doc_name = {
"cim10": "CIM-10 FR 2026",
"cim10_alpha": "CIM-10 Index Alphabétique 2026",
"guide_methodo": "Guide Méthodologique MCO 2026",
"ccam": "CCAM PMSI V4 2025",
}.get(src["document"], src["document"])
code_info = f" (code: {src['code']})" if src.get("code") else ""
page_info = f" [page {src['page']}]" if src.get("page") else ""
sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n"
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
ctx_str = format_enriched_context(contexte)
sources_text = _format_sources(sources)
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage CCAM PMSI.
Tu dois coder l'acte chirurgical/médical suivant en respectant STRICTEMENT la nomenclature CCAM.
RÈGLES IMPÉRATIVES :
- Le code doit provenir UNIQUEMENT des sources CCAM fournies
- Un code CCAM est composé de 4 lettres + 3 chiffres (ex: HMFC004)
- Vérifie l'activité (1=acte technique, 4=anesthésie) et le regroupement
- Tiens compte du tarif secteur 1 pour valider la cohérence
- Si plusieurs codes sont possibles, choisis le plus spécifique à l'acte décrit
- En cas de doute, indique confidence "low" plutôt que de proposer un code inadapté
ACTE À CODER : "{texte}"
CONTEXTE CLINIQUE :
{ctx_str}
SOURCES CCAM :
{sources_text}
Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
{{
"analyse_acte": "que décrit cet acte sur le plan technique/chirurgical",
"codes_candidats": "quels codes CCAM des sources sont compatibles",
"discrimination": "pourquoi choisir ce code plutôt qu'un autre (activité, regroupement, tarif)",
"code": "ABCD123",
"confidence": "high ou medium ou low",
"justification": "explication courte en français"
}}"""
return CODING_CCAM.format(
texte=texte,
ctx_str=ctx_str,
sources_text=sources_text,
)
def _parse_ollama_response(raw: str) -> dict | None:
@@ -481,7 +464,7 @@ def _parse_ollama_response(raw: str) -> dict | None:
def _call_ollama(prompt: str) -> dict | None:
"""Appelle Ollama (mode JSON) et parse la réponse avec reconstitution du raisonnement."""
result = call_ollama(prompt, temperature=0.1, max_tokens=2500)
result = call_ollama(prompt, temperature=0.1, max_tokens=2500, role="coding")
if result is None:
return None
# Reconstituer le raisonnement structuré
@@ -669,42 +652,12 @@ def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[s
ctx_str = format_enriched_context(contexte)
existing_str = "\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun"
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI.
Analyse le texte médical suivant et identifie les diagnostics associés significatifs (DAS) qui n'ont PAS encore été codés.
RÈGLES IMPÉRATIVES :
- Un DAS doit avoir mobilisé des ressources supplémentaires pendant le séjour
- Ne PAS proposer de doublons avec les DAS déjà codés ci-dessous
- Ne PAS proposer le diagnostic principal comme DAS
- Ne PAS coder les symptômes (R00-R99) si un diagnostic précis les explique
- Ne PAS coder les antécédents non pertinents pour le séjour
- Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère)
- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte
- ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale.
DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"}
DAS DÉJÀ CODÉS :
{existing_str}
CONTEXTE CLINIQUE :
{ctx_str}
TEXTE MÉDICAL :
{text[:4000]}
Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
{{
"diagnostics_supplementaires": [
{{
"texte": "description du diagnostic",
"code_cim10": "X99.9",
"justification": "pourquoi ce DAS est pertinent pour le séjour"
}}
]
}}
Si aucun DAS supplémentaire n'est pertinent, retourne : {{"diagnostics_supplementaires": []}}"""
return DAS_EXTRACTION.format(
dp_texte=dp_texte or "Non identifié",
existing_str=existing_str,
ctx_str=ctx_str,
text_medical=text[:4000],
)
def extract_das_llm(
@@ -741,7 +694,7 @@ def extract_das_llm(
# Construire le prompt et appeler Ollama
prompt = _build_prompt_das_extraction(text, contexte, existing_das, dp_texte)
result = call_ollama(prompt, temperature=0.1, max_tokens=2000)
result = call_ollama(prompt, temperature=0.1, max_tokens=2000, role="coding")
if result is None:
logger.warning("Extraction DAS LLM : Ollama non disponible")
@@ -766,7 +719,7 @@ def enrich_dossier(dossier: DossierMedical) -> None:
Utilise un cache persistant et parallélise les appels Ollama
pour les DAS et actes CCAM (max_workers = OLLAMA_MAX_PARALLEL).
"""
cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL)
cache = OllamaCache(OLLAMA_CACHE_PATH, get_model("coding"))
contexte = build_enriched_context(dossier)