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 ---
OLLAMA_URL = "http://localhost:11434"
OLLAMA_MODEL = "mistral-large-3:675b-cloud"
OLLAMA_MODEL = "gemma3:12b"
OLLAMA_TIMEOUT = 120

View File

@@ -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(