diff --git a/src/config.py b/src/config.py index 4811ae8..934bb32 100644 --- a/src/config.py +++ b/src/config.py @@ -31,7 +31,7 @@ NER_CONFIDENCE_THRESHOLD = 0.80 # --- Configuration Ollama --- OLLAMA_URL = "http://localhost:11434" -OLLAMA_MODEL = "mistral-large-3:675b-cloud" +OLLAMA_MODEL = "gemma3:12b" OLLAMA_TIMEOUT = 120 diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index dbb8fd9..223358a 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -4,8 +4,6 @@ from __future__ import annotations import json import logging -import re -from typing import Optional import requests @@ -19,9 +17,6 @@ _embed_model = None # Score minimum de similarité FAISS pour retenir un résultat _MIN_SCORE = 0.3 -# Marqueur de fin de raisonnement dans la réponse Ollama -_RESULT_MARKER = "###RESULT###" - def _get_embed_model(): """Charge le modèle d'embedding (singleton).""" @@ -186,100 +181,88 @@ CONTEXTE CLINIQUE : SOURCES CIM-10 : {sources_text} -RAISONNE ÉTAPE PAR ÉTAPE : -1. ANALYSE CLINIQUE : Que signifie ce diagnostic sur le plan médical ? -2. CODES CANDIDATS : Quels codes des sources fournies sont compatibles ? -3. DISCRIMINATION : Pourquoi choisir un code plutôt qu'un autre ? (inclusions/exclusions, spécificité) -4. RÈGLE PMSI : Ce code est-il conforme pour un {type_diag} ? (guide méthodologique) - -Après ton raisonnement, conclus OBLIGATOIREMENT par le JSON suivant sur une ligne séparée : -{_RESULT_MARKER} -{{"code": "X99.9", "confidence": "high|medium|low", "justification": "explication courte en français"}}""" +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" +}}""" def _parse_ollama_response(raw: str) -> dict | None: - """Parse la réponse Ollama en extrayant le JSON après le marqueur ###RESULT###. + """Parse la réponse JSON d'Ollama (mode JSON). - Fallback sur la recherche d'accolades si le marqueur est absent. - Retourne un dict avec les clés code/confidence/justification + raisonnement. + Reconstitue le raisonnement à partir des champs structurés. """ - raisonnement = None - json_str = None - - # Stratégie 1 : chercher le marqueur ###RESULT### - marker_pos = raw.find(_RESULT_MARKER) - if marker_pos != -1: - raisonnement = raw[:marker_pos].strip() - after_marker = raw[marker_pos + len(_RESULT_MARKER):] - brace_start = after_marker.find("{") - brace_end = after_marker.rfind("}") - if brace_start != -1 and brace_end != -1: - json_str = after_marker[brace_start:brace_end + 1] - else: - # Fallback : chercher le dernier bloc JSON dans la réponse - # (le raisonnement peut contenir des accolades intermédiaires) - last_brace = raw.rfind("}") - if last_brace != -1: - # Chercher l'accolade ouvrante correspondante en remontant - depth = 0 - start = -1 - for i in range(last_brace, -1, -1): - if raw[i] == "}": - depth += 1 - elif raw[i] == "{": - depth -= 1 - if depth == 0: - start = i - break - if start != -1: - json_str = raw[start:last_brace + 1] - raisonnement = raw[:start].strip() - - if not json_str: - logger.warning("Ollama : réponse sans JSON valide : %s", raw[:200]) - return None + # Stripper les blocs markdown ```json ... ``` que certains modèles ajoutent + text = raw.strip() + if text.startswith("```"): + first_nl = text.find("\n") + if first_nl != -1: + text = text[first_nl + 1:] + # Retirer la fence fermante seulement si elle existe en fin de texte + if text.rstrip().endswith("```"): + text = text.rstrip()[:-3] + text = text.strip() try: - parsed = json.loads(json_str) + parsed = json.loads(text) except json.JSONDecodeError: - logger.warning("Ollama : JSON invalide : %s", json_str[:200]) + logger.warning("Ollama : JSON invalide : %s", raw[:200]) return None - if raisonnement: - parsed["raisonnement"] = raisonnement + # Reconstituer le raisonnement à partir des champs structurés + reasoning_parts = [] + for key in ("analyse_clinique", "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 et parse la réponse JSON.""" - try: - response = requests.post( - f"{OLLAMA_URL}/api/generate", - json={ - "model": OLLAMA_MODEL, - "prompt": prompt, - "stream": False, - "options": { - "temperature": 0.1, - "num_predict": 1200, + """Appelle Ollama (mode JSON) et parse la réponse. Retry une fois si parsing échoue.""" + for attempt in range(2): + try: + response = requests.post( + f"{OLLAMA_URL}/api/generate", + json={ + "model": OLLAMA_MODEL, + "prompt": prompt, + "stream": False, + "format": "json", + "options": { + "temperature": 0.1, + "num_predict": 2500, + }, }, - }, - timeout=OLLAMA_TIMEOUT, - ) - response.raise_for_status() - raw = response.json().get("response", "") - return _parse_ollama_response(raw) - - except requests.ConnectionError: - logger.warning("Ollama non disponible (connexion refusée)") - return None - except requests.Timeout: - logger.warning("Ollama timeout après %ds", OLLAMA_TIMEOUT) - return None - except (requests.RequestException, json.JSONDecodeError) as e: - logger.warning("Ollama erreur : %s", e) - return None + timeout=OLLAMA_TIMEOUT, + ) + response.raise_for_status() + raw = response.json().get("response", "") + result = _parse_ollama_response(raw) + if result is not None: + return result + if attempt == 0: + logger.info("Ollama : retry après échec de parsing") + except requests.ConnectionError: + logger.warning("Ollama non disponible (connexion refusée)") + return None + except requests.Timeout: + logger.warning("Ollama timeout après %ds", OLLAMA_TIMEOUT) + return None + except (requests.RequestException, json.JSONDecodeError) as e: + logger.warning("Ollama erreur : %s", e) + return None + return None def enrich_diagnostic( diff --git a/tests/test_rag.py b/tests/test_rag.py index 9339783..27d39dc 100644 --- a/tests/test_rag.py +++ b/tests/test_rag.py @@ -226,18 +226,22 @@ class TestChunkingCCAM: class TestParseOllamaResponse: - """Tests pour _parse_ollama_response avec le marqueur ###RESULT###.""" + """Tests pour _parse_ollama_response en mode JSON structuré.""" - def test_parse_with_marker(self): + def test_parse_structured_json(self): + """Le mode JSON retourne un objet avec champs de raisonnement.""" from src.medical.rag_search import _parse_ollama_response + import json - raw = """1. ANALYSE CLINIQUE : La pancréatite aiguë biliaire est une inflammation... -2. CODES CANDIDATS : K85.0, K85.1, K85.9 -3. DISCRIMINATION : K85.1 est spécifique à l'origine biliaire -4. RÈGLE PMSI : Conforme pour un DP - -###RESULT### -{"code": "K85.1", "confidence": "high", "justification": "Pancréatite aiguë d'origine biliaire"}""" + raw = json.dumps({ + "analyse_clinique": "La pancréatite aiguë biliaire est une inflammation...", + "codes_candidats": "K85.0, K85.1, K85.9", + "discrimination": "K85.1 est spécifique à l'origine biliaire", + "regle_pmsi": "Conforme pour un DP", + "code": "K85.1", + "confidence": "high", + "justification": "Pancréatite aiguë d'origine biliaire", + }) result = _parse_ollama_response(raw) assert result is not None @@ -246,18 +250,27 @@ class TestParseOllamaResponse: assert result["justification"] == "Pancréatite aiguë d'origine biliaire" assert "raisonnement" in result assert "ANALYSE CLINIQUE" in result["raisonnement"] + assert "CODES CANDIDATS" in result["raisonnement"] + # Les champs de raisonnement sont retirés du dict + assert "analyse_clinique" not in result + assert "codes_candidats" not in result - def test_parse_without_marker_fallback(self): - """Fallback sur la recherche d'accolades quand le marqueur est absent.""" + def test_parse_minimal_json(self): + """JSON sans champs de raisonnement — pas de clé raisonnement.""" from src.medical.rag_search import _parse_ollama_response + import json - raw = """Voici mon analyse... -{"code": "E66.0", "confidence": "medium", "justification": "Obésité due à un excès calorique"}""" + raw = json.dumps({ + "code": "E66.0", + "confidence": "medium", + "justification": "Obésité due à un excès calorique", + }) result = _parse_ollama_response(raw) assert result is not None assert result["code"] == "E66.0" assert result["confidence"] == "medium" + assert "raisonnement" not in result def test_parse_empty_response(self): from src.medical.rag_search import _parse_ollama_response @@ -274,26 +287,27 @@ class TestParseOllamaResponse: def test_parse_invalid_json(self): from src.medical.rag_search import _parse_ollama_response - raw = """###RESULT### -{code: K85.1, invalid json}""" + raw = """{code: K85.1, invalid json}""" result = _parse_ollama_response(raw) assert result is None - def test_parse_marker_with_raisonnement_containing_braces(self): - """Le raisonnement peut contenir des accolades (ex: listes, exemples).""" + def test_parse_partial_reasoning_fields(self): + """Seuls certains champs de raisonnement sont présents.""" from src.medical.rag_search import _parse_ollama_response + import json - raw = """Le code {K85} est un code parent. -Sous-codes : {K85.0, K85.1, K85.2, K85.3} - -###RESULT### -{"code": "K85.1", "confidence": "high", "justification": "Biliaire confirmé"}""" + raw = json.dumps({ + "analyse_clinique": "Diagnostic clair", + "code": "K85.1", + "confidence": "high", + "justification": "Biliaire confirmé", + }) result = _parse_ollama_response(raw) assert result is not None assert result["code"] == "K85.1" assert "raisonnement" in result - assert "{K85}" in result["raisonnement"] + assert "ANALYSE CLINIQUE" in result["raisonnement"] class TestBuildPrompt: @@ -308,8 +322,8 @@ class TestBuildPrompt: assert "Pancréatite aiguë biliaire" in prompt assert "DP (diagnostic principal)" in prompt - assert "ANALYSE CLINIQUE" in prompt - assert "###RESULT###" in prompt + assert "analyse_clinique" in prompt + assert "objet JSON" in prompt def test_prompt_das_type(self): from src.medical.rag_search import _build_prompt