diff --git a/src/config.py b/src/config.py index 5b19eb6..cf9aac1 100644 --- a/src/config.py +++ b/src/config.py @@ -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): diff --git a/src/control/cpam_response.py b/src/control/cpam_response.py index 17c9281..dd6f834 100644 --- a/src/control/cpam_response.py +++ b/src/control/cpam_response.py @@ -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 diff --git a/src/extraction/page_tracker.py b/src/extraction/page_tracker.py new file mode 100644 index 0000000..dfb2896 --- /dev/null +++ b/src/extraction/page_tracker.py @@ -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 diff --git a/src/extraction/pdf_extractor.py b/src/extraction/pdf_extractor.py index 547e8cc..67f924a 100644 --- a/src/extraction/pdf_extractor.py +++ b/src/extraction/pdf_extractor.py @@ -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] = [] diff --git a/src/main.py b/src/main.py index 04b3339..3ec94cf 100644 --- a/src/main.py +++ b/src/main.py @@ -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) diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index 8c9da1e..9facca8 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -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(" DAS≈DP : %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)) diff --git a/src/medical/das_filter.py b/src/medical/das_filter.py index c8fb698..2402817 100644 --- a/src/medical/das_filter.py +++ b/src/medical/das_filter.py @@ -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. diff --git a/src/medical/fusion.py b/src/medical/fusion.py index a1732c2..f26a41f 100644 --- a/src/medical/fusion.py +++ b/src/medical/fusion.py @@ -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: diff --git a/src/medical/ghm.py b/src/medical/ghm.py index 115c3d4..95e8cbb 100644 --- a/src/medical/ghm.py +++ b/src/medical/ghm.py @@ -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 diff --git a/src/medical/ollama_client.py b/src/medical/ollama_client.py index 59cc0d6..199e649 100644 --- a/src/medical/ollama_client.py +++ b/src/medical/ollama_client.py @@ -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 diff --git a/src/medical/severity.py b/src/medical/severity.py index 086372d..cfe19d9 100644 --- a/src/medical/severity.py +++ b/src/medical/severity.py @@ -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 "") ) diff --git a/src/viewer/app.py b/src/viewer/app.py index cfa39a4..75ef0fd 100644 --- a/src/viewer/app.py +++ b/src/viewer/app.py @@ -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'' + f'CMA {label}' + ) + + 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 diff --git a/src/viewer/templates/base.html b/src/viewer/templates/base.html index 5ec6d65..945514e 100644 --- a/src/viewer/templates/base.html +++ b/src/viewer/templates/base.html @@ -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 @@ -