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_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:12b") 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 = 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_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"))
@@ -55,6 +57,7 @@ UPLOAD_MAX_SIZE_MB = 50
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"} ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json" CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.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" 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")) 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")) 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_cma: Optional[bool] = None
est_cms: Optional[bool] = None est_cms: Optional[bool] = None
niveau_severite: Optional[str] = None # "leger" | "modere" | "severe" | "non_evalue" 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: 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): class ActeCCAM(BaseModel):

View File

@@ -4,8 +4,8 @@ from __future__ import annotations
import logging import logging
from ..config import ControleCPAM, DossierMedical, RAGSource from ..config import ControleCPAM, DossierMedical, RAGSource, OLLAMA_MODEL_CPAM, OLLAMA_TIMEOUT_CPAM
from ..medical.ollama_client import call_ollama from ..medical.ollama_client import call_anthropic, call_ollama
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -244,33 +244,84 @@ CONSIGNES :
AXE MÉDICAL : AXE MÉDICAL :
- Analyse le bien-fondé médical du codage de l'établissement - 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 - Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
- Identifie les points où la CPAM a éventuellement raison - 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 : AXE ASYMÉTRIE D'INFORMATION :
- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis - 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, cite les VALEURS EXACTES et explique leur signification clinique
- Pour chaque élément clinique pertinent, explique pourquoi il invalide ou nuance l'argumentation CPAM - 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 : AXE RÉGLEMENTAIRE :
- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle - 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 - 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 - 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 : Réponds UNIQUEMENT avec un objet JSON au format suivant :
{{ {{
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base", "analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
"points_accord": "Points où la CPAM a raison (ou 'Aucun')", "points_accord": "Points où la CPAM a raison (ou 'Aucun')",
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage", "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_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", "contre_arguments_reglementaires": "Erreurs d'interprétation réglementaire de la CPAM, avec citations verbatim des sources",
"references": "Références EXACTES tirées des sources fournies (document, page, code)", "references": [
{{"document": "nom du document source", "page": "numéro de page", "citation": "citation verbatim du passage"}}
],
"conclusion": "Synthèse et position recommandée" "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.""" """Formate la réponse LLM en texte lisible."""
sections = [] sections = []
@@ -287,6 +338,19 @@ def _format_response(parsed: dict) -> str:
if contre_med: if contre_med:
sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{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") contre_asym = parsed.get("contre_arguments_asymetrie")
if contre_asym: if contre_asym:
sections.append(f"ASYMÉTRIE D'INFORMATION\n{contre_asym}") sections.append(f"ASYMÉTRIE D'INFORMATION\n{contre_asym}")
@@ -301,14 +365,33 @@ def _format_response(parsed: dict) -> str:
if contre: if contre:
sections.append(f"CONTRE-ARGUMENTS\n{contre}") sections.append(f"CONTRE-ARGUMENTS\n{contre}")
# Références structurées (nouveau format liste) ou ancien format string
refs = parsed.get("references") refs = parsed.get("references")
if refs: if 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}") sections.append(f"REFERENCES\n{refs}")
conclusion = parsed.get("conclusion") conclusion = parsed.get("conclusion")
if conclusion: if conclusion:
sections.append(f"CONCLUSION\n{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) return "\n\n".join(sections)
@@ -335,7 +418,23 @@ def generate_cpam_response(
# 2. Construction du prompt # 2. Construction du prompt
prompt = _build_cpam_prompt(dossier, controle, sources) prompt = _build_cpam_prompt(dossier, controle, sources)
# 3. Appel Ollama # 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) result = call_ollama(prompt, temperature=0.1, max_tokens=3000)
# 4. Conversion des sources RAG # 4. Conversion des sources RAG
@@ -350,11 +449,16 @@ def generate_cpam_response(
] ]
if result is None: 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 return "", rag_sources
# 5. Formater la réponse # 5. Validation des références
text = _format_response(result) 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)) logger.info(" Contre-argumentation générée (%d caractères)", len(text))
return text, rag_sources 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 __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Optional
import pdfplumber import pdfplumber
from .page_tracker import PageTracker
def extract_text(pdf_path: str | Path) -> str: def extract_text(pdf_path: str | Path) -> str:
"""Extrait le texte de toutes les pages d'un PDF.""" """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) 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]: def extract_pages(pdf_path: str | Path) -> list[str]:
"""Extrait le texte page par page.""" """Extrait le texte page par page."""
pages: list[str] = [] 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.document_classifier import classify
from .extraction.crh_parser import parse_crh from .extraction.crh_parser import parse_crh
from .extraction.document_splitter import split_documents 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 .extraction.trackare_parser import parse_trackare
from .medical.cim10_extractor import extract_medical_info from .medical.cim10_extractor import extract_medical_info
from .medical.ghm import estimate_ghm from .medical.ghm import estimate_ghm
@@ -38,8 +38,8 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
t0 = time.time() t0 = time.time()
logger.info("Traitement de %s", pdf_path.name) logger.info("Traitement de %s", pdf_path.name)
# 1. Extraction texte # 1. Extraction texte avec pages
raw_text = extract_text(pdf_path) raw_text, page_tracker = extract_text_with_pages(pdf_path)
logger.info(" Texte extrait : %d caractères", len(raw_text)) logger.info(" Texte extrait : %d caractères", len(raw_text))
# 2. Classification # 2. Classification
@@ -82,7 +82,10 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization
edsnlp_result = _run_edsnlp(anonymized_text) edsnlp_result = _run_edsnlp(anonymized_text)
# 7. Extraction médicale CIM-10 # 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.source_file = pdf_path.name
dossier.document_type = doc_type dossier.document_type = doc_type
logger.info(" DP%s : %s", part_label, dossier.diagnostic_principal) logger.info(" DP%s : %s", part_label, dossier.diagnostic_principal)

View File

@@ -98,11 +98,21 @@ def extract_medical_info(
anonymized_text: str, anonymized_text: str,
edsnlp_result: Optional[EdsnlpResult] = None, edsnlp_result: Optional[EdsnlpResult] = None,
use_rag: bool = False, use_rag: bool = False,
page_tracker=None,
raw_text: str | None = None,
) -> DossierMedical: ) -> 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 = DossierMedical()
dossier.document_type = parsed_data.get("type", "") 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_sejour(parsed_data, dossier)
_extract_diagnostics(parsed_data, anonymized_text, dossier, edsnlp_result) _extract_diagnostics(parsed_data, anonymized_text, dossier, edsnlp_result)
_extract_actes(anonymized_text, dossier) _extract_actes(anonymized_text, dossier)
@@ -140,6 +150,10 @@ def extract_medical_info(
# Post-processing : retirer DAS dont le code est identique au DP # Post-processing : retirer DAS dont le code est identique au DP
_remove_das_equal_dp(dossier) _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 return dossier
@@ -331,8 +345,10 @@ def _extract_diagnostics(
elif edsnlp_codes: elif edsnlp_codes:
# Utiliser la première entité CIM-10 edsnlp comme DP # Utiliser la première entité CIM-10 edsnlp comme DP
code, texte = next(iter(edsnlp_codes.items())) code, texte = next(iter(edsnlp_codes.items()))
texte_clean = texte.capitalize()
if is_valid_diagnostic_text(texte_clean):
dossier.diagnostic_principal = Diagnostic( dossier.diagnostic_principal = Diagnostic(
texte=texte.capitalize(), cim10_suggestion=code, texte=texte_clean, cim10_suggestion=code,
source="edsnlp", source="edsnlp",
) )
@@ -881,18 +897,46 @@ def _apply_code_corrections(dossier: DossierMedical) -> None:
diag.cim10_suggestion = corrected 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: 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 dp_code = dossier.diagnostic_principal.cim10_suggestion if dossier.diagnostic_principal else None
if not dp_code: if not dp_code:
return return
before = len(dossier.diagnostics_associes) before = len(dossier.diagnostics_associes)
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) removed = before - len(dossier.diagnostics_associes)
if removed: 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: 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] lo, hi = BIO_NORMALS[test]
return val > hi or val < lo return val > hi or val < lo
return None 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 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: def correct_known_miscodes(code: str, texte: str) -> str | None:
"""Corrige les codes CIM-10 systématiquement mal attribués par le LLM. """Corrige les codes CIM-10 systématiquement mal attribués par le LLM.

View File

@@ -17,6 +17,8 @@ from ..config import (
Sejour, Sejour,
Traitement, 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__) logger = logging.getLogger(__name__)
@@ -163,6 +165,14 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
if len(dossiers) == 1: if len(dossiers) == 1:
result = dossiers[0].model_copy(deep=True) result = dossiers[0].model_copy(deep=True)
result.source_files = [result.source_file] 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 return result
merged = DossierMedical() merged = DossierMedical()
@@ -181,23 +191,29 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
for d in dossiers: for d in dossiers:
all_das.extend(d.diagnostics_associes) all_das.extend(d.diagnostics_associes)
# Si le DP de ce dossier est différent du DP fusionné, l'ajouter comme DAS # 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 ( if (
d.diagnostic_principal d.diagnostic_principal
and merged.diagnostic_principal and merged.diagnostic_principal
and d.diagnostic_principal.cim10_suggestion and d.diagnostic_principal.cim10_suggestion
!= merged.diagnostic_principal.cim10_suggestion != merged.diagnostic_principal.cim10_suggestion
and is_valid_diagnostic_text(d.diagnostic_principal.texte)
): ):
all_das.append(d.diagnostic_principal) all_das.append(d.diagnostic_principal)
merged.diagnostics_associes = _dedup_diagnostics(all_das) 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 dp_code = merged.diagnostic_principal.cim10_suggestion if merged.diagnostic_principal else None
if dp_code: if dp_code:
merged.diagnostics_associes = [ 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 # Actes CCAM
all_actes: list[ActeCCAM] = [] all_actes: list[ActeCCAM] = []
for d in dossiers: 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]: def _compute_severity(das_list: list) -> tuple[int, int, int]:
"""Calcule le niveau de sévérité à partir des DAS. """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: Returns:
(niveau, cma_count, cms_count) (niveau, cma_count, cms_count)
""" """
cma_count = 0 cma_count = 0
cms_count = 0 cms_count = 0
max_cma_level = 1
for das in das_list: 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): if getattr(das, "est_cma", False):
cma_count += 1 cma_count += 1
if getattr(das, "est_cms", False): if getattr(das, "est_cms", False):
cms_count += 1 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 niveau = 4
elif cms_count >= 1 or cma_count >= 3: elif cms_count >= 1 or cma_count >= 3:
niveau = 3 niveau = 3

View File

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

View File

@@ -6,12 +6,16 @@ Phase 2 (future) : tables CMA/CMS officielles ATIH.
from __future__ import annotations from __future__ import annotations
import json
import logging
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from .cim10_dict import load_dict, normalize_text from .cim10_dict import load_dict, normalize_text
logger = logging.getLogger(__name__)
# --- Marqueurs de sévérité dans le texte --- # --- 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 @dataclass
class SeverityInfo: class SeverityInfo:
"""Résultat de l'évaluation de sévérité d'un diagnostic.""" """Résultat de l'évaluation de sévérité d'un diagnostic."""
est_cma_probable: bool = False est_cma_probable: bool = False
niveau_severite: str = "non_evalue" # "leger" | "modere" | "severe" | "non_evalue" 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) marqueurs_trouves: list[str] = field(default_factory=list)
@@ -119,11 +146,14 @@ def _is_heuristic_cma(code: str) -> bool:
def evaluate_severity(diagnostic) -> SeverityInfo: def evaluate_severity(diagnostic) -> SeverityInfo:
"""Évalue la sévérité d'un diagnostic (texte + code CIM-10). """É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: Args:
diagnostic: Objet avec attributs texte, cim10_suggestion. diagnostic: Objet avec attributs texte, cim10_suggestion.
Returns: Returns:
SeverityInfo avec est_cma_probable, niveau_severite, marqueurs_trouves. SeverityInfo avec est_cma_probable, niveau_cma, niveau_severite, marqueurs_trouves.
""" """
info = SeverityInfo() info = SeverityInfo()
@@ -147,12 +177,16 @@ def evaluate_severity(diagnostic) -> SeverityInfo:
info.niveau_severite = niveau info.niveau_severite = niveau
info.marqueurs_trouves = marqueurs info.marqueurs_trouves = marqueurs
# 3. Heuristique CMA basée sur la racine CIM-10 # 3. Lookup officiel CMA ATIH (prioritaire)
if code and _is_heuristic_cma(code): 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 info.est_cma_probable = True
elif _is_heuristic_cma(code):
# Un diagnostic sévère avec un code CMA-probable = forte indication # Fallback heuristique → niveau 2
if niveau == "severe" and info.est_cma_probable: info.niveau_cma = 2
info.est_cma_probable = True info.est_cma_probable = True
return info 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: if dp and dp.cim10_suggestion:
info = evaluate_severity(dp) info = evaluate_severity(dp)
dp.niveau_severite = info.niveau_severite dp.niveau_severite = info.niveau_severite
dp.niveau_cma = info.niveau_cma
if info.est_cma_probable: if info.est_cma_probable:
dp.est_cma = True dp.est_cma = True
@@ -187,15 +222,16 @@ def enrich_dossier_severity(dp, das_list: list) -> tuple[list[str], int, int]:
continue continue
info = evaluate_severity(das) info = evaluate_severity(das)
das.niveau_severite = info.niveau_severite das.niveau_severite = info.niveau_severite
das.niveau_cma = info.niveau_cma
if info.est_cma_probable: if info.est_cma_probable:
das.est_cma = True das.est_cma = True
cma_count += 1 cma_count += 1
# CMS = CMA sévère # CMS = CMA niveau 4 ou CMA sévère
if info.niveau_severite == "severe": if info.niveau_cma >= 4 or info.niveau_severite == "severe":
das.est_cms = True das.est_cms = True
cms_count += 1 cms_count += 1
alertes.append( 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"sévérité {info.niveau_severite}"
+ (f", marqueurs : {', '.join(info.marqueurs_trouves)}" if info.marqueurs_trouves else "") + (f", marqueurs : {', '.join(info.marqueurs_trouves)}" if info.marqueurs_trouves else "")
) )

View File

@@ -305,6 +305,13 @@ _SEVERITY_STYLES = {
"leger": ("Léger", "#065f46", "#d1fae5"), "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: def format_duration(seconds: float | None) -> str:
"""Formate une durée en secondes vers un format lisible (ex: 2min 30s).""" """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: 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": if name == "racine":
return "Non classés" return "Non classés"
m = re.match(r"^\d+_(\d+)$", name)
if m:
return f"Dossier {m.group(1)}"
return name return name
@@ -364,6 +382,7 @@ def create_app() -> Flask:
app.jinja_env.filters["confidence_badge"] = confidence_badge app.jinja_env.filters["confidence_badge"] = confidence_badge
app.jinja_env.filters["confidence_label"] = confidence_label app.jinja_env.filters["confidence_label"] = confidence_label
app.jinja_env.filters["severity_badge"] = severity_badge 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_duration"] = format_duration
app.jinja_env.filters["format_dossier_name"] = format_dossier_name app.jinja_env.filters["format_dossier_name"] = format_dossier_name
app.jinja_env.filters["format_doc_name"] = format_doc_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 return jsonify({"error": f"PDF source '{source_file}' introuvable"}), 404
try: try:
anonymized_text, new_dossier, report = process_pdf(pdf_path) pdf_results = process_pdf(pdf_path)
stem = pdf_path.stem.replace(" ", "_") stem = pdf_path.stem.replace(" ", "_")
subdir = None subdir = None
if pdf_path.parent != input_dir: if pdf_path.parent != input_dir:
subdir = pdf_path.parent.name subdir = pdf_path.parent.name
write_outputs(stem, anonymized_text, new_dossier, report, subdir=subdir) multi = len(pdf_results) > 1
return jsonify({"ok": True, "message": "Traitement terminé"}) 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: except Exception as e:
logger.exception("Erreur lors du retraitement") logger.exception("Erreur lors du retraitement")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500

View File

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

View File

@@ -7,7 +7,7 @@
{% if siblings %} {% if siblings %}
<div class="group-title" style="margin-top:1rem;">{{ current_group }}</div> <div class="group-title" style="margin-top:1rem;">{{ current_group }}</div>
{% for sib in siblings %} {% 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 }} {{ sib.name }}
</a> </a>
{% endfor %} {% endfor %}
@@ -213,7 +213,11 @@
{% 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>
{{ dp.cim10_confidence | confidence_badge }} {{ 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 }} {{ dp.niveau_severite | severity_badge }}
{% endif %} {% endif %}
{% if dp.justification %} {% if dp.justification %}
@@ -242,22 +246,40 @@
<div class="card section"> <div class="card section">
<h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3> <h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3>
<table> <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> <tbody>
{% for das in dossier.diagnostics_associes %} {% for das in dossier.diagnostics_associes %}
<tr> <tr>
<td> <td>{{ das.texte }}</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>{% if das.cim10_suggestion %}<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ das.cim10_suggestion }}</span>{% endif %}</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.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> <td style="font-size:0.8rem;color:#475569;">{{ das.justification or '' }}</td>
</tr> </tr>
{% if das.raisonnement %} {% if das.raisonnement %}
<tr> <tr>
<td colspan="5" style="padding:0 0.75rem 0.5rem;"> <td colspan="6" style="padding:0 0.75rem 0.5rem;">
<details> <details>
<summary>Raisonnement LLM</summary> <summary>Raisonnement LLM</summary>
<pre>{{ das.raisonnement }}</pre> <pre>{{ das.raisonnement }}</pre>
@@ -267,7 +289,7 @@
{% endif %} {% endif %}
{% if das.sources_rag %} {% if das.sources_rag %}
<tr> <tr>
<td colspan="5" style="padding:0 0.75rem 0.5rem;"> <td colspan="6" style="padding:0 0.75rem 0.5rem;">
<details> <details>
<summary>Sources RAG ({{ das.sources_rag|length }})</summary> <summary>Sources RAG ({{ das.sources_rag|length }})</summary>
{% for src in das.sources_rag %} {% for src in das.sources_rag %}

View File

@@ -19,6 +19,7 @@ from src.control.cpam_response import (
_build_cpam_prompt, _build_cpam_prompt,
_format_response, _format_response,
_search_rag_for_control, _search_rag_for_control,
_validate_references,
generate_cpam_response, generate_cpam_response,
) )
@@ -173,6 +174,31 @@ class TestBuildPrompt:
assert "contre_arguments_asymetrie" in prompt assert "contre_arguments_asymetrie" in prompt
assert "contre_arguments_reglementaires" in prompt assert "contre_arguments_reglementaires" in prompt
def test_prompt_contains_cite_exacts(self):
"""Le prompt renforcé demande des preuves exactes."""
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
assert "CITE" in prompt
assert "EXACTS" in prompt
def test_prompt_contains_interdiction(self):
"""Le prompt interdit les références inventées."""
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
assert "INTERDICTION ABSOLUE" in prompt
def test_prompt_contains_preuves_dossier_field(self):
"""Le format JSON demandé inclut preuves_dossier."""
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
assert "preuves_dossier" in prompt
class TestFormatResponse: class TestFormatResponse:
def test_full_response_new_format(self): def test_full_response_new_format(self):
@@ -236,11 +262,94 @@ class TestFormatResponse:
text = _format_response({}) text = _format_response({})
assert text == "" assert text == ""
def test_preuves_dossier_formatting(self):
"""Le nouveau champ preuves_dossier est formaté correctement."""
parsed = {
"contre_arguments_medicaux": "Arguments...",
"preuves_dossier": [
{"element": "biologie", "valeur": "CRP 180 mg/L", "signification": "inflammation sévère"},
{"element": "imagerie", "valeur": "lithiase cholédocienne", "signification": "confirme le diagnostic"},
],
"conclusion": "Conclusion...",
}
text = _format_response(parsed)
assert "PREUVES DU DOSSIER" in text
assert "CRP 180 mg/L" in text
assert "[biologie]" in text
assert "[imagerie]" in text
def test_structured_references_formatting(self):
"""Les références structurées sont formatées correctement."""
parsed = {
"contre_arguments_medicaux": "Arguments...",
"references": [
{"document": "Guide Méthodologique MCO 2026", "page": "64", "citation": "Le DAS doit être..."},
],
"conclusion": "Conclusion...",
}
text = _format_response(parsed)
assert "REFERENCES" in text
assert "Guide Méthodologique MCO 2026" in text
assert "p.64" in text
assert "Le DAS doit être..." in text
def test_ref_warnings_appended(self):
"""Les avertissements de références non vérifiées apparaissent."""
parsed = {"conclusion": "Conclusion..."}
warnings = ["Référence non vérifiable : Manuel Imaginaire 2025"]
text = _format_response(parsed, ref_warnings=warnings)
assert "AVERTISSEMENT" in text
assert "Manuel Imaginaire 2025" in text
class TestValidateReferences:
def test_valid_reference_no_warning(self):
parsed = {
"references": [
{"document": "Guide Méthodologique MCO 2026", "page": "64", "citation": "..."},
]
}
sources = [{"document": "guide_methodo", "page": 64, "extrait": "..."}]
warnings = _validate_references(parsed, sources)
assert len(warnings) == 0
def test_invented_reference_detected(self):
parsed = {
"references": [
{"document": "Manuel Inventé 2025", "page": "12", "citation": "..."},
]
}
sources = [{"document": "guide_methodo", "page": 64, "extrait": "..."}]
warnings = _validate_references(parsed, sources)
assert len(warnings) == 1
assert "Manuel Inventé" in warnings[0]
def test_old_format_string_no_crash(self):
"""L'ancien format string pour references ne cause pas de crash."""
parsed = {"references": "Guide méthodo p.64"}
sources = [{"document": "guide_methodo"}]
warnings = _validate_references(parsed, sources)
assert len(warnings) == 0 # pas de validation sur l'ancien format
def test_no_sources_no_validation(self):
parsed = {
"references": [
{"document": "Quelque chose", "page": "1", "citation": "..."},
]
}
warnings = _validate_references(parsed, [])
assert len(warnings) == 0
class TestGenerateResponse: class TestGenerateResponse:
@patch("src.control.cpam_response.call_ollama") @patch("src.control.cpam_response.call_ollama")
@patch("src.control.cpam_response.call_anthropic")
@patch("src.control.cpam_response._search_rag_for_control") @patch("src.control.cpam_response._search_rag_for_control")
def test_generate_success(self, mock_rag, mock_ollama): def test_generate_success_ollama_cpam(self, mock_rag, mock_anthropic, mock_ollama):
"""Mode hybride : Ollama CPAM (27b) disponible → utilisé en premier."""
mock_rag.return_value = [ mock_rag.return_value = [
{"document": "guide_methodo", "page": 64, "extrait": "Texte guide"}, {"document": "guide_methodo", "page": 64, "extrait": "Texte guide"},
] ]
@@ -259,12 +368,42 @@ class TestGenerateResponse:
assert "Contre-arguments médicaux..." in text assert "Contre-arguments médicaux..." in text
assert len(sources) == 1 assert len(sources) == 1
assert sources[0].document == "guide_methodo" assert sources[0].document == "guide_methodo"
# Ollama CPAM appelé en premier (avec model= et timeout=)
mock_ollama.assert_called_once() mock_ollama.assert_called_once()
mock_anthropic.assert_not_called()
@patch("src.control.cpam_response.call_ollama") @patch("src.control.cpam_response.call_ollama")
@patch("src.control.cpam_response.call_anthropic")
@patch("src.control.cpam_response._search_rag_for_control") @patch("src.control.cpam_response._search_rag_for_control")
def test_generate_ollama_unavailable(self, mock_rag, mock_ollama): def test_generate_fallback_haiku(self, mock_rag, mock_anthropic, mock_ollama):
"""Ollama CPAM indisponible → fallback Haiku."""
mock_rag.return_value = [
{"document": "guide_methodo", "page": 64, "extrait": "Texte guide"},
]
mock_ollama.return_value = None
mock_anthropic.return_value = {
"analyse_contestation": "Analyse Haiku...",
"contre_arguments_medicaux": "Contre-args Haiku...",
"conclusion": "Conclusion Haiku...",
}
dossier = _make_dossier()
controle = _make_controle()
text, sources = generate_cpam_response(dossier, controle)
assert "Contre-args Haiku..." in text
# Ollama CPAM appelé d'abord (échec), puis Haiku
mock_ollama.assert_called_once()
mock_anthropic.assert_called_once()
@patch("src.control.cpam_response.call_ollama")
@patch("src.control.cpam_response.call_anthropic")
@patch("src.control.cpam_response._search_rag_for_control")
def test_generate_all_unavailable(self, mock_rag, mock_anthropic, mock_ollama):
"""Ollama CPAM, Haiku et Ollama défaut tous indisponibles → texte vide."""
mock_rag.return_value = [] mock_rag.return_value = []
mock_anthropic.return_value = None
mock_ollama.return_value = None mock_ollama.return_value = None
dossier = _make_dossier() dossier = _make_dossier()

View File

@@ -2,7 +2,12 @@
import pytest import pytest
from src.medical.das_filter import clean_diagnostic_text, is_valid_diagnostic_text, correct_known_miscodes from src.medical.das_filter import (
clean_diagnostic_text,
is_valid_diagnostic_text,
correct_known_miscodes,
SEMANTIC_REDUNDANCIES,
)
class TestCleanDiagnosticText: class TestCleanDiagnosticText:
@@ -223,3 +228,24 @@ class TestCorrectKnownMiscodes:
def test_d64_9_pas_corrige(self): def test_d64_9_pas_corrige(self):
"""D64.9 lui-même → pas de correction.""" """D64.9 lui-même → pas de correction."""
assert correct_known_miscodes("D64.9", "Anémie") is None assert correct_known_miscodes("D64.9", "Anémie") is None
class TestSemanticRedundanciesStructure:
"""Vérifie le format de la constante SEMANTIC_REDUNDANCIES."""
def test_is_list_of_tuples(self):
assert isinstance(SEMANTIC_REDUNDANCIES, list)
for item in SEMANTIC_REDUNDANCIES:
assert isinstance(item, tuple)
assert len(item) == 2
dominated, dominants = item
assert isinstance(dominated, str)
assert isinstance(dominants, list)
for d in dominants:
assert isinstance(d, str)
def test_has_known_rules(self):
prefixes = {item[0] for item in SEMANTIC_REDUNDANCIES}
assert "I10" in prefixes
assert "N30" in prefixes
assert "J18" in prefixes

View File

@@ -20,6 +20,7 @@ from src.medical.fusion import (
_dedup_actes, _dedup_actes,
_is_enriched, _is_enriched,
) )
from src.medical.das_filter import apply_semantic_dedup
class TestCIM10Specificity: class TestCIM10Specificity:
@@ -354,3 +355,139 @@ class TestDedupPreferEnriched:
result = _dedup_diagnostics(das) result = _dedup_diagnostics(das)
assert len(result) == 1 assert len(result) == 1
assert result[0].cim10_confidence == "high" assert result[0].cim10_confidence == "high"
class TestDasFamilyDpRemoved:
"""Vérifie la dédup DAS vs DP par famille CIM-10 (3 premiers caractères)."""
def test_same_family_removed(self):
"""DP=K85.1, DAS=[K85.0, K85.9, E66.0] → seul E66.0 reste."""
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Pancréatite biliaire", cim10_suggestion="K85.1"),
diagnostics_associes=[
Diagnostic(texte="Pancréatite SAI", cim10_suggestion="K85.0"),
Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.9"),
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
],
)
result = merge_dossiers([d1])
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert "K85.0" not in das_codes
assert "K85.9" not in das_codes
assert "E66.0" in das_codes
def test_trauma_siblings_kept(self):
"""S/T : sites anatomiques différents → tous gardés."""
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Fracture col fémoral", cim10_suggestion="S72.1"),
diagnostics_associes=[
Diagnostic(texte="Fracture trochanter", cim10_suggestion="S72.0"),
Diagnostic(texte="Fracture sous-troch", cim10_suggestion="S72.3"),
],
)
result = merge_dossiers([d1])
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert "S72.0" in das_codes
assert "S72.3" in das_codes
def test_diabetes_complications_kept(self):
"""E10-E14 : complications distinctes → tous gardés."""
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Diabète avec complications oculaires", cim10_suggestion="E11.6"),
diagnostics_associes=[
Diagnostic(texte="Diabète avec complications rénales", cim10_suggestion="E11.2"),
Diagnostic(texte="HTA essentielle", cim10_suggestion="I10"),
],
)
result = merge_dossiers([d1])
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert "E11.2" in das_codes
assert "I10" in das_codes
def test_parent_child_removed(self):
"""DP=K85.1, DAS=[K85] → K85 (parent) retiré."""
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Pancréatite biliaire", cim10_suggestion="K85.1"),
diagnostics_associes=[
Diagnostic(texte="Pancréatite", cim10_suggestion="K85"),
],
)
result = merge_dossiers([d1])
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert len(das_codes) == 0
def test_ocr_dp_not_promoted(self):
"""Fusion avec DP artefact OCR 'À 09' → pas promu en DAS."""
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Pancréatite biliaire", cim10_suggestion="K85.1"),
)
d2 = DossierMedical(
diagnostic_principal=Diagnostic(texte="À 09", cim10_suggestion="A41.9"),
)
result = merge_dossiers([d1, d2])
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert "A41.9" not in das_codes
class TestSemanticDedup:
"""Vérifie les redondances sémantiques entre DAS."""
def test_i10_removed_when_i11_present(self):
"""I10 (HTA essentielle) retiré si I11.9 (cardiopathie hypertensive) présent."""
das = [
Diagnostic(texte="HTA essentielle", cim10_suggestion="I10"),
Diagnostic(texte="Cardiopathie hypertensive", cim10_suggestion="I11.9"),
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
]
result = apply_semantic_dedup(das)
codes = {d.cim10_suggestion for d in result}
assert "I10" not in codes
assert "I11.9" in codes
assert "E66.0" in codes
def test_n30_removed_when_n39_present(self):
"""N30.9 (cystite) retiré si N39.0 (infection urinaire) présent."""
das = [
Diagnostic(texte="Infection urinaire", cim10_suggestion="N39.0"),
Diagnostic(texte="Cystite SAI", cim10_suggestion="N30.9"),
]
result = apply_semantic_dedup(das)
codes = {d.cim10_suggestion for d in result}
assert "N39.0" in codes
assert "N30.9" not in codes
def test_j18_removed_when_j15_present(self):
"""J18.9 (pneumonie SAI) retiré si J15.1 (pneumonie spécifique) présent."""
das = [
Diagnostic(texte="Pneumonie SAI", cim10_suggestion="J18.9"),
Diagnostic(texte="Pneumonie à Klebsiella", cim10_suggestion="J15.1"),
]
result = apply_semantic_dedup(das)
codes = {d.cim10_suggestion for d in result}
assert "J15.1" in codes
assert "J18.9" not in codes
def test_no_removal_without_dominant(self):
"""I10 conservé si aucun code dominant I11/I12/I13."""
das = [
Diagnostic(texte="HTA essentielle", cim10_suggestion="I10"),
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
]
result = apply_semantic_dedup(das)
codes = {d.cim10_suggestion for d in result}
assert "I10" in codes
assert "E66.0" in codes
def test_semantic_dedup_in_merge(self):
"""Vérifie que la dédup sémantique est appliquée lors de la fusion."""
d1 = DossierMedical(
diagnostic_principal=Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"),
diagnostics_associes=[
Diagnostic(texte="HTA essentielle", cim10_suggestion="I10"),
Diagnostic(texte="Cardiopathie hypertensive", cim10_suggestion="I11.9"),
],
)
result = merge_dossiers([d1])
das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
assert "I10" not in das_codes
assert "I11.9" in das_codes

