From 50a77c9f61f80ce77059bc25ed6cf003851ea4a2 Mon Sep 17 00:00:00 2001 From: dom Date: Sun, 15 Feb 2026 11:08:15 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20RAG=20CPAM=20d=C3=A9di=C3=A9=20avec=20r?= =?UTF-8?q?equ=C3=AAtes=20multi-cibl=C3=A9es=20+=20prompt=203=20axes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouvelle search_similar_cpam() : priorité Guide Méthodo, seuil 0.40, déduplication par code CIM-10, fetch élargi top_k*3 - _search_rag_for_control() refactoré : 2-3 requêtes ciblées (codes contestés, argument CPAM, contexte clinique) au lieu d'1 fourre-tout - Fusion dédupliquée par (document, code, page), top 10 résultats - Prompt CPAM enrichi : 3 axes (médical, asymétrie, réglementaire), section asymétrie d'information, format réponse structuré - 9 nouveaux tests unitaires pour la logique RAG multi-requêtes Élimine les sources CIM-10 hors-sujet (F45, F98.1, F50.5 sur pancréatite) au profit de résultats Guide Méthodo et référentiels pertinents. Co-Authored-By: Claude Opus 4.6 --- src/control/cpam_response.py | 194 +++++++++++++++++---- src/medical/rag_search.py | 69 ++++++++ tests/test_cpam_response.py | 316 +++++++++++++++++++++++++++++++++-- 3 files changed, 536 insertions(+), 43 deletions(-) diff --git a/src/control/cpam_response.py b/src/control/cpam_response.py index 825457a..17c9281 100644 --- a/src/control/cpam_response.py +++ b/src/control/cpam_response.py @@ -11,35 +11,75 @@ logger = logging.getLogger(__name__) def _search_rag_for_control(controle: ControleCPAM, dossier: DossierMedical) -> list[dict]: - """Recherche RAG ciblée pour le sujet du désaccord.""" + """Recherche RAG ciblée pour le sujet du désaccord. + + Effectue 2-3 recherches ciblées au lieu d'une requête fourre-tout : + 1. Codes contestés → règles de codage spécifiques + 2. Argument CPAM → passages Guide Méthodo contradictoires + 3. Contexte clinique (optionnel) → définitions CIM-10 des codes en jeu + """ try: - from ..medical.rag_search import search_similar + from ..medical.rag_search import search_similar_cpam except Exception: logger.warning("Index RAG non disponible pour la contre-argumentation") return [] - # Construire une requête combinant l'argument CPAM et le diagnostic concerné - query_parts = [] + all_results: list[dict] = [] + # Requête 1 — Codes contestés (règles de codage) + if controle.dp_ucr or controle.da_ucr: + query_parts = [] + if controle.dp_ucr: + query_parts.append(f"règles codage {controle.dp_ucr} diagnostic principal") + if controle.da_ucr: + query_parts.append(f"diagnostic associé significatif {controle.da_ucr} CMA") + query_codes = " ".join(query_parts) + results_codes = search_similar_cpam(query_codes, top_k=6) + logger.debug(" RAG requête codes : %d résultats", len(results_codes)) + all_results.extend(results_codes) + + # Requête 2 — Argument CPAM (recherche dans le Guide Méthodo) + query_parts_arg = [] if controle.titre: - query_parts.append(controle.titre) - - # Ajouter les codes contestés pour cibler la recherche - if controle.dp_ucr: - query_parts.append(f"diagnostic principal {controle.dp_ucr}") - if controle.da_ucr: - query_parts.append(f"diagnostic associé {controle.da_ucr}") - - # Tronquer l'argument CPAM pour ne garder que le coeur - arg_short = controle.arg_ucr[:300] if controle.arg_ucr else "" + query_parts_arg.append(controle.titre) + arg_short = controle.arg_ucr[:200] if controle.arg_ucr else "" if arg_short: - query_parts.append(arg_short) + query_parts_arg.append(arg_short) + query_arg = " ".join(query_parts_arg) + if query_arg.strip(): + results_arg = search_similar_cpam(query_arg, top_k=6) + logger.debug(" RAG requête argument : %d résultats", len(results_arg)) + all_results.extend(results_arg) - query = " ".join(query_parts) - if not query.strip(): + # Requête 3 — Contexte clinique (définitions CIM-10 des codes en jeu) + if controle.da_ucr and dossier.diagnostic_principal: + dp_text = dossier.diagnostic_principal.texte + das_texts = [ + d.texte for d in dossier.diagnostics_associes + if d.cim10_suggestion and controle.da_ucr + and d.cim10_suggestion in controle.da_ucr + ] + if das_texts: + query_clinique = f"{dp_text} {' '.join(das_texts)}" + results_clinique = search_similar_cpam(query_clinique, top_k=4) + logger.debug(" RAG requête clinique : %d résultats", len(results_clinique)) + all_results.extend(results_clinique) + + if not all_results: return [] - return search_similar(query, top_k=8) + # Fusion : dédupliquer par (document, code, page), garder le meilleur score + seen: dict[tuple, dict] = {} + for r in all_results: + key = (r.get("document"), r.get("code"), r.get("page")) + if key in seen: + if r["score"] > seen[key]["score"]: + seen[key] = r + else: + seen[key] = r + + merged = sorted(seen.values(), key=lambda r: r["score"], reverse=True) + return merged[:10] def _build_cpam_prompt( @@ -78,17 +118,80 @@ def _build_cpam_prompt( if sejour.age is not None: patient_info.append(f"{sejour.age} ans") dossier_lines.append(f"- Patient : {', '.join(patient_info)}") + if sejour.imc is not None: + dossier_lines.append(f"- IMC : {sejour.imc}") if dossier.biologie_cle: bio = [f"{b.test}: {b.valeur}" for b in dossier.biologie_cle[:5] if b.valeur] if bio: dossier_lines.append(f"- Biologie clé : {', '.join(bio)}") + if dossier.imagerie: + img_parts = [] + for im in dossier.imagerie: + conclusion = f" — {im.conclusion}" if im.conclusion else "" + img_parts.append(f"{im.type}{conclusion}") + dossier_lines.append(f"- Imagerie : {', '.join(img_parts)}") + + if dossier.traitements_sortie: + trt_parts = [] + for t in dossier.traitements_sortie[:10]: + posologie = f" {t.posologie}" if t.posologie else "" + trt_parts.append(f"{t.medicament}{posologie}") + dossier_lines.append(f"- Traitements de sortie : {', '.join(trt_parts)}") + + if dossier.antecedents: + dossier_lines.append(f"- Antécédents : {', '.join(dossier.antecedents[:10])}") + if dossier.complications: dossier_lines.append(f"- Complications : {', '.join(dossier.complications)}") dossier_str = "\n".join(dossier_lines) if dossier_lines else "Non disponible" + # Section asymétrie : éléments que la CPAM n'avait pas + asymetrie_lines = [] + + if dossier.biologie_cle: + bio_details = [] + for b in dossier.biologie_cle if len(dossier.biologie_cle) <= 10 else dossier.biologie_cle[:10]: + anomalie = " (anormale)" if b.anomalie else "" + if b.valeur: + bio_details.append(f"{b.test}: {b.valeur}{anomalie}") + if bio_details: + asymetrie_lines.append(f"- Biologie : {', '.join(bio_details)}") + + if dossier.imagerie: + img_details = [] + for im in dossier.imagerie: + conclusion = f" — {im.conclusion}" if im.conclusion else "" + img_details.append(f"{im.type}{conclusion}") + if img_details: + asymetrie_lines.append(f"- Imagerie : {', '.join(img_details)}") + + if dossier.traitements_sortie: + trt_details = [] + for t in dossier.traitements_sortie[:10]: + posologie = f" {t.posologie}" if t.posologie else "" + trt_details.append(f"{t.medicament}{posologie}") + if trt_details: + asymetrie_lines.append(f"- Traitements : {', '.join(trt_details)}") + + if dossier.actes_ccam: + actes_details = [ + f"{a.texte} ({a.code_ccam_suggestion})" if a.code_ccam_suggestion else a.texte + for a in dossier.actes_ccam + ] + if actes_details: + asymetrie_lines.append(f"- Actes CCAM : {', '.join(actes_details)}") + + asymetrie_str = "" + if asymetrie_lines: + asymetrie_str = ( + "\n\nÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM " + "(l'UCR n'a eu que le CRH et les codes) :\n" + + "\n".join(asymetrie_lines) + ) + # Codes contestés par la CPAM codes_contestes = [] if controle.dp_ucr: @@ -118,10 +221,11 @@ def _build_cpam_prompt( sources_text += (src.get("extrait", "")[:800]) + "\n\n" return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A. -Tu dois contre-argumenter la décision de la CPAM (UCR) point par point, en t'appuyant sur le guide méthodologique et la CIM-10. +Tu dois contre-argumenter la décision de la CPAM (UCR) en mobilisant trois axes : médical, asymétrie d'information, et réglementaire. DOSSIER MÉDICAL DE L'ÉTABLISSEMENT : {dossier_str} +{asymetrie_str} OBJET DU DÉSACCORD : {controle.titre} @@ -137,18 +241,31 @@ SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) : {sources_text} CONSIGNES : -- Analyse objectivement l'argument de la CPAM -- Identifie les points où la CPAM a raison (le cas échéant) -- Contre-argumente point par point en citant le guide méthodologique et la CIM-10 -- Cite les références précises (pages, articles, fascicules) -- Propose une conclusion et la position recommandée + +AXE MÉDICAL : +- Analyse le bien-fondé médical du codage de l'établissement +- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies +- Identifie les points où la CPAM a éventuellement raison + +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 + +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 +- 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", - "points_accord": "Points où la CPAM a raison (ou 'Aucun' si non applicable)", - "contre_arguments": "Arguments point par point en faveur de l'établissement", - "references": "Références guide méthodologique / CIM-10 citées", + "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", + "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)", "conclusion": "Synthèse et position recommandée" }}""" @@ -165,9 +282,24 @@ def _format_response(parsed: dict) -> str: if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""): sections.append(f"POINTS D'ACCORD\n{accord}") - contre = parsed.get("contre_arguments") - if contre: - sections.append(f"CONTRE-ARGUMENTS\n{contre}") + # Nouveaux champs structurés par axe + contre_med = parsed.get("contre_arguments_medicaux") + if contre_med: + sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{contre_med}") + + contre_asym = parsed.get("contre_arguments_asymetrie") + if contre_asym: + sections.append(f"ASYMÉTRIE D'INFORMATION\n{contre_asym}") + + contre_regl = parsed.get("contre_arguments_reglementaires") + if contre_regl: + sections.append(f"CONTRE-ARGUMENTS RÉGLEMENTAIRES\n{contre_regl}") + + # Fallback : ancien champ unique (réponses en cache existantes) + if not contre_med and not contre_asym and not contre_regl: + contre = parsed.get("contre_arguments") + if contre: + sections.append(f"CONTRE-ARGUMENTS\n{contre}") refs = parsed.get("references") if refs: diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index a8a8621..22395f6 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -23,6 +23,8 @@ _embed_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(): @@ -149,6 +151,73 @@ def search_similar_ccam(query: str, top_k: int = 8) -> list[dict]: 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) + + # Prioriser le Guide Méthodologique (min 3 résultats) + guide_results = [r for r in deduped if r["document"] == "guide_methodo"] + other_results = [r for r in deduped 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 = [] diff --git a/tests/test_cpam_response.py b/tests/test_cpam_response.py index 192d91a..f726379 100644 --- a/tests/test_cpam_response.py +++ b/tests/test_cpam_response.py @@ -1,11 +1,26 @@ """Tests pour la génération de contre-argumentation CPAM.""" -from unittest.mock import patch +from unittest.mock import patch, call import pytest -from src.config import ControleCPAM, Diagnostic, DossierMedical, RAGSource, Sejour -from src.control.cpam_response import _build_cpam_prompt, _format_response, generate_cpam_response +from src.config import ( + ActeCCAM, + BiologieCle, + ControleCPAM, + Diagnostic, + DossierMedical, + Imagerie, + RAGSource, + Sejour, + Traitement, +) +from src.control.cpam_response import ( + _build_cpam_prompt, + _format_response, + _search_rag_for_control, + generate_cpam_response, +) def _make_dossier() -> DossierMedical: @@ -24,6 +39,37 @@ def _make_dossier() -> DossierMedical: ) +def _make_dossier_complet() -> DossierMedical: + """Crée un dossier médical enrichi avec traitements, imagerie, antécédents.""" + return DossierMedical( + source_file="test.pdf", + document_type="crh", + sejour=Sejour(sexe="M", age=65, duree_sejour=5, imc=31.2), + diagnostic_principal=Diagnostic( + texte="Cholécystite aiguë", + cim10_suggestion="K81.0", + ), + diagnostics_associes=[ + Diagnostic(texte="Iléus réflexe", cim10_suggestion="K56.0"), + ], + actes_ccam=[ + ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004"), + ], + biologie_cle=[ + BiologieCle(test="CRP", valeur="180 mg/L", anomalie=True), + BiologieCle(test="Créatinine", valeur="450 µmol/L", anomalie=True), + ], + imagerie=[ + Imagerie(type="Scanner abdominal", conclusion="Lithiase cholédocienne confirmée"), + ], + traitements_sortie=[ + Traitement(medicament="Augmentin IV", posologie="3g/j"), + Traitement(medicament="Morphine SC"), + ], + antecedents=["HTA", "Diabète type 2"], + ) + + def _make_controle() -> ControleCPAM: """Crée un contrôle CPAM de test.""" return ControleCPAM( @@ -75,32 +121,115 @@ class TestBuildPrompt: assert "CIM-10 FR 2026" in prompt assert "page 64" in prompt + def test_prompt_contains_three_axes(self): + dossier = _make_dossier() + controle = _make_controle() + prompt = _build_cpam_prompt(dossier, controle, []) + + assert "AXE MÉDICAL" in prompt + assert "AXE ASYMÉTRIE D'INFORMATION" in prompt + assert "AXE RÉGLEMENTAIRE" in prompt + + def test_prompt_contains_traitements_imagerie_when_present(self): + dossier = _make_dossier_complet() + controle = _make_controle() + prompt = _build_cpam_prompt(dossier, controle, []) + + assert "Augmentin IV 3g/j" in prompt + assert "Morphine SC" in prompt + assert "Scanner abdominal" in prompt + assert "Lithiase cholédocienne confirmée" in prompt + assert "HTA" in prompt + assert "Diabète type 2" in prompt + assert "IMC : 31.2" in prompt + + def test_prompt_asymetrie_section_when_data_present(self): + dossier = _make_dossier_complet() + controle = _make_controle() + prompt = _build_cpam_prompt(dossier, controle, []) + + assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" in prompt + assert "CRP: 180 mg/L (anormale)" in prompt + assert "Cholécystectomie (HMFC004)" in prompt + + def test_prompt_no_asymetrie_section_when_no_data(self): + dossier = DossierMedical( + source_file="test.pdf", + document_type="crh", + sejour=Sejour(), + diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"), + ) + controle = _make_controle() + prompt = _build_cpam_prompt(dossier, controle, []) + + assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" not in prompt + + def test_prompt_json_format_new_fields(self): + dossier = _make_dossier() + controle = _make_controle() + prompt = _build_cpam_prompt(dossier, controle, []) + + assert "contre_arguments_medicaux" in prompt + assert "contre_arguments_asymetrie" in prompt + assert "contre_arguments_reglementaires" in prompt + class TestFormatResponse: - def test_full_response(self): + def test_full_response_new_format(self): parsed = { "analyse_contestation": "La CPAM conteste le DAS K56.0", "points_accord": "Aucun", - "contre_arguments": "Le guide méthodologique précise...", - "references": "Guide métho p.64", + "contre_arguments_medicaux": "Le guide méthodologique précise...", + "contre_arguments_asymetrie": "La biologie montre une CRP à 180...", + "contre_arguments_reglementaires": "L'UCR interprète restrictivement...", + "references": "Guide métho p.64, CIM-10 K56.0", "conclusion": "Le DAS est justifié", } text = _format_response(parsed) assert "ANALYSE DE LA CONTESTATION" in text - assert "CONTRE-ARGUMENTS" in text + assert "CONTRE-ARGUMENTS MÉDICAUX" in text + assert "ASYMÉTRIE D'INFORMATION" in text + assert "CONTRE-ARGUMENTS RÉGLEMENTAIRES" in text + assert "REFERENCES" in text assert "CONCLUSION" in text # "Aucun" ne doit pas générer la section points d'accord assert "POINTS D'ACCORD" not in text + # L'ancien champ ne doit pas apparaître + assert "CONTRE-ARGUMENTS\n" not in text - def test_partial_response(self): + def test_fallback_old_format(self): + """L'ancien champ contre_arguments est toujours géré (réponses en cache).""" parsed = { - "contre_arguments": "Arguments...", + "analyse_contestation": "Analyse...", + "contre_arguments": "Arguments anciens...", "conclusion": "Conclusion...", } text = _format_response(parsed) - assert "CONTRE-ARGUMENTS" in text + assert "CONTRE-ARGUMENTS\nArguments anciens..." in text + assert "CONCLUSION" in text + + def test_new_fields_override_fallback(self): + """Si les nouveaux champs existent, l'ancien contre_arguments est ignoré.""" + parsed = { + "contre_arguments_medicaux": "Médicaux...", + "contre_arguments": "Ancien fallback...", + "conclusion": "Conclusion...", + } + text = _format_response(parsed) + + assert "CONTRE-ARGUMENTS MÉDICAUX" in text + assert "Ancien fallback" not in text + + def test_partial_response(self): + parsed = { + "contre_arguments_medicaux": "Arguments médicaux...", + "conclusion": "Conclusion...", + } + text = _format_response(parsed) + + assert "CONTRE-ARGUMENTS MÉDICAUX" in text assert "CONCLUSION" in text def test_empty_response(self): @@ -117,7 +246,8 @@ class TestGenerateResponse: ] mock_ollama.return_value = { "analyse_contestation": "Analyse...", - "contre_arguments": "Contre-arguments...", + "contre_arguments_medicaux": "Contre-arguments médicaux...", + "contre_arguments_asymetrie": "Asymétrie...", "conclusion": "Conclusion...", } @@ -126,7 +256,7 @@ class TestGenerateResponse: text, sources = generate_cpam_response(dossier, controle) - assert "Contre-arguments..." in text + assert "Contre-arguments médicaux..." in text assert len(sources) == 1 assert sources[0].document == "guide_methodo" mock_ollama.assert_called_once() @@ -144,3 +274,165 @@ class TestGenerateResponse: assert text == "" assert sources == [] + + +class TestSearchRagForControl: + """Tests pour la logique de recherche RAG multi-requêtes.""" + + @patch("src.medical.rag_search.search_similar_cpam") + def test_multiple_queries_with_da_ucr(self, mock_search): + """Avec da_ucr, on doit avoir au moins 2 requêtes (codes + argument).""" + mock_search.return_value = [ + {"document": "guide_methodo", "page": 10, "code": None, "score": 0.6, + "extrait": "Texte guide"}, + ] + + dossier = _make_dossier() + controle = _make_controle() # da_ucr="K56.0" + + results = _search_rag_for_control(controle, dossier) + + # Au moins 2 appels : codes contestés + argument CPAM + assert mock_search.call_count >= 2 + + @patch("src.medical.rag_search.search_similar_cpam") + def test_query_codes_contains_cma(self, mock_search): + """La requête codes contestés doit contenir 'CMA' pour un DA.""" + mock_search.return_value = [] + + dossier = _make_dossier() + controle = _make_controle() # da_ucr="K56.0" + + _search_rag_for_control(controle, dossier) + + # Premier appel = requête codes + first_call_query = mock_search.call_args_list[0][0][0] + assert "K56.0" in first_call_query + assert "CMA" in first_call_query + + @patch("src.medical.rag_search.search_similar_cpam") + def test_query_argument_contains_titre(self, mock_search): + """La requête argument doit contenir le titre du contrôle.""" + mock_search.return_value = [] + + dossier = _make_dossier() + controle = _make_controle() + + _search_rag_for_control(controle, dossier) + + # Deuxième appel = requête argument + second_call_query = mock_search.call_args_list[1][0][0] + assert controle.titre in second_call_query + + @patch("src.medical.rag_search.search_similar_cpam") + def test_deduplication_by_document_code_page(self, mock_search): + """Les résultats dupliqués (même document/code/page) sont fusionnés.""" + # Les deux requêtes retournent le même résultat + shared_result = { + "document": "guide_methodo", "page": 64, "code": None, + "score": 0.55, "extrait": "Texte partagé", + } + mock_search.return_value = [shared_result.copy()] + + dossier = _make_dossier() + controle = _make_controle() + + results = _search_rag_for_control(controle, dossier) + + # Le résultat ne doit apparaître qu'une seule fois malgré les requêtes multiples + guide_results = [r for r in results if r["document"] == "guide_methodo" and r.get("page") == 64] + assert len(guide_results) == 1 + + @patch("src.medical.rag_search.search_similar_cpam") + def test_dedup_keeps_best_score(self, mock_search): + """La déduplication garde le meilleur score.""" + def side_effect(query, top_k=8): + if "CMA" in query: + return [{"document": "cim10", "code": "K56.0", "page": None, + "score": 0.5, "extrait": "Iléus"}] + else: + return [{"document": "cim10", "code": "K56.0", "page": None, + "score": 0.7, "extrait": "Iléus"}] + mock_search.side_effect = side_effect + + dossier = _make_dossier() + controle = _make_controle() + + results = _search_rag_for_control(controle, dossier) + + k56_results = [r for r in results if r.get("code") == "K56.0"] + assert len(k56_results) == 1 + assert k56_results[0]["score"] == 0.7 + + @patch("src.medical.rag_search.search_similar_cpam") + def test_no_codes_contestes_only_argument_query(self, mock_search): + """Sans codes contestés, seule la requête argument est lancée.""" + mock_search.return_value = [] + + dossier = _make_dossier() + controle = ControleCPAM( + numero_ogc=1, + titre="Désaccord sur la durée", + arg_ucr="Séjour trop long selon l'UCR.", + decision_ucr="Rejet", + dp_ucr=None, + da_ucr=None, + ) + + _search_rag_for_control(controle, dossier) + + # Un seul appel : requête argument (pas de codes contestés) + assert mock_search.call_count == 1 + + @patch("src.medical.rag_search.search_similar_cpam") + def test_dp_ucr_query_contains_diagnostic_principal(self, mock_search): + """Avec dp_ucr, la requête codes mentionne 'diagnostic principal'.""" + mock_search.return_value = [] + + dossier = _make_dossier() + controle = ControleCPAM( + numero_ogc=2, + titre="Désaccord sur le DP", + arg_ucr="Le DP devrait être K80.1", + decision_ucr="Rejet", + dp_ucr="K81.0", + da_ucr=None, + ) + + _search_rag_for_control(controle, dossier) + + first_call_query = mock_search.call_args_list[0][0][0] + assert "K81.0" in first_call_query + assert "diagnostic principal" in first_call_query + + @patch("src.medical.rag_search.search_similar_cpam") + def test_max_10_results(self, mock_search): + """Le résultat final est limité à 10 entrées.""" + mock_search.return_value = [ + {"document": "guide_methodo", "page": i, "code": None, + "score": 0.9 - i * 0.01, "extrait": f"Texte {i}"} + for i in range(8) + ] + + dossier = _make_dossier() + controle = _make_controle() + + results = _search_rag_for_control(controle, dossier) + + assert len(results) <= 10 + + @patch("src.medical.rag_search.search_similar_cpam") + def test_clinical_query_when_das_match(self, mock_search): + """Requête clinique lancée quand da_ucr matche un DAS du dossier.""" + mock_search.return_value = [] + + dossier = _make_dossier() # DAS K56.0 "Iléus réflexe" + controle = _make_controle() # da_ucr="K56.0" + + _search_rag_for_control(controle, dossier) + + # 3 appels : codes + argument + clinique + assert mock_search.call_count == 3 + third_call_query = mock_search.call_args_list[2][0][0] + assert "Iléus réflexe" in third_call_query + assert "Cholécystite aiguë" in third_call_query