feat: mode hybride Ollama — gemma3:27b pour CPAM, 12b pour codage

Le pipeline utilise désormais gemma3:12b (rapide) pour le codage CIM-10
et gemma3:27b (meilleur raisonnement) pour la contre-argumentation CPAM.
Configurable via OLLAMA_MODEL_CPAM et OLLAMA_TIMEOUT_CPAM.

Inclut aussi : traçabilité source/page DAS, niveaux CMA ATIH, sévérité,
page tracker PDF, améliorations fusion et filtres DAS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-17 17:53:53 +01:00
parent 4ef42dd3d3
commit 01d47f3c4b
20 changed files with 1025 additions and 98 deletions

View File

@@ -36,7 +36,9 @@ NER_CONFIDENCE_THRESHOLD = float(os.environ.get("T2A_NER_THRESHOLD", "0.80"))
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:12b")
OLLAMA_MODEL_CPAM = os.environ.get("OLLAMA_MODEL_CPAM", "gemma3:27b")
OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "120"))
OLLAMA_TIMEOUT_CPAM = int(os.environ.get("OLLAMA_TIMEOUT_CPAM", "300"))
OLLAMA_CACHE_PATH = BASE_DIR / "data" / "ollama_cache.json"
OLLAMA_MAX_PARALLEL = int(os.environ.get("OLLAMA_MAX_PARALLEL", "2"))
@@ -55,6 +57,7 @@ UPLOAD_MAX_SIZE_MB = 50
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json"
CMA_LEVELS_PATH = BASE_DIR / "data" / "cma_levels.json"
CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json"
CIM10_PDF = Path(os.environ.get("T2A_CIM10_PDF", "/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf"))
GUIDE_METHODO_PDF = Path(os.environ.get("T2A_GUIDE_METHODO_PDF", "/home/dom/ai/aivanov_CIM/guide_methodo_mco_2026_version_provisoire.pdf"))
@@ -101,7 +104,10 @@ class Diagnostic(BaseModel):
est_cma: Optional[bool] = None
est_cms: Optional[bool] = None
niveau_severite: Optional[str] = None # "leger" | "modere" | "severe" | "non_evalue"
niveau_cma: Optional[int] = None # 1 (pas CMA) | 2 | 3 | 4 (niveau officiel ATIH)
source: Optional[str] = None # "trackare" | "edsnlp" | "regex" | "llm_das"
source_page: Optional[int] = None # numéro de page (1-indexed) dans le PDF source
source_excerpt: Optional[str] = None # extrait du texte source (~200 chars)
class ActeCCAM(BaseModel):

View File