View File

@@ -0,0 +1,95 @@
"""Tests pour le module PageTracker (traçabilité source)."""
import pytest
from src.extraction.page_tracker import PageTracker
class TestCharToPage:
def test_first_page(self):
pt = PageTracker([(0, 100), (102, 200)])
assert pt.char_to_page(0) == 1
assert pt.char_to_page(50) == 1
assert pt.char_to_page(99) == 1
def test_second_page(self):
pt = PageTracker([(0, 100), (102, 200)])
assert pt.char_to_page(102) == 2
assert pt.char_to_page(150) == 2
def test_beyond_last_page(self):
pt = PageTracker([(0, 100), (102, 200)])
assert pt.char_to_page(300) == 2
def test_single_page(self):
pt = PageTracker([(0, 500)])
assert pt.char_to_page(250) == 1
def test_empty_offsets(self):
pt = PageTracker([])
assert pt.char_to_page(0) == 1
class TestFindPageForText:
def _make_tracker(self):
"""Simule un document 3 pages."""
page1 = "Pancréatite aiguë biliaire"
page2 = "Cholécystectomie par coelioscopie"
page3 = "TTT de sortie: Augmentin IV"
sep = "\n\n"
full = sep.join([page1, page2, page3])
offsets = []
offset = 0
for text in [page1, page2, page3]:
offsets.append((offset, offset + len(text)))
offset += len(text) + len(sep)
return PageTracker(offsets), full
def test_exact_match_page1(self):
pt, full = self._make_tracker()
assert pt.find_page_for_text("Pancréatite", full) == 1
def test_exact_match_page2(self):
pt, full = self._make_tracker()
assert pt.find_page_for_text("Cholécystectomie", full) == 2
def test_exact_match_page3(self):
pt, full = self._make_tracker()
assert pt.find_page_for_text("Augmentin", full) == 3
def test_case_insensitive(self):
pt, full = self._make_tracker()
assert pt.find_page_for_text("pancréatite", full) == 1
def test_not_found(self):
pt, full = self._make_tracker()
assert pt.find_page_for_text("inexistant", full) is None
def test_empty_text(self):
pt, full = self._make_tracker()
assert pt.find_page_for_text("", full) is None
class TestExtractExcerpt:
def test_returns_excerpt(self):
text = "A" * 200 + "Pancréatite aiguë" + "B" * 200
pt = PageTracker([(0, len(text))])
excerpt = pt.extract_excerpt("Pancréatite aiguë", text, context_chars=50)
assert excerpt is not None
assert "Pancréatite aiguë" in excerpt
assert excerpt.startswith("...")
assert excerpt.endswith("...")
def test_at_start(self):
text = "Pancréatite aiguë biliaire " + "X" * 200
pt = PageTracker([(0, len(text))])
excerpt = pt.extract_excerpt("Pancréatite", text, context_chars=50)
assert excerpt is not None
assert not excerpt.startswith("...")
def test_not_found(self):
text = "Texte sans rapport"
pt = PageTracker([(0, len(text))])
assert pt.extract_excerpt("inexistant", text) is None

