feat: mode JSON natif Ollama + modèle gemma3:12b + retry

- Ajout format:"json" dans l'appel API Ollama (force sortie JSON valide)
- Prompt restructuré : raisonnement en champs JSON structurés
  (analyse_clinique, codes_candidats, discrimination, regle_pmsi)
- Parser simplifié : json.loads direct + reconstitution du raisonnement
- Suppression du marqueur ###RESULT### (obsolète avec mode JSON)
- Retry automatique (1 tentative) si parsing échoue
- Stripping des blocs markdown ```json pour compatibilité multi-modèles
- num_predict 1200→2500, modèle gemma3:12b (tient en 12Go VRAM)
- Résultat : 0% échec parsing (était 11% avant)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-12 02:19:09 +01:00
parent 931b6c5d1c
commit 86d7ec5ea4
3 changed files with 106 additions and 109 deletions

View File

@@ -31,7 +31,7 @@ NER_CONFIDENCE_THRESHOLD = 0.80
# --- Configuration Ollama --- # --- Configuration Ollama ---
OLLAMA_URL = "http://localhost:11434" OLLAMA_URL = "http://localhost:11434"
OLLAMA_MODEL = "mistral-large-3:675b-cloud" OLLAMA_MODEL = "gemma3:12b"
OLLAMA_TIMEOUT = 120 OLLAMA_TIMEOUT = 120

View File

@@ -4,8 +4,6 @@ from __future__ import annotations
import json import json
import logging import logging
import re
from typing import Optional
import requests import requests
@@ -19,9 +17,6 @@ _embed_model = None
# Score minimum de similarité FAISS pour retenir un résultat # Score minimum de similarité FAISS pour retenir un résultat
_MIN_SCORE = 0.3 _MIN_SCORE = 0.3
# Marqueur de fin de raisonnement dans la réponse Ollama
_RESULT_MARKER = "###RESULT###"
def _get_embed_model(): def _get_embed_model():
"""Charge le modèle d'embedding (singleton).""" """Charge le modèle d'embedding (singleton)."""
@@ -186,100 +181,88 @@ CONTEXTE CLINIQUE :
SOURCES CIM-10 : SOURCES CIM-10 :
{sources_text} {sources_text}
RAISONNE ÉTAPE PAR ÉTAPE : Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
1. ANALYSE CLINIQUE : Que signifie ce diagnostic sur le plan médical ? {{
2. CODES CANDIDATS : Quels codes des sources fournies sont compatibles ? "analyse_clinique": "que signifie ce diagnostic sur le plan médical",
3. DISCRIMINATION : Pourquoi choisir un code plutôt qu'un autre ? (inclusions/exclusions, spécificité) "codes_candidats": "quels codes CIM-10 des sources sont compatibles",
4. RÈGLE PMSI : Ce code est-il conforme pour un {type_diag} ? (guide méthodologique) "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)",
Après ton raisonnement, conclus OBLIGATOIREMENT par le JSON suivant sur une ligne séparée : "code": "X99.9",
{_RESULT_MARKER} "confidence": "high ou medium ou low",
{{"code": "X99.9", "confidence": "high|medium|low", "justification": "explication courte en français"}}""" "justification": "explication courte en français"
}}"""
def _parse_ollama_response(raw: str) -> dict | None: 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. Reconstitue le raisonnement à partir des champs structurés.
Retourne un dict avec les clés code/confidence/justification + raisonnement.
""" """
raisonnement = None # Stripper les blocs markdown ```json ... ``` que certains modèles ajoutent
json_str = None text = raw.strip()
if text.startswith("```"):
# Stratégie 1 : chercher le marqueur ###RESULT### first_nl = text.find("\n")
marker_pos = raw.find(_RESULT_MARKER) if first_nl != -1:
if marker_pos != -1: text = text[first_nl + 1:]
raisonnement = raw[:marker_pos].strip() # Retirer la fence fermante seulement si elle existe en fin de texte
after_marker = raw[marker_pos + len(_RESULT_MARKER):] if text.rstrip().endswith("```"):
brace_start = after_marker.find("{") text = text.rstrip()[:-3]
brace_end = after_marker.rfind("}") text = text.strip()
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
try: try:
parsed = json.loads(json_str) parsed = json.loads(text)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning("Ollama : JSON invalide : %s", json_str[:200]) logger.warning("Ollama : JSON invalide : %s", raw[:200])
return None return None
if raisonnement: # Reconstituer le raisonnement à partir des champs structurés
parsed["raisonnement"] = raisonnement 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 return parsed
def _call_ollama(prompt: str) -> dict | None: def _call_ollama(prompt: str) -> dict | None:
"""Appelle Ollama et parse la réponse JSON.""" """Appelle Ollama (mode JSON) et parse la réponse. Retry une fois si parsing échoue."""
try: for attempt in range(2):
response = requests.post( try:
f"{OLLAMA_URL}/api/generate", response = requests.post(
json={ f"{OLLAMA_URL}/api/generate",
"model": OLLAMA_MODEL, json={
"prompt": prompt, "model": OLLAMA_MODEL,
"stream": False, "prompt": prompt,
"options": { "stream": False,
"temperature": 0.1, "format": "json",
"num_predict": 1200, "options": {
"temperature": 0.1,
"num_predict": 2500,
},
}, },
}, timeout=OLLAMA_TIMEOUT,
timeout=OLLAMA_TIMEOUT, )
) response.raise_for_status()
response.raise_for_status() raw = response.json().get("response", "")
raw = response.json().get("response", "") result = _parse_ollama_response(raw)
return _parse_ollama_response(raw) if result is not None:
return result
except requests.ConnectionError: if attempt == 0:
logger.warning("Ollama non disponible (connexion refusée)") logger.info("Ollama : retry après échec de parsing")
return None except requests.ConnectionError:
except requests.Timeout: logger.warning("Ollama non disponible (connexion refusée)")
logger.warning("Ollama timeout après %ds", OLLAMA_TIMEOUT) return None
return None except requests.Timeout:
except (requests.RequestException, json.JSONDecodeError) as e: logger.warning("Ollama timeout après %ds", OLLAMA_TIMEOUT)
logger.warning("Ollama erreur : %s", e) return None
return None except (requests.RequestException, json.JSONDecodeError) as e:
logger.warning("Ollama erreur : %s", e)
return None
return None
def enrich_diagnostic( def enrich_diagnostic(

View File

@@ -226,18 +226,22 @@ class TestChunkingCCAM:
class TestParseOllamaResponse: 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 from src.medical.rag_search import _parse_ollama_response
import json
raw = """1. ANALYSE CLINIQUE : La pancréatite aiguë biliaire est une inflammation... raw = json.dumps({
2. CODES CANDIDATS : K85.0, K85.1, K85.9 "analyse_clinique": "La pancréatite aiguë biliaire est une inflammation...",
3. DISCRIMINATION : K85.1 est spécifique à l'origine biliaire "codes_candidats": "K85.0, K85.1, K85.9",
4. RÈGLE PMSI : Conforme pour un DP "discrimination": "K85.1 est spécifique à l'origine biliaire",
"regle_pmsi": "Conforme pour un DP",
###RESULT### "code": "K85.1",
{"code": "K85.1", "confidence": "high", "justification": "Pancréatite aiguë d'origine biliaire"}""" "confidence": "high",
"justification": "Pancréatite aiguë d'origine biliaire",
})
result = _parse_ollama_response(raw) result = _parse_ollama_response(raw)
assert result is not None assert result is not None
@@ -246,18 +250,27 @@ class TestParseOllamaResponse:
assert result["justification"] == "Pancréatite aiguë d'origine biliaire" assert result["justification"] == "Pancréatite aiguë d'origine biliaire"
assert "raisonnement" in result assert "raisonnement" in result
assert "ANALYSE CLINIQUE" in result["raisonnement"] 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): def test_parse_minimal_json(self):
"""Fallback sur la recherche d'accolades quand le marqueur est absent.""" """JSON sans champs de raisonnement — pas de clé raisonnement."""
from src.medical.rag_search import _parse_ollama_response from src.medical.rag_search import _parse_ollama_response
import json
raw = """Voici mon analyse... raw = json.dumps({
{"code": "E66.0", "confidence": "medium", "justification": "Obésité due à un excès calorique"}""" "code": "E66.0",
"confidence": "medium",
"justification": "Obésité due à un excès calorique",
})
result = _parse_ollama_response(raw) result = _parse_ollama_response(raw)
assert result is not None assert result is not None
assert result["code"] == "E66.0" assert result["code"] == "E66.0"
assert result["confidence"] == "medium" assert result["confidence"] == "medium"
assert "raisonnement" not in result
def test_parse_empty_response(self): def test_parse_empty_response(self):
from src.medical.rag_search import _parse_ollama_response from src.medical.rag_search import _parse_ollama_response
@@ -274,26 +287,27 @@ class TestParseOllamaResponse:
def test_parse_invalid_json(self): def test_parse_invalid_json(self):
from src.medical.rag_search import _parse_ollama_response from src.medical.rag_search import _parse_ollama_response
raw = """###RESULT### raw = """{code: K85.1, invalid json}"""
{code: K85.1, invalid json}"""
result = _parse_ollama_response(raw) result = _parse_ollama_response(raw)
assert result is None assert result is None
def test_parse_marker_with_raisonnement_containing_braces(self): def test_parse_partial_reasoning_fields(self):
"""Le raisonnement peut contenir des accolades (ex: listes, exemples).""" """Seuls certains champs de raisonnement sont présents."""
from src.medical.rag_search import _parse_ollama_response from src.medical.rag_search import _parse_ollama_response
import json
raw = """Le code {K85} est un code parent. raw = json.dumps({
Sous-codes : {K85.0, K85.1, K85.2, K85.3} "analyse_clinique": "Diagnostic clair",
"code": "K85.1",
###RESULT### "confidence": "high",
{"code": "K85.1", "confidence": "high", "justification": "Biliaire confirmé"}""" "justification": "Biliaire confirmé",
})
result = _parse_ollama_response(raw) result = _parse_ollama_response(raw)
assert result is not None assert result is not None
assert result["code"] == "K85.1" assert result["code"] == "K85.1"
assert "raisonnement" in result assert "raisonnement" in result
assert "{K85}" in result["raisonnement"] assert "ANALYSE CLINIQUE" in result["raisonnement"]
class TestBuildPrompt: class TestBuildPrompt:
@@ -308,8 +322,8 @@ class TestBuildPrompt:
assert "Pancréatite aiguë biliaire" in prompt assert "Pancréatite aiguë biliaire" in prompt
assert "DP (diagnostic principal)" in prompt assert "DP (diagnostic principal)" in prompt
assert "ANALYSE CLINIQUE" in prompt assert "analyse_clinique" in prompt
assert "###RESULT###" in prompt assert "objet JSON" in prompt
def test_prompt_das_type(self): def test_prompt_das_type(self):
from src.medical.rag_search import _build_prompt from src.medical.rag_search import _build_prompt