@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
from ..config import ControleCPAM, DossierMedical, RAGSource
from ..medical.ollama_client import call_ollama
from ..config import ControleCPAM, DossierMedical, RAGSource, OLLAMA_MODEL_CPAM, OLLAMA_TIMEOUT_CPAM
from ..medical.ollama_client import call_anthropic, call_ollama
logger = logging.getLogger(__name__)
@@ -244,33 +244,84 @@ CONSIGNES :
AXE MÉDICAL :
- Analyse le bien-fondé médical du codage de l'établissement
- CITE les éléments cliniques EXACTS du dossier : valeurs bio précises (ex: CRP 180 mg/L), résultats imagerie verbatim, traitements avec molécules et posologies
- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
- Identifie les points où la CPAM a éventuellement raison
- Ne mentionne que les éléments réellement présents dans le dossier fourni
AXE ASYMÉTRIE D'INFORMATION :
- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis
- Démontre en quoi les éléments cliniques complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté
- Pour chaque élément clinique pertinent, explique pourquoi il invalide ou nuance l'argumentation CPAM
- 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
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
- NE CITE AUCUNE référence qui ne figure pas dans les sources fournies
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 où la CPAM a raison (ou 'Aucun')",
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage",
"preuves_dossier": [
{{"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 des sources",
"references": "Références EXACTES tirées des sources fournies (document, page, code)",
"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 et position recommandée"
}}"""
def _format_response(parsed: dict) -> str:
def _validate_references(parsed: dict, sources: list[dict]) -> list[str]:
"""Vérifie que les références citées correspondent aux sources RAG fournies.
Returns:
Liste d'avertissements pour les références non vérifiables.
"""
warnings = []
refs = parsed.get("references")
if not refs or not isinstance(refs, list):
return warnings
# Construire un set des documents sources disponibles
source_docs = set()
for src in sources:
doc_name = src.get("document", "")
source_docs.add(doc_name)
# Ajouter les noms lisibles aussi
readable = {
"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_name, "")
if readable:
source_docs.add(readable)
source_docs.add(readable.lower())
if not source_docs:
return warnings
for ref in refs:
if not isinstance(ref, dict):
continue
doc = ref.get("document", "")
if doc and not any(sd in doc.lower() or doc.lower() in sd.lower() for sd in source_docs if sd):
warnings.append(f"Référence non vérifiable : {doc}")
logger.warning("CPAM : référence non vérifiable « %s »", doc)
return warnings
def _format_response(parsed: dict, ref_warnings: list[str] | None = None) -> str:
"""Formate la réponse LLM en texte lisible."""
sections = []
@@ -287,6 +338,19 @@ def _format_response(parsed: dict) -> str:
if contre_med:
sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{contre_med}")
# Preuves du dossier (nouveau champ structuré)
preuves = parsed.get("preuves_dossier")
if preuves and isinstance(preuves, list):
preuves_lines = []
for p in preuves:
if isinstance(p, dict):
elem = p.get("element", "")
valeur = p.get("valeur", "")
signif = p.get("signification", "")
preuves_lines.append(f"- [{elem}] {valeur}{signif}")
if preuves_lines:
sections.append(f"PREUVES DU DOSSIER\n" + "\n".join(preuves_lines))
contre_asym = parsed.get("contre_arguments_asymetrie")
if contre_asym:
sections.append(f"ASYMÉTRIE D'INFORMATION\n{contre_asym}")
@@ -301,14 +365,33 @@ def _format_response(parsed: dict) -> str:
if contre:
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
# Références structurées (nouveau format liste) ou ancien format string
refs = parsed.get("references")
if refs:
sections.append(f"REFERENCES\n{refs}")
if isinstance(refs, list):
ref_lines = []
for r in refs:
if isinstance(r, dict):
doc = r.get("document", "")
page = r.get("page", "")
citation = r.get("citation", "")
ref_lines.append(f"- [{doc}, p.{page}] {citation}")
else:
ref_lines.append(f"- {r}")
if ref_lines:
sections.append(f"REFERENCES\n" + "\n".join(ref_lines))
else:
sections.append(f"REFERENCES\n{refs}")
conclusion = parsed.get("conclusion")
if conclusion:
sections.append(f"CONCLUSION\n{conclusion}")
# Avertissements sur les références non vérifiables
if ref_warnings:
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")
return "\n\n".join(sections)
@@ -335,8 +418,24 @@ def generate_cpam_response(
# 2. Construction du prompt
prompt = _build_cpam_prompt(dossier, controle, sources)
# 3. Appel Ollama
result = call_ollama(prompt, temperature=0.1, max_tokens=3000)
# 3. Appel LLM — Mode hybride : Ollama CPAM (27b) > Haiku > Ollama défaut
result = None
if OLLAMA_MODEL_CPAM:
logger.info(" Contre-argumentation via Ollama %s (mode hybride)", OLLAMA_MODEL_CPAM)
result = call_ollama(
prompt, temperature=0.1, max_tokens=4000,
model=OLLAMA_MODEL_CPAM, timeout=OLLAMA_TIMEOUT_CPAM,
)
if result is not None:
logger.info(" Contre-argumentation via Ollama %s", OLLAMA_MODEL_CPAM)
else:
logger.info(" Ollama CPAM indisponible → fallback Anthropic Haiku")
result = call_anthropic(prompt, temperature=0.1, max_tokens=4000)
if result is not None:
logger.info(" Contre-argumentation via Anthropic Haiku")
else:
logger.info(" Haiku indisponible → fallback Ollama défaut")
result = call_ollama(prompt, temperature=0.1, max_tokens=3000)
# 4. Conversion des sources RAG
rag_sources = [
@@ -350,11 +449,16 @@ def generate_cpam_response(
]
if result is None:
logger.warning(" Ollama non disponible — contre-argumentation non générée")
logger.warning(" LLM non disponible — contre-argumentation non générée")
return "", rag_sources
# 5. Formater la réponse
text = _format_response(result)
# 5. Validation des références
ref_warnings = _validate_references(result, sources)
if ref_warnings:
logger.warning(" CPAM : %d référence(s) non vérifiable(s)", len(ref_warnings))
# 6. Formater la réponse
text = _format_response(result, ref_warnings)
logger.info(" Contre-argumentation générée (%d caractères)", len(text))
return text, rag_sources

View File

@@ -0,0 +1,91 @@
"""Suivi des pages sources pour la traçabilité des diagnostics.
Permet de retrouver la page d'origine et l'extrait de texte correspondant
à un diagnostic extrait du PDF.
"""
from __future__ import annotations
from typing import Optional
class PageTracker:
"""Associe chaque position de caractère au numéro de page source.
Args:
page_offsets: Liste de tuples (start, end) pour chaque page (0-indexed dans la liste).
"""
def __init__(self, page_offsets: list[tuple[int, int]]):
self._offsets = page_offsets
def char_to_page(self, char_pos: int) -> int:
"""Retourne le numéro de page (1-indexed) pour une position de caractère."""
for i, (start, end) in enumerate(self._offsets):
if start <= char_pos < end:
return i + 1
# Si au-delà de la dernière page, retourner la dernière
if self._offsets:
return len(self._offsets)
return 1
def find_page_for_text(self, text: str, full_text: str) -> Optional[int]:
"""Cherche le texte dans full_text et retourne la page (1-indexed).
Effectue une recherche case-insensitive si la recherche exacte échoue.
"""
if not text or not full_text:
return None
# Recherche exacte
pos = full_text.find(text)
if pos >= 0:
return self.char_to_page(pos)
# Recherche case-insensitive
pos = full_text.lower().find(text.lower())
if pos >= 0:
return self.char_to_page(pos)
# Recherche partielle (premiers 50 chars)
short = text[:50].strip()
if len(short) >= 10:
pos = full_text.lower().find(short.lower())
if pos >= 0:
return self.char_to_page(pos)
return None
def extract_excerpt(
self, text: str, full_text: str, context_chars: int = 100,
) -> Optional[str]:
"""Extrait le contexte autour du texte trouvé (~200 chars).
Returns:
Extrait avec contexte, ou None si le texte n'est pas trouvé.
"""
if not text or not full_text:
return None
# Recherche (exacte puis case-insensitive)
pos = full_text.find(text)
if pos < 0:
pos = full_text.lower().find(text.lower())
if pos < 0:
short = text[:50].strip()
if len(short) >= 10:
pos = full_text.lower().find(short.lower())
if pos < 0:
return None
start = max(0, pos - context_chars)
end = min(len(full_text), pos + len(text) + context_chars)
excerpt = full_text[start:end].strip()
# Ajouter des ellipses
if start > 0:
excerpt = "..." + excerpt
if end < len(full_text):
excerpt = excerpt + "..."
return excerpt

View File

@@ -3,9 +3,12 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
import pdfplumber
from .page_tracker import PageTracker
def extract_text(pdf_path: str | Path) -> str:
"""Extrait le texte de toutes les pages d'un PDF."""
@@ -17,6 +20,33 @@ def extract_text(pdf_path: str | Path) -> str:
return "\n\n".join(pages_text)
def extract_text_with_pages(pdf_path: str | Path) -> tuple[str, PageTracker]:
"""Extrait le texte avec un tracker de pages pour la traçabilité.
Returns:
(texte_complet, page_tracker) où page_tracker permet de retrouver
la page source de chaque position de caractère.
"""
pages_text: list[str] = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
pages_text.append(text)
# Construire le texte complet avec "\n\n" comme séparateur (identique à extract_text)
separator = "\n\n"
page_offsets: list[tuple[int, int]] = []
offset = 0
for i, page_text in enumerate(pages_text):
start = offset
end = offset + len(page_text)
page_offsets.append((start, end))
offset = end + len(separator)
full_text = separator.join(pages_text)
return full_text, PageTracker(page_offsets)
def extract_pages(pdf_path: str | Path) -> list[str]:
"""Extrait le texte page par page."""
pages: list[str] = []

View File

@@ -14,7 +14,7 @@ from .config import ANONYMIZED_DIR, INPUT_DIR, OUTPUT_DIR, REPORTS_DIR, STRUCTUR
from .extraction.document_classifier import classify
from .extraction.crh_parser import parse_crh
from .extraction.document_splitter import split_documents
from .extraction.pdf_extractor import extract_text
from .extraction.pdf_extractor import extract_text, extract_text_with_pages
from .extraction.trackare_parser import parse_trackare
from .medical.cim10_extractor import extract_medical_info
from .medical.ghm import estimate_ghm
@@ -38,8 +38,8 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
t0 = time.time()
logger.info("Traitement de %s", pdf_path.name)
# 1. Extraction texte
raw_text = extract_text(pdf_path)
# 1. Extraction texte avec pages
raw_text, page_tracker = extract_text_with_pages(pdf_path)
logger.info(" Texte extrait : %d caractères", len(raw_text))
# 2. Classification
@@ -82,7 +82,10 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
edsnlp_result = _run_edsnlp(anonymized_text)
# 7. Extraction médicale CIM-10
dossier = extract_medical_info(parsed, anonymized_text, edsnlp_result, use_rag=_use_rag)
dossier = extract_medical_info(
parsed, anonymized_text, edsnlp_result, use_rag=_use_rag,
page_tracker=page_tracker, raw_text=raw_text,
)
dossier.source_file = pdf_path.name
dossier.document_type = doc_type
logger.info(" DP%s : %s", part_label, dossier.diagnostic_principal)

View File

@@ -98,11 +98,21 @@ def extract_medical_info(
anonymized_text: str,
edsnlp_result: Optional[EdsnlpResult] = None,
use_rag: bool = False,
page_tracker=None,
raw_text: str | None = None,
) -> DossierMedical:
"""Extrait les informations médicales structurées depuis les données parsées et le texte."""
"""Extrait les informations médicales structurées depuis les données parsées et le texte.
Args:
page_tracker: PageTracker pour la traçabilité page/extrait (optionnel).
raw_text: Texte brut avant anonymisation (pour recherche page source).
"""
dossier = DossierMedical()
dossier.document_type = parsed_data.get("type", "")
# Texte de référence pour la recherche de pages (raw_text préféré, sinon anonymized)
search_text = raw_text or anonymized_text
_extract_sejour(parsed_data, dossier)
_extract_diagnostics(parsed_data, anonymized_text, dossier, edsnlp_result)
_extract_actes(anonymized_text, dossier)
@@ -140,6 +150,10 @@ def extract_medical_info(
# Post-processing : retirer DAS dont le code est identique au DP
_remove_das_equal_dp(dossier)
# Post-processing : traçabilité source (page + extrait)
if page_tracker:
_apply_source_tracking(dossier, page_tracker, search_text)
return dossier
@@ -331,10 +345,12 @@ def _extract_diagnostics(
elif edsnlp_codes:
# Utiliser la première entité CIM-10 edsnlp comme DP
code, texte = next(iter(edsnlp_codes.items()))
dossier.diagnostic_principal = Diagnostic(
texte=texte.capitalize(), cim10_suggestion=code,
source="edsnlp",
)
texte_clean = texte.capitalize()
if is_valid_diagnostic_text(texte_clean):
dossier.diagnostic_principal = Diagnostic(
texte=texte_clean, cim10_suggestion=code,
source="edsnlp",
)
# Diagnostics associés depuis le texte (regex)
das = _find_diagnostics_associes(text_lower, conclusion, dossier)
@@ -881,18 +897,46 @@ def _apply_code_corrections(dossier: DossierMedical) -> None:
diag.cim10_suggestion = corrected
def _is_dp_family_redundant(das_code: str, dp_code: str) -> bool:
"""True si le DAS est redondant avec le DP (même code, parent/enfant, ou même famille)."""
if das_code == dp_code:
return True
# Relation parent/enfant → toujours redondant
das_norm = das_code.replace(".", "")
dp_norm = dp_code.replace(".", "")
if das_norm.startswith(dp_norm) or dp_norm.startswith(das_norm):
return True
# Même famille 3 chars, sauf exceptions
dp_family = dp_code[:3]
if das_code[:3] == dp_family:
# S/T (trauma) : sites différents → garder
if dp_family[0] in ("S", "T"):
return False
# E10-E14 (diabète) : complications différentes → garder
if dp_family[0] == "E" and dp_family[1:].isdigit() and 10 <= int(dp_family[1:]) <= 14:
return False
return True
return False
def _remove_das_equal_dp(dossier: DossierMedical) -> None:
"""Retire les DAS dont le code CIM-10 est identique au DP (violation règle PMSI)."""
"""Retire les DAS redondants avec le DP (même code, famille, ou sémantique)."""
from .das_filter import apply_semantic_dedup
dp_code = dossier.diagnostic_principal.cim10_suggestion if dossier.diagnostic_principal else None
if not dp_code:
return
before = len(dossier.diagnostics_associes)
dossier.diagnostics_associes = [
d for d in dossier.diagnostics_associes if d.cim10_suggestion != dp_code
d for d in dossier.diagnostics_associes
if not d.cim10_suggestion or not _is_dp_family_redundant(d.cim10_suggestion, dp_code)
]
removed = before - len(dossier.diagnostics_associes)
if removed:
logger.info(" DAS=DP : %d DAS retiré(s) (code %s identique au DP)", removed, dp_code)
logger.info(" DASDP : %d DAS retiré(s) (famille %s du DP)", removed, dp_code[:3])
# Redondances sémantiques entre DAS
dossier.diagnostics_associes = apply_semantic_dedup(dossier.diagnostics_associes)
def _apply_noncumul_rules(dossier: DossierMedical) -> None:
@@ -945,3 +989,33 @@ def _is_abnormal(test: str, value: str) -> bool | None:
lo, hi = BIO_NORMALS[test]
return val > hi or val < lo
return None
def _apply_source_tracking(dossier: DossierMedical, page_tracker, search_text: str) -> None:
"""Ajoute la traçabilité source (page + extrait) à chaque diagnostic.
Cherche le texte du diagnostic dans le texte source pour retrouver
la page d'origine et extraire un passage contextualisé.
"""
all_diags: list[Diagnostic] = []
if dossier.diagnostic_principal:
all_diags.append(dossier.diagnostic_principal)
all_diags.extend(dossier.diagnostics_associes)
tracked = 0
for diag in all_diags:
if diag.source_page is not None:
continue # déjà renseigné
texte = diag.texte
if not texte:
continue
page = page_tracker.find_page_for_text(texte, search_text)
if page:
diag.source_page = page
diag.source_excerpt = page_tracker.extract_excerpt(texte, search_text)
tracked += 1
if tracked:
logger.info(" Traçabilité source : %d/%d diagnostics localisés", tracked, len(all_diags))

View File

@@ -100,6 +100,44 @@ def is_valid_diagnostic_text(text: str) -> bool:
return True
# Paires de redondance sémantique CIM-10 en PMSI
# Format: (dominated_prefix, dominant_prefixes)
# Si un code commençant par dominated_prefix ET un code commençant par un dominant_prefix
# sont tous deux en DAS, le dominated est supprimé.
SEMANTIC_REDUNDANCIES: list[tuple[str, list[str]]] = [
# I10 (HTA essentielle) redondant si I11/I12/I13 présent (cardio/néphropathie hypertensive)
("I10", ["I11", "I12", "I13"]),
# N30 (cystite) redondant si N39.0 présent (infection urinaire)
("N30", ["N39"]),
# J18 (pneumonie SAI) redondant si J15/J16 présent (pneumonie spécifique)
("J18", ["J15", "J16"]),
]
def apply_semantic_dedup(das_list: list) -> list:
"""Retire les DAS rendus redondants par la présence d'un code plus spécifique.
Utilise SEMANTIC_REDUNDANCIES pour déterminer les paires dominé/dominant.
Accepte une liste de Diagnostic (avec attribut cim10_suggestion).
"""
codes_present = {d.cim10_suggestion for d in das_list if d.cim10_suggestion}
to_remove: set[str] = set()
for dominated_prefix, dominant_prefixes in SEMANTIC_REDUNDANCIES:
dominated_codes = [c for c in codes_present if c.startswith(dominated_prefix)]
if not dominated_codes:
continue
has_dominant = any(
c.startswith(dp) for c in codes_present for dp in dominant_prefixes
)
if has_dominant:
to_remove.update(dominated_codes)
if not to_remove:
return das_list
return [d for d in das_list if d.cim10_suggestion not in to_remove]
def correct_known_miscodes(code: str, texte: str) -> str | None:
"""Corrige les codes CIM-10 systématiquement mal attribués par le LLM.

View File

@@ -17,6 +17,8 @@ from ..config import (
Sejour,
Traitement,
)
from ..medical.das_filter import is_valid_diagnostic_text, apply_semantic_dedup
from ..medical.cim10_extractor import _is_dp_family_redundant
logger = logging.getLogger(__name__)
@@ -163,6 +165,14 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
if len(dossiers) == 1:
result = dossiers[0].model_copy(deep=True)
result.source_files = [result.source_file]
# Appliquer la dédup famille DP + sémantique même pour un seul dossier
dp_code = result.diagnostic_principal.cim10_suggestion if result.diagnostic_principal else None
if dp_code:
result.diagnostics_associes = [
d for d in result.diagnostics_associes
if not d.cim10_suggestion or not _is_dp_family_redundant(d.cim10_suggestion, dp_code)
]
result.diagnostics_associes = apply_semantic_dedup(result.diagnostics_associes)
return result
merged = DossierMedical()
@@ -181,23 +191,29 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
for d in dossiers:
all_das.extend(d.diagnostics_associes)
# Si le DP de ce dossier est différent du DP fusionné, l'ajouter comme DAS
# mais seulement si le texte est un diagnostic valide (filtre artefacts OCR)
if (
d.diagnostic_principal
and merged.diagnostic_principal
and d.diagnostic_principal.cim10_suggestion
!= merged.diagnostic_principal.cim10_suggestion
and is_valid_diagnostic_text(d.diagnostic_principal.texte)
):
all_das.append(d.diagnostic_principal)
merged.diagnostics_associes = _dedup_diagnostics(all_das)
# Retirer les DAS dont le code est identique au DP (violation règle PMSI)
# Retirer les DAS redondants avec le DP (même code, famille, parent/enfant)
dp_code = merged.diagnostic_principal.cim10_suggestion if merged.diagnostic_principal else None
if dp_code:
merged.diagnostics_associes = [
d for d in merged.diagnostics_associes if d.cim10_suggestion != dp_code
d for d in merged.diagnostics_associes
if not d.cim10_suggestion or not _is_dp_family_redundant(d.cim10_suggestion, dp_code)
]
# Redondances sémantiques entre DAS
merged.diagnostics_associes = apply_semantic_dedup(merged.diagnostics_associes)
# Actes CCAM
all_actes: list[ActeCCAM] = []
for d in dossiers:

View File

@@ -141,19 +141,29 @@ def _detect_type_ghm(actes_ccam: list) -> str:
def _compute_severity(das_list: list) -> tuple[int, int, int]:
"""Calcule le niveau de sévérité à partir des DAS.
Utilise le max des niveau_cma officiels ATIH quand disponibles,
avec fallback sur le comptage CMA/CMS.
Returns:
(niveau, cma_count, cms_count)
"""
cma_count = 0
cms_count = 0
max_cma_level = 1
for das in das_list:
niveau_cma = getattr(das, "niveau_cma", None)
if niveau_cma and niveau_cma > 1:
max_cma_level = max(max_cma_level, niveau_cma)
if getattr(das, "est_cma", False):
cma_count += 1
if getattr(das, "est_cms", False):
cms_count += 1
if cms_count >= 2:
# Priorité au niveau CMA officiel ATIH
if max_cma_level > 1:
niveau = max_cma_level
elif cms_count >= 2:
niveau = 4
elif cms_count >= 1 or cma_count >= 3:
niveau = 3

View File

@@ -34,12 +34,12 @@ def _get_anthropic_client():
return None
def _call_anthropic(
def call_anthropic(
prompt: str,
temperature: float = 0.1,
max_tokens: int = 2500,
) -> dict | None:
"""Appelle l'API Anthropic en fallback."""
"""Appelle l'API Anthropic (Haiku)."""
client = _get_anthropic_client()
if client is None:
return None
@@ -82,6 +82,8 @@ def call_ollama(
prompt: str,
temperature: float = 0.1,
max_tokens: int = 2500,
model: str | None = None,
timeout: int | None = None,
) -> dict | None:
"""Appelle Ollama en mode JSON natif, avec fallback Anthropic si indisponible.
@@ -89,16 +91,20 @@ 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).
timeout: Timeout en secondes (défaut: OLLAMA_TIMEOUT global).
Returns:
Le dict JSON parsé, ou None en cas d'erreur.
"""
use_model = model or OLLAMA_MODEL
use_timeout = timeout or OLLAMA_TIMEOUT
for attempt in range(2):
try:
response = requests.post(
f"{OLLAMA_URL}/api/generate",
json={
"model": OLLAMA_MODEL,
"model": use_model,
"prompt": prompt,
"stream": False,
"format": "json",
@@ -107,7 +113,7 @@ def call_ollama(
"num_predict": max_tokens,
},
},
timeout=OLLAMA_TIMEOUT,
timeout=use_timeout,
)
response.raise_for_status()
raw = response.json().get("response", "")
@@ -115,13 +121,14 @@ def call_ollama(
if result is not None:
return result
if attempt == 0:
logger.info("Ollama : retry après échec de parsing")
logger.info("Ollama (%s) : retry après échec de parsing", use_model)
except requests.ConnectionError:
logger.info("Ollama indisponible → fallback Anthropic (%s)", _ANTHROPIC_MODEL)
return _call_anthropic(prompt, temperature, max_tokens)
return call_anthropic(prompt, temperature, max_tokens)
except requests.Timeout:
logger.warning("Ollama timeout après %ds → fallback Anthropic", OLLAMA_TIMEOUT)
return _call_anthropic(prompt, temperature, max_tokens)
logger.warning("Ollama (%s) timeout après %ds → fallback Anthropic",
use_model, use_timeout)
return call_anthropic(prompt, temperature, max_tokens)
except (requests.RequestException, json.JSONDecodeError) as e:
logger.warning("Ollama erreur : %s", e)
return None

View File

@@ -6,12 +6,16 @@ Phase 2 (future) : tables CMA/CMS officielles ATIH.
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Optional
from .cim10_dict import load_dict, normalize_text
logger = logging.getLogger(__name__)
# --- Marqueurs de sévérité dans le texte ---
@@ -73,11 +77,34 @@ _HEURISTIC_CMA_ROOTS: set[str] = {
}
_cma_levels: dict[str, int] | None = None
def _load_cma_levels() -> dict[str, int]:
"""Charge les niveaux CMA officiels depuis data/cma_levels.json (lazy-loaded)."""
global _cma_levels
if _cma_levels is not None:
return _cma_levels
from ..config import CMA_LEVELS_PATH
try:
data = json.loads(CMA_LEVELS_PATH.read_text(encoding="utf-8"))
_cma_levels = {k: int(v) for k, v in data.items()}
logger.debug("CMA levels chargés : %d codes", len(_cma_levels))
except FileNotFoundError:
logger.warning("Fichier CMA levels non trouvé : %s", CMA_LEVELS_PATH)
_cma_levels = {}
except Exception:
logger.warning("Erreur chargement CMA levels", exc_info=True)
_cma_levels = {}
return _cma_levels
@dataclass
class SeverityInfo:
"""Résultat de l'évaluation de sévérité d'un diagnostic."""
est_cma_probable: bool = False
niveau_severite: str = "non_evalue" # "leger" | "modere" | "severe" | "non_evalue"
niveau_cma: int = 1 # 1 (pas CMA), 2, 3 ou 4 (officiel ATIH)
marqueurs_trouves: list[str] = field(default_factory=list)
@@ -119,11 +146,14 @@ def _is_heuristic_cma(code: str) -> bool:
def evaluate_severity(diagnostic) -> SeverityInfo:
"""Évalue la sévérité d'un diagnostic (texte + code CIM-10).
Utilise en priorité les niveaux CMA officiels ATIH (2/3/4),
avec fallback sur l'heuristique par racines CIM-10.
Args:
diagnostic: Objet avec attributs texte, cim10_suggestion.
Returns:
SeverityInfo avec est_cma_probable, niveau_severite, marqueurs_trouves.
SeverityInfo avec est_cma_probable, niveau_cma, niveau_severite, marqueurs_trouves.
"""
info = SeverityInfo()
@@ -147,13 +177,17 @@ def evaluate_severity(diagnostic) -> SeverityInfo:
info.niveau_severite = niveau
info.marqueurs_trouves = marqueurs
# 3. Heuristique CMA basée sur la racine CIM-10
if code and _is_heuristic_cma(code):
info.est_cma_probable = True
# Un diagnostic sévère avec un code CMA-probable = forte indication
if niveau == "severe" and info.est_cma_probable:
info.est_cma_probable = True
# 3. Lookup officiel CMA ATIH (prioritaire)
if code:
cma_levels = _load_cma_levels()
official_level = cma_levels.get(code)
if official_level:
info.niveau_cma = official_level
info.est_cma_probable = True
elif _is_heuristic_cma(code):
# Fallback heuristique → niveau 2
info.niveau_cma = 2
info.est_cma_probable = True
return info
@@ -176,6 +210,7 @@ def enrich_dossier_severity(dp, das_list: list) -> tuple[list[str], int, int]:
if dp and dp.cim10_suggestion:
info = evaluate_severity(dp)
dp.niveau_severite = info.niveau_severite
dp.niveau_cma = info.niveau_cma
if info.est_cma_probable:
dp.est_cma = True
@@ -187,15 +222,16 @@ def enrich_dossier_severity(dp, das_list: list) -> tuple[list[str], int, int]:
continue
info = evaluate_severity(das)
das.niveau_severite = info.niveau_severite
das.niveau_cma = info.niveau_cma
if info.est_cma_probable:
das.est_cma = True
cma_count += 1
# CMS = CMA sévère
if info.niveau_severite == "severe":
# CMS = CMA niveau 4 ou CMA sévère
if info.niveau_cma >= 4 or info.niveau_severite == "severe":
das.est_cms = True
cms_count += 1
alertes.append(
f"CMA probable : '{das.texte}' ({das.cim10_suggestion}) — "
f"CMA niveau {info.niveau_cma} : '{das.texte}' ({das.cim10_suggestion}) — "
f"sévérité {info.niveau_severite}"
+ (f", marqueurs : {', '.join(info.marqueurs_trouves)}" if info.marqueurs_trouves else "")
)

View File

@@ -305,6 +305,13 @@ _SEVERITY_STYLES = {
"leger": ("Léger", "#065f46", "#d1fae5"),
}
_CMA_LEVEL_STYLES = {
1: ("1", "#6b7280", "#f3f4f6"), # gris — pas CMA
2: ("2", "#065f46", "#d1fae5"), # vert
3: ("3", "#92400e", "#fef3c7"), # jaune/orange
4: ("4", "#dc2626", "#fee2e2"), # rouge
}
def format_duration(seconds: float | None) -> str:
"""Formate une durée en secondes vers un format lisible (ex: 2min 30s)."""
@@ -330,13 +337,24 @@ def severity_badge(value: str | None) -> Markup:
)
def cma_level_badge(value: int | None) -> Markup:
"""Badge CMA niveau 1-4 avec couleurs graduées."""
if value is None or value < 1:
return Markup("")
level = min(value, 4)
label, fg, bg = _CMA_LEVEL_STYLES.get(level, _CMA_LEVEL_STYLES[1])
title = {1: "Pas CMA", 2: "CMA niveau 2", 3: "CMA niveau 3", 4: "CMA niveau 4"}.get(level, "")
return Markup(
f'<span title="{title}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">'
f'CMA {label}</span>'
)
def format_dossier_name(name: str) -> str:
"""Transforme un nom de dossier en nom lisible (ex: 15_23096332 → Dossier 23096332)."""
"""Retourne le nom complet du dossier (ex: 1_23096332)."""
if name == "racine":
return "Non classés"
m = re.match(r"^\d+_(\d+)$", name)
if m:
return f"Dossier {m.group(1)}"
return name
@@ -364,6 +382,7 @@ def create_app() -> Flask:
app.jinja_env.filters["confidence_badge"] = confidence_badge
app.jinja_env.filters["confidence_label"] = confidence_label
app.jinja_env.filters["severity_badge"] = severity_badge
app.jinja_env.filters["cma_level_badge"] = cma_level_badge
app.jinja_env.filters["format_duration"] = format_duration
app.jinja_env.filters["format_dossier_name"] = format_dossier_name
app.jinja_env.filters["format_doc_name"] = format_doc_name
@@ -445,13 +464,16 @@ def create_app() -> Flask:
return jsonify({"error": f"PDF source '{source_file}' introuvable"}), 404
try:
anonymized_text, new_dossier, report = process_pdf(pdf_path)
pdf_results = process_pdf(pdf_path)
stem = pdf_path.stem.replace(" ", "_")
subdir = None
if pdf_path.parent != input_dir:
subdir = pdf_path.parent.name
write_outputs(stem, anonymized_text, new_dossier, report, subdir=subdir)
return jsonify({"ok": True, "message": "Traitement terminé"})
multi = len(pdf_results) > 1
for part_idx, (anonymized_text, new_dossier, report) in enumerate(pdf_results):
part_stem = f"{stem}_part{part_idx + 1}" if multi else stem
write_outputs(part_stem, anonymized_text, new_dossier, report, subdir=subdir)
return jsonify({"ok": True, "message": f"Traitement terminé ({len(pdf_results)} dossier(s))"})
except Exception as e:
logger.exception("Erreur lors du retraitement")
return jsonify({"error": str(e)}), 500

View File

@@ -18,8 +18,8 @@
.sidebar {
width: 280px;
min-width: 280px;
background: #0f172a;
color: #cbd5e1;
background: #1e293b;
color: #e2e8f0;
display: flex;
flex-direction: column;
position: fixed;
@@ -30,16 +30,16 @@
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid #1e293b;
border-bottom: 1px solid #334155;
}
.sidebar-header h1 {
font-size: 1.1rem;
color: #e2e8f0;
color: #f1f5f9;
font-weight: 700;
}
.sidebar-header p {
font-size: 0.75rem;
color: #64748b;
color: #94a3b8;
margin-top: 0.25rem;
}
.sidebar-nav {
@@ -52,15 +52,16 @@
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
color: #94a3b8;
font-weight: 700;
}
.sidebar-nav a {
display: block;
padding: 0.4rem 1rem;
color: #94a3b8;
color: #cbd5e1;
text-decoration: none;
font-size: 0.8rem;
font-weight: 600;
border-left: 3px solid transparent;
transition: all 0.15s;
white-space: nowrap;
@@ -68,45 +69,45 @@
text-overflow: ellipsis;
}
.sidebar-nav a:hover {
color: #e2e8f0;
background: #1e293b;
color: #f8fafc;
background: #334155;
border-left-color: #3b82f6;
}
.sidebar-nav a.sidebar-fusionne {
color: #60a5fa;
font-weight: 600;
font-weight: 700;
}
/* Search */
.sidebar-search {
padding: 0.75rem 1rem 0.5rem;
border-bottom: 1px solid #1e293b;
border-bottom: 1px solid #334155;
}
.sidebar-search input {
width: 100%;
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid #334155;
background: #1e293b;
border: 1px solid #475569;
background: #0f172a;
color: #e2e8f0;
font-size: 0.8rem;
outline: none;
transition: border-color 0.15s;
}
.sidebar-search input::placeholder { color: #475569; }
.sidebar-search input::placeholder { color: #64748b; }
.sidebar-search input:focus { border-color: #3b82f6; }
/* Admin section */
.sidebar-admin {
padding: 1rem;
border-top: 1px solid #1e293b;
border-top: 1px solid #334155;
font-size: 0.8rem;
}
.sidebar-admin label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
color: #94a3b8;
color: #cbd5e1;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -115,8 +116,8 @@
width: 100%;
padding: 0.4rem;
border-radius: 6px;
border: 1px solid #334155;
background: #1e293b;
border: 1px solid #475569;
background: #0f172a;
color: #e2e8f0;
font-size: 0.8rem;
margin-bottom: 0.5rem;
@@ -145,7 +146,6 @@
margin-left: 280px;
flex: 1;
padding: 2rem;
max-width: 1100px;
}
/* Utilities */
@@ -249,17 +249,17 @@
<nav class="sidebar-nav" id="sidebar-nav">
{% block sidebar %}{% endblock %}
</nav>
<div class="sidebar-admin" style="border-top:1px solid #1e293b;padding:0.5rem 1rem;">
<a href="/dashboard" style="display:block;color:#94a3b8;text-decoration:none;font-size:0.8rem;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#e2e8f0'" onmouseout="this.style.color='#94a3b8'">
<div class="sidebar-admin" style="border-top:1px solid #334155;padding:0.5rem 1rem;">
<a href="/dashboard" style="display:block;color:#cbd5e1;text-decoration:none;font-size:0.8rem;font-weight:600;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#f8fafc'" onmouseout="this.style.color='#cbd5e1'">
Dashboard
</a>
<a href="/cpam" style="display:block;color:#94a3b8;text-decoration:none;font-size:0.8rem;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#e2e8f0'" onmouseout="this.style.color='#94a3b8'">
<a href="/cpam" style="display:block;color:#cbd5e1;text-decoration:none;font-size:0.8rem;font-weight:600;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#f8fafc'" onmouseout="this.style.color='#cbd5e1'">
Contrôles CPAM
</a>
<a href="/admin/referentiels" style="display:block;color:#94a3b8;text-decoration:none;font-size:0.8rem;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#e2e8f0'" onmouseout="this.style.color='#94a3b8'">
<a href="/admin/referentiels" style="display:block;color:#cbd5e1;text-decoration:none;font-size:0.8rem;font-weight:600;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#f8fafc'" onmouseout="this.style.color='#cbd5e1'">
Référentiels RAG
</a>
</div>

View File

@@ -7,7 +7,7 @@
{% if siblings %}
<div class="group-title" style="margin-top:1rem;">{{ current_group }}</div>
{% for sib in siblings %}
<a href="/dossier/{{ sib.path_rel }}" {% if sib.path_rel == filepath %}style="color:#e2e8f0;border-left-color:#3b82f6;background:#1e293b;"{% endif %}>
<a href="/dossier/{{ sib.path_rel }}" {% if sib.path_rel == filepath %}style="color:#f8fafc;border-left-color:#3b82f6;background:#334155;"{% endif %}>
{{ sib.name }}
</a>
{% endfor %}
@@ -213,7 +213,11 @@
{% if dp.cim10_suggestion %}
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
{{ dp.cim10_confidence | confidence_badge }}
{% if dp.est_cma %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">CMA</span>{% endif %}
{% if dp.niveau_cma and dp.niveau_cma > 1 %}
{{ dp.niveau_cma | cma_level_badge }}
{% elif dp.est_cma %}
<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">CMA</span>
{% endif %}
{{ dp.niveau_severite | severity_badge }}
{% endif %}
{% if dp.justification %}
@@ -242,22 +246,40 @@
<div class="card section">
<h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3>
<table>
<thead><tr><th>Texte</th><th>CIM-10</th><th>Confiance</th><th>Sévérité</th><th>Justification</th></tr></thead>
<thead><tr><th>Texte</th><th>CIM-10</th><th>Confiance</th><th>CMA</th><th>Source</th><th>Justification</th></tr></thead>
<tbody>
{% for das in dossier.diagnostics_associes %}
<tr>
<td>
{{ das.texte }}
{% if das.est_cma %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.7rem;margin-left:0.3rem;">CMA</span>{% endif %}
</td>
<td>{{ das.texte }}</td>
<td>{% if das.cim10_suggestion %}<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ das.cim10_suggestion }}</span>{% endif %}</td>
<td>{{ das.cim10_confidence | confidence_badge }}</td>
<td>{{ das.niveau_severite | severity_badge }}</td>
<td>
{% if das.niveau_cma and das.niveau_cma > 1 %}
{{ das.niveau_cma | cma_level_badge }}
{% elif das.est_cma %}
<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.7rem;">CMA</span>
{% else %}
{% endif %}
</td>
<td>
{% if das.source %}
<span class="badge" style="background:#e0e7ff;color:#3730a3;font-size:0.7rem;">{{ das.source }}</span>
{% endif %}
{% if das.source_page %}
<span style="font-size:0.7rem;color:#64748b;">p.{{ das.source_page }}</span>
{% endif %}
{% if das.source_excerpt %}
<details style="margin-top:0.2rem;"><summary style="font-size:0.7rem;color:#94a3b8;cursor:pointer;">extrait</summary>
<pre style="font-size:0.7rem;white-space:pre-wrap;max-width:300px;color:#475569;">{{ das.source_excerpt }}</pre>
</details>
{% endif %}
</td>
<td style="font-size:0.8rem;color:#475569;">{{ das.justification or '' }}</td>
</tr>
{% if das.raisonnement %}
<tr>
<td colspan="5" style="padding:0 0.75rem 0.5rem;">
<td colspan="6" style="padding:0 0.75rem 0.5rem;">
<details>
<summary>Raisonnement LLM</summary>
<pre>{{ das.raisonnement }}</pre>
@@ -267,7 +289,7 @@
{% endif %}
{% if das.sources_rag %}
<tr>
<td colspan="5" style="padding:0 0.75rem 0.5rem;">
<td colspan="6" style="padding:0 0.75rem 0.5rem;">
<details>
<summary>Sources RAG ({{ das.sources_rag|length }})</summary>
{% for src in das.sources_rag %}