View File

@@ -8,6 +8,7 @@ from src.medical.severity import (
enrich_dossier_severity, enrich_dossier_severity,
_detect_severity_markers, _detect_severity_markers,
_is_heuristic_cma, _is_heuristic_cma,
_load_cma_levels,
) )
@@ -59,6 +60,49 @@ class TestHeuristicCMA:
assert _is_heuristic_cma(None) is False assert _is_heuristic_cma(None) is False
class TestCMALevels:
"""Tests pour le lookup CMA officiel ATIH."""
def test_load_cma_levels(self):
levels = _load_cma_levels()
assert len(levels) > 0
# A01.0 est severity 2 dans cocoa_entries
assert levels.get("A01.0") == 2
def test_official_level_4(self):
"""Un code CMA niveau 4 est bien détecté."""
levels = _load_cma_levels()
level4_codes = [k for k, v in levels.items() if v == 4]
assert len(level4_codes) > 0
def test_official_level_propagated(self):
"""evaluate_severity propage le niveau CMA officiel."""
levels = _load_cma_levels()
# Prendre un code de niveau 3
code_lv3 = next((k for k, v in levels.items() if v == 3), None)
if code_lv3:
diag = Diagnostic(texte="Test diagnostic", cim10_suggestion=code_lv3)
info = evaluate_severity(diag)
assert info.niveau_cma == 3
assert info.est_cma_probable is True
def test_heuristic_fallback_level_2(self):
"""Un code heuristique CMA sans entrée officielle → niveau 2."""
# E11.9 est dans les racines heuristiques ET dans le fichier officiel
# Testons avec un code heuristique qui n'est pas dans le fichier officiel
diag = Diagnostic(texte="Test", cim10_suggestion="E11.9")
info = evaluate_severity(diag)
assert info.est_cma_probable is True
assert info.niveau_cma >= 2
def test_non_cma_remains_level_1(self):
"""Un code non-CMA reste au niveau 1."""
diag = Diagnostic(texte="Grippe", cim10_suggestion="J11.1")
info = evaluate_severity(diag)
if not info.est_cma_probable:
assert info.niveau_cma == 1
class TestEvaluateSeverity: class TestEvaluateSeverity:
def test_cma_code_detected(self): def test_cma_code_detected(self):
diag = Diagnostic(texte="Diabète type 2", cim10_suggestion="E11.9") diag = Diagnostic(texte="Diabète type 2", cim10_suggestion="E11.9")
@@ -66,7 +110,8 @@ class TestEvaluateSeverity:
assert info.est_cma_probable is True assert info.est_cma_probable is True
def test_non_cma_code(self): def test_non_cma_code(self):
diag = Diagnostic(texte="Pancréatite aiguë biliaire", cim10_suggestion="K85.1") """Un code non CMA (J11.1 grippe) n'est pas détecté comme CMA."""
diag = Diagnostic(texte="Grippe", cim10_suggestion="J11.1")
info = evaluate_severity(diag) info = evaluate_severity(diag)
assert info.est_cma_probable is False assert info.est_cma_probable is False
@@ -82,6 +127,12 @@ class TestEvaluateSeverity:
info = evaluate_severity(diag) info = evaluate_severity(diag)
assert info.est_cma_probable is True assert info.est_cma_probable is True
def test_niveau_cma_in_result(self):
"""Le champ niveau_cma est toujours renseigné."""
diag = Diagnostic(texte="Sepsis", cim10_suggestion="A41.9")
info = evaluate_severity(diag)
assert info.niveau_cma >= 1
class TestEnrichDossierSeverity: class TestEnrichDossierSeverity:
def test_enriches_das_in_place(self): def test_enriches_das_in_place(self):
@@ -119,3 +170,22 @@ class TestEnrichDossierSeverity:
assert das[0].est_cma is True assert das[0].est_cma is True
assert das[0].est_cms is True assert das[0].est_cms is True
assert cms_count == 1 assert cms_count == 1
def test_niveau_cma_set_on_das(self):
"""enrich_dossier_severity propage niveau_cma sur chaque DAS."""
dp = Diagnostic(texte="Pancréatite", cim10_suggestion="K85.1")
das = [
Diagnostic(texte="Fibrillation auriculaire", cim10_suggestion="I48.9"),
]
enrich_dossier_severity(dp, das)
assert das[0].niveau_cma is not None
assert das[0].niveau_cma >= 2
def test_alertes_contain_cma_level(self):
"""Les alertes mentionnent le niveau CMA."""
dp = Diagnostic(texte="Test", cim10_suggestion="K85.1")
das = [
Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"),
]
alertes, _, _ = enrich_dossier_severity(dp, das)
assert any("CMA niveau" in a for a in alertes)

