"""Recherche RAG (FAISS) + génération via Ollama pour le codage CIM-10.""" from __future__ import annotations import logging from concurrent.futures import ThreadPoolExecutor, as_completed from ..config import ( ActeCCAM, Diagnostic, DossierMedical, PreuveClinique, RAGSource, OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL, EMBEDDING_MODEL, RERANKER_MODEL, ) from .cim10_dict import normalize_code, validate_code as cim10_validate, fallback_parent_code from .cim10_extractor import BIO_NORMALS from .clinical_context import build_enriched_context, format_enriched_context from .ccam_dict import validate_code as ccam_validate from .ollama_client import call_ollama, parse_json_response from .ollama_cache import OllamaCache logger = logging.getLogger(__name__) # Singleton pour le modèle d'embedding (chargé une seule fois) _embed_model = None # Singleton pour le cross-encoder de re-ranking (CPU uniquement) _reranker_model = None # Score minimum de similarité FAISS pour retenir un résultat _MIN_SCORE = 0.3 # Seuil rehaussé pour le contexte CPAM (filtrage plus agressif du bruit) _MIN_SCORE_CPAM = 0.40 def _get_embed_model(): """Charge le modèle d'embedding (singleton). Tente CUDA d'abord, fallback CPU si OOM (Ollama peut occuper la VRAM). """ global _embed_model if _embed_model is None: from sentence_transformers import SentenceTransformer import torch _device = "cuda" if torch.cuda.is_available() else "cpu" try: logger.info("Chargement du modèle d'embedding (%s)...", _device) _embed_model = SentenceTransformer(EMBEDDING_MODEL, device=_device) except (torch.OutOfMemoryError, torch.cuda.CudaError, torch.AcceleratorError, RuntimeError) as exc: if _device == "cuda" and "memory" in str(exc).lower(): logger.warning("CUDA OOM pour l'embedding — fallback CPU") torch.cuda.empty_cache() _embed_model = SentenceTransformer(EMBEDDING_MODEL, device="cpu") else: raise _embed_model.max_seq_length = 512 return _embed_model def _get_reranker(): """Charge le cross-encoder de re-ranking (singleton, CPU uniquement). Forcé sur CPU pour ne pas interférer avec Ollama sur GPU. """ global _reranker_model if _reranker_model is None: from sentence_transformers import CrossEncoder logger.info("Chargement du cross-encoder de re-ranking (cpu)...") _reranker_model = CrossEncoder(RERANKER_MODEL, device="cpu") return _reranker_model def _rerank(query: str, results: list[dict], top_k: int) -> list[dict]: """Re-classe les résultats FAISS via un cross-encoder. Args: query: Texte de la requête originale. results: Résultats FAISS avec clé 'extrait'. top_k: Nombre de résultats à retourner. Returns: Résultats re-classés par score cross-encoder, limités à top_k. """ if not results: return results reranker = _get_reranker() # Construire les paires (query, passage) pour le cross-encoder pairs = [(query, r.get("extrait", "")) for r in results] ce_scores = reranker.predict(pairs) # Injecter le score cross-encoder et trier for r, ce_score in zip(results, ce_scores): r["score_faiss"] = r["score"] r["score"] = float(ce_score) results.sort(key=lambda r: r["score"], reverse=True) return results[:top_k] def search_similar(query: str, top_k: int = 10) -> list[dict]: """Recherche les passages les plus similaires dans l'index FAISS. Args: query: Texte du diagnostic à rechercher. top_k: Nombre de résultats à retourner. Returns: Liste de dicts avec les métadonnées + score de similarité, filtrés par score minimum et priorisant les sources CIM-10. """ from .rag_index import get_index import numpy as np result = get_index() if result is None: logger.warning("Index FAISS non disponible") return [] faiss_index, metadata = result model = _get_embed_model() query_vec = model.encode([query], normalize_embeddings=True) query_vec = np.array(query_vec, dtype=np.float32) # Chercher plus de résultats que top_k pour pouvoir filtrer ensuite fetch_k = min(top_k * 2, faiss_index.ntotal) scores, indices = faiss_index.search(query_vec, fetch_k) raw_results = [] for score, idx in zip(scores[0], indices[0]): if idx < 0: continue if float(score) < _MIN_SCORE: continue meta = metadata[idx].copy() meta["score"] = float(score) raw_results.append(meta) # Prioriser les sources CIM-10 (au moins 6 sur top_k) cim10_results = [r for r in raw_results if r["document"] in ("cim10", "cim10_alpha")] other_results = [r for r in raw_results if r["document"] not in ("cim10", "cim10_alpha")] min_cim10 = min(6, len(cim10_results)) final = cim10_results[:min_cim10] remaining_slots = top_k - len(final) # Remplir le reste avec les meilleurs résultats (CIM-10 restants + autres) remaining = cim10_results[min_cim10:] + other_results remaining.sort(key=lambda r: r["score"], reverse=True) final.extend(remaining[:remaining_slots]) return final def search_similar_ccam(query: str, top_k: int = 8) -> list[dict]: """Recherche les passages CCAM les plus similaires dans l'index FAISS. Même logique que search_similar() mais priorise les sources CCAM. """ from .rag_index import get_index import numpy as np result = get_index() if result is None: logger.warning("Index FAISS non disponible") return [] faiss_index, metadata = result model = _get_embed_model() query_vec = model.encode([query], normalize_embeddings=True) query_vec = np.array(query_vec, dtype=np.float32) fetch_k = min(top_k * 2, faiss_index.ntotal) scores, indices = faiss_index.search(query_vec, fetch_k) raw_results = [] for score, idx in zip(scores[0], indices[0]): if idx < 0: continue if float(score) < _MIN_SCORE: continue meta = metadata[idx].copy() meta["score"] = float(score) raw_results.append(meta) # Prioriser les sources CCAM (au moins 5 sur top_k) ccam_results = [r for r in raw_results if r["document"] == "ccam"] other_results = [r for r in raw_results if r["document"] != "ccam"] min_ccam = min(5, len(ccam_results)) final = ccam_results[:min_ccam] remaining_slots = top_k - len(final) remaining = ccam_results[min_ccam:] + other_results remaining.sort(key=lambda r: r["score"], reverse=True) final.extend(remaining[:remaining_slots]) return final def search_similar_cpam(query: str, top_k: int = 8) -> list[dict]: """Recherche RAG spécifique au contexte CPAM (contre-argumentation). Différences avec search_similar() : - Priorité Guide Méthodologique (min 3 résultats) plutôt que CIM-10 - Seuil de score rehaussé (0.40 vs 0.30) pour éliminer le bruit - Fetch élargi (top_k * 3) car filtrage plus agressif - Déduplication par code CIM-10 (garde le meilleur score par code) """ from .rag_index import get_index import numpy as np result = get_index() if result is None: logger.warning("Index FAISS non disponible") return [] faiss_index, metadata = result model = _get_embed_model() query_vec = model.encode([query], normalize_embeddings=True) query_vec = np.array(query_vec, dtype=np.float32) # Fetch élargi pour compenser le filtrage agressif fetch_k = min(top_k * 3, faiss_index.ntotal) scores, indices = faiss_index.search(query_vec, fetch_k) raw_results = [] for score, idx in zip(scores[0], indices[0]): if idx < 0: continue if float(score) < _MIN_SCORE_CPAM: continue meta = metadata[idx].copy() meta["score"] = float(score) raw_results.append(meta) # Dédupliquer par code CIM-10 (garder meilleur score par code) seen_codes: dict[str, dict] = {} deduped = [] for r in raw_results: code = r.get("code") if code: if code in seen_codes: if r["score"] > seen_codes[code]["score"]: seen_codes[code] = r else: seen_codes[code] = r else: deduped.append(r) # pas de code → garder (guide_methodo, etc.) deduped.extend(seen_codes.values()) deduped.sort(key=lambda r: r["score"], reverse=True) # Re-ranking cross-encoder (CPU) pour affiner le classement reranked = _rerank(query, deduped, top_k=len(deduped)) # Prioriser le Guide Méthodologique (min 3 résultats) guide_results = [r for r in reranked if r["document"] == "guide_methodo"] other_results = [r for r in reranked if r["document"] != "guide_methodo"] min_guide = min(3, len(guide_results)) final = guide_results[:min_guide] remaining_slots = top_k - len(final) remaining = guide_results[min_guide:] + other_results remaining.sort(key=lambda r: r["score"], reverse=True) final.extend(remaining[:remaining_slots]) return final def _format_contexte(contexte: dict) -> str: """Formate le contexte patient de manière structurée pour le prompt.""" lines = [] sexe = contexte.get("sexe") age = contexte.get("age") imc = contexte.get("imc") patient_parts = [] if sexe: patient_parts.append(sexe) if age: patient_parts.append(f"{age} ans") if imc: patient_parts.append(f"IMC {imc}") if patient_parts: lines.append(f"- Patient : {', '.join(patient_parts)}") duree = contexte.get("duree_sejour") if duree: lines.append(f"- Durée séjour : {duree} jours") antecedents = contexte.get("antecedents") if antecedents: lines.append(f"- Antécédents : {', '.join(antecedents[:5])}") biologie = contexte.get("biologie_cle") if biologie: bio_parts = [] for b in biologie: test, valeur, anomalie = b if isinstance(b, (list, tuple)) else (b.get("test"), b.get("valeur"), b.get("anomalie")) # Ajouter la plage de référence si connue norme_str = "" if test in BIO_NORMALS: lo, hi = BIO_NORMALS[test] lo_s = int(lo) if lo == int(lo) else lo hi_s = int(hi) if hi == int(hi) else hi norme_str = f" [N: {lo_s}-{hi_s}]" marker = " (\u2191)" if anomalie else "" bio_parts.append(f"{test} {valeur}{norme_str}{marker}") lines.append(f"- Biologie : {', '.join(bio_parts)}") imagerie = contexte.get("imagerie") if imagerie: for img in imagerie: img_type, conclusion = img if isinstance(img, (list, tuple)) else (img.get("type"), img.get("conclusion")) if conclusion: lines.append(f"- Imagerie : {img_type} — {conclusion[:200]}") complications = contexte.get("complications") if complications: lines.append(f"- Complications : {', '.join(complications)}") dp_texte = contexte.get("dp_texte") if dp_texte: lines.append(f"- DP du séjour : {dp_texte}") das_codes = contexte.get("das_codes_existants") if das_codes: lines.append(f"- DAS déjà codés : {', '.join(das_codes)}") return "\n".join(lines) if lines else "Non précisé" def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool = True) -> str: """Construit le prompt expert DIM avec raisonnement structuré.""" sources_text = "" for i, src in enumerate(sources, 1): doc_name = { "cim10": "CIM-10 FR 2026", "cim10_alpha": "CIM-10 Index Alphabétique 2026", "guide_methodo": "Guide Méthodologique MCO 2026", "ccam": "CCAM PMSI V4 2025", }.get(src["document"], src["document"]) code_info = f" (code: {src['code']})" if src.get("code") else "" page_info = f" [page {src['page']}]" if src.get("page") else "" sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n" sources_text += (src.get("extrait", "")[:800]) + "\n\n" type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)" ctx_str = format_enriched_context(contexte) return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. Tu dois coder le diagnostic suivant en respectant STRICTEMENT les règles de l'ATIH. RÈGLES IMPÉRATIVES : - Le code doit provenir UNIQUEMENT des sources CIM-10 fournies - Distingue la DESCRIPTION CLINIQUE (ce que le médecin écrit) de la LOGIQUE DE CODAGE (ce que l'ATIH impose) - Privilégie le code le plus SPÉCIFIQUE disponible (4e ou 5e caractère) - Vérifie les notes d'inclusion/exclusion de chaque code candidat - Si le diagnostic est un DP, il doit refléter le motif principal de prise en charge du séjour - Si c'est un DAS, il doit avoir mobilisé des ressources supplémentaires pendant le séjour - EXCLUSION SYMPTÔME : Si le diagnostic est un symptôme (R00-R99) et qu'un diagnostic précis (Chapitres I-XIV, A00-N99) expliquant ce symptôme est présent, le symptôme ne doit PAS être codé comme DAS DIAGNOSTIC À CODER : "{texte}" TYPE : {type_diag} CONTEXTE CLINIQUE : {ctx_str} SOURCES CIM-10 : {sources_text} Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : {{ "analyse_clinique": "que signifie ce diagnostic sur le plan médical", "codes_candidats": "quels codes CIM-10 des sources sont compatibles", "discrimination": "pourquoi choisir ce code plutôt qu'un autre (inclusions/exclusions, spécificité)", "regle_pmsi": "conformité aux règles PMSI pour un {type_diag} (guide méthodologique)", "code": "X99.9", "confidence": "high ou medium ou low", "justification": "explication courte en français", "preuves_cliniques": [ {{"type": "biologie|imagerie|traitement|acte|clinique", "element": "élément concret du dossier", "interpretation": "signification clinique justifiant le code"}} ] }}""" def _build_prompt_ccam(texte: str, sources: list[dict], contexte: dict) -> str: """Construit le prompt expert DIM pour le codage CCAM avec raisonnement structuré.""" sources_text = "" for i, src in enumerate(sources, 1): doc_name = { "cim10": "CIM-10 FR 2026", "cim10_alpha": "CIM-10 Index Alphabétique 2026", "guide_methodo": "Guide Méthodologique MCO 2026", "ccam": "CCAM PMSI V4 2025", }.get(src["document"], src["document"]) code_info = f" (code: {src['code']})" if src.get("code") else "" page_info = f" [page {src['page']}]" if src.get("page") else "" sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n" sources_text += (src.get("extrait", "")[:800]) + "\n\n" ctx_str = format_enriched_context(contexte) return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage CCAM PMSI. Tu dois coder l'acte chirurgical/médical suivant en respectant STRICTEMENT la nomenclature CCAM. RÈGLES IMPÉRATIVES : - Le code doit provenir UNIQUEMENT des sources CCAM fournies - Un code CCAM est composé de 4 lettres + 3 chiffres (ex: HMFC004) - Vérifie l'activité (1=acte technique, 4=anesthésie) et le regroupement - Tiens compte du tarif secteur 1 pour valider la cohérence - Si plusieurs codes sont possibles, choisis le plus spécifique à l'acte décrit - En cas de doute, indique confidence "low" plutôt que de proposer un code inadapté ACTE À CODER : "{texte}" CONTEXTE CLINIQUE : {ctx_str} SOURCES CCAM : {sources_text} Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : {{ "analyse_acte": "que décrit cet acte sur le plan technique/chirurgical", "codes_candidats": "quels codes CCAM des sources sont compatibles", "discrimination": "pourquoi choisir ce code plutôt qu'un autre (activité, regroupement, tarif)", "code": "ABCD123", "confidence": "high ou medium ou low", "justification": "explication courte en français" }}""" def _parse_ollama_response(raw: str) -> dict | None: """Parse la réponse JSON d'Ollama et reconstitue le raisonnement structuré.""" parsed = parse_json_response(raw) if parsed is None: return None # Reconstituer le raisonnement à partir des champs structurés reasoning_parts = [] for key in ("analyse_clinique", "analyse_acte", "codes_candidats", "discrimination", "regle_pmsi"): val = parsed.pop(key, None) if val: titre = key.replace("_", " ").upper() reasoning_parts.append(f"{titre} :\n{val}") if reasoning_parts: parsed["raisonnement"] = "\n\n".join(reasoning_parts) return parsed def _call_ollama(prompt: str) -> dict | None: """Appelle Ollama (mode JSON) et parse la réponse avec reconstitution du raisonnement.""" result = call_ollama(prompt, temperature=0.1, max_tokens=2500) if result is None: return None # Reconstituer le raisonnement structuré reasoning_parts = [] for key in ("analyse_clinique", "analyse_acte", "codes_candidats", "discrimination", "regle_pmsi"): val = result.pop(key, None) if val: titre = key.replace("_", " ").upper() reasoning_parts.append(f"{titre} :\n{val}") if reasoning_parts: result["raisonnement"] = "\n\n".join(reasoning_parts) return result def _apply_llm_result_diagnostic(diagnostic: Diagnostic, llm_result: dict) -> None: """Applique un résultat LLM (frais ou caché) à un Diagnostic.""" code = llm_result.get("code") confidence = llm_result.get("confidence") justification = llm_result.get("justification") raisonnement = llm_result.get("raisonnement") if code: code = normalize_code(code) is_valid, _ = cim10_validate(code) if is_valid: diagnostic.cim10_suggestion = code else: # Tenter fallback vers le code parent (D71.9 → D71) parent = fallback_parent_code(code) if parent: logger.info( "RAG : code Ollama %s invalide → fallback parent %s pour « %s »", code, parent, diagnostic.texte, ) diagnostic.cim10_suggestion = parent else: logger.warning( "RAG : code Ollama %s invalide pour « %s », code ignoré", code, diagnostic.texte, ) if confidence in ("high", "medium", "low"): diagnostic.cim10_confidence = confidence if justification: diagnostic.justification = justification if raisonnement: diagnostic.raisonnement = raisonnement # Stocker les preuves cliniques preuves = llm_result.get("preuves_cliniques", []) if preuves and isinstance(preuves, list): for p in preuves: if isinstance(p, dict) and p.get("element"): try: diagnostic.preuves_cliniques.append(PreuveClinique( type=p.get("type", "clinique"), element=p["element"], interpretation=p.get("interpretation", ""), )) except Exception: pass def enrich_diagnostic( diagnostic: Diagnostic, contexte: dict, est_dp: bool = True, cache: OllamaCache | None = None, ) -> None: """Enrichit un Diagnostic avec le RAG (FAISS + Ollama). Modifie le diagnostic en place. Fallback gracieux si FAISS ou Ollama échouent. """ diag_type = "dp" if est_dp else "das" # 1. Vérifier le cache cached = cache.get(diagnostic.texte, diag_type) if cache else None # 2. Recherche FAISS (toujours, pour les sources_rag fraîches) sources = search_similar(diagnostic.texte, top_k=10) if not sources: logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte) return # 3. Stocker les sources RAG diagnostic.sources_rag = [ RAGSource( document=s["document"], page=s.get("page"), code=s.get("code"), extrait=s.get("extrait", "")[:200], ) for s in sources ] # 4. Si cache hit, appliquer et court-circuiter Ollama if cached is not None: logger.info("Cache hit pour %s : « %s »", diag_type.upper(), diagnostic.texte) _apply_llm_result_diagnostic(diagnostic, cached) return # 5. Appel Ollama pour justification avec raisonnement structuré prompt = _build_prompt(diagnostic.texte, sources, contexte, est_dp=est_dp) llm_result = _call_ollama(prompt) if llm_result: _apply_llm_result_diagnostic(diagnostic, llm_result) if cache: cache.put(diagnostic.texte, diag_type, llm_result) else: logger.info("Ollama non disponible — sources FAISS conservées sans justification LLM") def _apply_llm_result_acte(acte: ActeCCAM, llm_result: dict) -> None: """Applique un résultat LLM (frais ou caché) à un ActeCCAM.""" code = llm_result.get("code") confidence = llm_result.get("confidence") justification = llm_result.get("justification") raisonnement = llm_result.get("raisonnement") if code: code = code.strip().upper() is_valid, _ = ccam_validate(code) if is_valid: acte.code_ccam_suggestion = code else: logger.warning( "RAG : code CCAM Ollama %s invalide pour « %s », code ignoré", code, acte.texte, ) if confidence in ("high", "medium", "low"): acte.ccam_confidence = confidence if justification: acte.justification = justification if raisonnement: acte.raisonnement = raisonnement def enrich_acte(acte: ActeCCAM, contexte: dict, cache: OllamaCache | None = None) -> None: """Enrichit un ActeCCAM avec le RAG (FAISS + Ollama). Modifie l'acte en place. Fallback gracieux si FAISS ou Ollama échouent. """ # 1. Vérifier le cache cached = cache.get(acte.texte, "ccam") if cache else None # 2. Recherche FAISS (sources CCAM priorisées) sources = search_similar_ccam(acte.texte, top_k=8) if not sources: logger.debug("Aucune source RAG CCAM trouvée pour : %s", acte.texte) return # 3. Stocker les sources RAG acte.sources_rag = [ RAGSource( document=s["document"], page=s.get("page"), code=s.get("code"), extrait=s.get("extrait", "")[:200], ) for s in sources ] # 4. Si cache hit, appliquer et court-circuiter Ollama if cached is not None: logger.info("Cache hit pour CCAM : « %s »", acte.texte) _apply_llm_result_acte(acte, cached) return # 5. Appel Ollama pour justification avec raisonnement structuré prompt = _build_prompt_ccam(acte.texte, sources, contexte) llm_result = _call_ollama(prompt) if llm_result: _apply_llm_result_acte(acte, llm_result) if cache: cache.put(acte.texte, "ccam", llm_result) else: logger.info("Ollama non disponible — sources FAISS CCAM conservées sans justification LLM") def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[str], dp_texte: str) -> str: """Construit le prompt pour l'extraction LLM de DAS supplémentaires.""" ctx_str = format_enriched_context(contexte) existing_str = "\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun" return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. Analyse le texte médical suivant et identifie les diagnostics associés significatifs (DAS) qui n'ont PAS encore été codés. RÈGLES IMPÉRATIVES : - Un DAS doit avoir mobilisé des ressources supplémentaires pendant le séjour - Ne PAS proposer de doublons avec les DAS déjà codés ci-dessous - Ne PAS proposer le diagnostic principal comme DAS - Ne PAS coder les symptômes (R00-R99) si un diagnostic précis les explique - Ne PAS coder les antécédents non pertinents pour le séjour - Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère) - Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte - ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale. DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"} DAS DÉJÀ CODÉS : {existing_str} CONTEXTE CLINIQUE : {ctx_str} TEXTE MÉDICAL : {text[:4000]} Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après : {{ "diagnostics_supplementaires": [ {{ "texte": "description du diagnostic", "code_cim10": "X99.9", "justification": "pourquoi ce DAS est pertinent pour le séjour" }} ] }} Si aucun DAS supplémentaire n'est pertinent, retourne : {{"diagnostics_supplementaires": []}}""" def extract_das_llm( text: str, contexte: dict, existing_das: list[str], dp_texte: str, cache: OllamaCache | None = None, ) -> list[dict]: """Extrait des DAS supplémentaires via un pass LLM. Args: text: Texte médical complet. contexte: Contexte patient (sexe, age, etc.). existing_das: Liste des DAS déjà codés (texte + code). dp_texte: Texte du diagnostic principal. cache: Cache Ollama optionnel. Returns: Liste de dicts {texte, code_cim10, justification} pour les DAS détectés. """ import hashlib # Clé de cache basée sur le hash du texte text_hash = hashlib.md5(text[:4000].encode()).hexdigest()[:16] cache_key_text = f"das_extract::{text_hash}" # Vérifier le cache if cache is not None: cached = cache.get(cache_key_text, "das_llm") if cached is not None: logger.info("Cache hit pour extraction DAS LLM") return cached.get("diagnostics_supplementaires", []) # Construire le prompt et appeler Ollama prompt = _build_prompt_das_extraction(text, contexte, existing_das, dp_texte) result = call_ollama(prompt, temperature=0.1, max_tokens=2000) if result is None: logger.warning("Extraction DAS LLM : Ollama non disponible") return [] das_list = result.get("diagnostics_supplementaires", []) if not isinstance(das_list, list): logger.warning("Extraction DAS LLM : format inattendu") return [] # Stocker dans le cache if cache is not None: cache.put(cache_key_text, "das_llm", result) logger.info("Extraction DAS LLM : %d diagnostics supplémentaires détectés", len(das_list)) return das_list def enrich_dossier(dossier: DossierMedical) -> None: """Enrichit le DP et tous les DAS d'un dossier via le RAG. Utilise un cache persistant et parallélise les appels Ollama pour les DAS et actes CCAM (max_workers = OLLAMA_MAX_PARALLEL). """ cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL) contexte = build_enriched_context(dossier) # Phase 1 : DP seul (le contexte DAS en dépend) if dossier.diagnostic_principal: logger.info("RAG enrichissement DP : %s", dossier.diagnostic_principal.texte) enrich_diagnostic(dossier.diagnostic_principal, contexte, est_dp=True, cache=cache) # Mettre à jour le contexte avec le DP pour les DAS if dossier.diagnostic_principal: contexte["dp_texte"] = dossier.diagnostic_principal.texte contexte["das_codes_existants"] = [ f"{d.cim10_suggestion} ({d.texte})" for d in dossier.diagnostics_associes if d.cim10_suggestion ] # Phase 2 : DAS + Actes en parallèle das_list = dossier.diagnostics_associes actes_list = dossier.actes_ccam if das_list or actes_list: with ThreadPoolExecutor(max_workers=OLLAMA_MAX_PARALLEL) as executor: futures = [] for das in das_list: logger.info("RAG enrichissement DAS : %s", das.texte) futures.append(executor.submit(enrich_diagnostic, das, contexte, False, cache)) for acte in actes_list: logger.info("RAG enrichissement CCAM : %s", acte.texte) futures.append(executor.submit(enrich_acte, acte, contexte, cache)) for f in as_completed(futures): f.result() # propage les exceptions cache.save()