View File

@@ -192,7 +192,7 @@ class TestSplitDocuments:
# --- Test intégration process_pdf --- # --- Test intégration process_pdf ---
class TestProcessPdfMulti: class TestProcessPdfMulti:
@patch("src.main.extract_text") @patch("src.main.extract_text_with_pages")
@patch("src.main.extract_medical_info") @patch("src.main.extract_medical_info")
@patch("src.main._run_edsnlp", return_value=None) @patch("src.main._run_edsnlp", return_value=None)
@patch("src.main._use_edsnlp", False) @patch("src.main._use_edsnlp", False)
@@ -202,9 +202,10 @@ class TestProcessPdfMulti:
from pathlib import Path from pathlib import Path
from src.main import process_pdf from src.main import process_pdf
from src.config import DossierMedical, Diagnostic from src.config import DossierMedical, Diagnostic
from src.extraction.page_tracker import PageTracker
# Mock extract_text retournant un texte multi-épisodes Trackare # Mock extract_text_with_pages retournant un texte multi-épisodes Trackare
mock_extract.return_value = TRACKARE_MULTI mock_extract.return_value = (TRACKARE_MULTI, PageTracker([(0, len(TRACKARE_MULTI))]))
# Mock extract_medical_info retournant un DossierMedical minimal # Mock extract_medical_info retournant un DossierMedical minimal
mock_medical.return_value = DossierMedical( mock_medical.return_value = DossierMedical(