Six modifications structurelles côté serveur, non destructives, aboutissant à un
pipeline replay bien plus stable pour la démo GHT Sud 95 (Urgences UHCD).
1. visual_workflow_builder/backend/app.py
load_dotenv() chargeait .env (cwd) au lieu de .env.local racine projet.
Conséquence : RPA_API_TOKEN absent après chaque restart manuel du backend
et tous les proxies VWB→streaming échouaient en 401 « Token API invalide ».
Charge maintenant explicitement .env.local du project root.
2. visual_workflow_builder/backend/api_v3/learned_workflows.py
Quatre appels proxy /api/v1/traces/stream/* ne portaient pas le Bearer.
Helper _stream_headers() factorisé et appliqué (workflows list/detail,
workflow detail, reload-workflows).
3. visual_workflow_builder/backend/api_v3/dag_execute.py
_ANCHOR_CLICK_TYPES excluait type_text/type_secret : pas de pre-click de
focus avant la frappe → texte tapé sans focus → textareas vides au replay.
Helper _inject_anchor_targeting() factorisé (centre bbox + visual_mode +
target_spec) appliqué aux click_anchor* ET aux type_text/type_secret dès
qu'un anchor_id est présent. Workflows historiques sans anchor sur
type_text → comportement inchangé.
4. agent_v0/server_v1/api_stream.py — endpoint /replay/next
_replay_lock (threading.Lock global) tenu pendant les actions serveur
lentes (extract_text OCR ~5s, t2a_decision LLM ~8-13s). Comme le handler
est async def, l'event loop FastAPI était bloqué : les polls clients
timeout à 5s, leurs actions étaient popped serveur sans destinataire,
perdues silencieusement. Mesure : 8 actions/25 perdues sur replay Urgence.
acquire(timeout=4.5) puis run_in_executor pour libérer l'event loop
pendant l'attente du lock ET pendant les handlers serveur synchrones.
Pendant un t2a_decision en cours, les polls concurrents reçoivent
immédiatement {action: null, server_busy: true} → l'agent ne timeout
plus, aucune action n'est popped sans destinataire.
5. agent_v0/server_v1/resolve_engine.py — _validate_resolution_quality
Drift > 0.20 par rapport aux coords enregistrées → fallback aux coords
enregistrées même quand le template matching trouve l'image avec un
score quasi parfait. Or un score >= 0.95 signifie que l'image EST
visuellement à l'écran à l'endroit indiqué, le drift reflète juste
un changement de layout (scroll, F11, redimensionnement), pas une
erreur. Exception ajoutée : score >= 0.95 sur template_matching →
ignore drift check, utilise position visuelle.
6. core/llm/t2a_decision.py — prompt T2A/PMSI
Ancien prompt autorisait « Critère non validé » en fallback creux.
Nouveau prompt impose au moins une CITATION LITTÉRALE entre « ... »
du DPI dans chaque preuve_critereN, qu'elle soutienne ou infirme le
critère. Si non validé : factualisation explicite (« Aucune ... »,
« Sortie à H+2 ») citée du dossier. Sortie = preuves cliniques
traçables et professionnelles, pas du remplissage.
État DB : aucun changement net (bbox patchés puis revertés depuis backup
visual_anchors_backup_20260501 ; by_text re-aligné sur 25003284). Le
re-enregistrement du workflow Urgence en conditions bureau standard
(Chrome normal, taille fenêtre standard) est l'étape suivante côté Dom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
7.2 KiB
Python
169 lines
7.2 KiB
Python
"""Aide à la décision de facturation urgences T2A/PMSI via LLM local.
|
|
|
|
Décide si un passage aux urgences relève :
|
|
- du FORFAIT_URGENCE (passage simple, retour à domicile)
|
|
- de la REQUALIFICATION_HOSPITALISATION (séjour MCO, valorisation 1k-5k€+)
|
|
|
|
Le prompt impose une extraction littérale des faits du DPI (pas d'invention)
|
|
et une modulation honnête de la confiance. Validé sur 15 DPI synthétiques :
|
|
qwen2.5:7b atteint 100 % d'accuracy en ~5 s/cas avec 4,7 Go VRAM.
|
|
|
|
Voir docs/clients/ght_sud_95/ et demo/facturation_urgences/RESULTATS.md pour le
|
|
bench comparatif des 11 LLMs évalués.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any, Dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/generate")
|
|
DEFAULT_MODEL = os.environ.get("T2A_MODEL", "qwen2.5:7b")
|
|
DEFAULT_TIMEOUT = 60 # secondes
|
|
|
|
PROMPT_TEMPLATE = """Tu es médecin DIM (Département d'Information Médicale), expert en facturation T2A/PMSI aux urgences hospitalières en France.
|
|
|
|
Analyse le dossier patient ci-dessous pour déterminer si le passage relève :
|
|
- FORFAIT_URGENCE : passage simple, retour à domicile, sans surveillance prolongée ni soins continus
|
|
- REQUALIFICATION_HOSPITALISATION : séjour MCO requis selon les 3 critères PMSI/ATIH
|
|
|
|
LES 3 CRITÈRES UHCD (au moins 2 sur 3 validés ⇒ REQUALIFICATION) :
|
|
1. Pathologie potentiellement évolutive (instabilité hémodynamique, terrain à risque, traitement nécessitant adaptation)
|
|
2. Surveillance médicale et paramédicale prolongée (constantes itératives, observations IDE/médecin, durée > 6 h)
|
|
3. Examens complémentaires ou actes thérapeutiques (biologie, imagerie, sutures, gestes techniques)
|
|
|
|
INSTRUCTIONS STRICTES :
|
|
1. N'utilise QUE des éléments littéralement présents dans le dossier patient. N'invente AUCUN critère.
|
|
2. Pour CHAQUE critère (1, 2, 3), tu DOIS produire un texte de preuve qui contient AU MOINS UNE CITATION LITTÉRALE du dossier entre guillemets français « ... ». Exemple : « FC à 110 bpm, TA 92/60 ».
|
|
3. Si le critère est NON validé, ne renvoie JAMAIS un fallback creux : explique factuellement ce qui manque, en citant le dossier (ex: « Sortie à H+2 », « Aucun acte technique au compte-rendu »).
|
|
4. Le texte de chaque preuve fait 2-3 phrases : (i) la citation littérale, (ii) l'analyse PMSI, (iii) la conclusion validé/non validé.
|
|
5. Calcule la durée totale du passage en heures (admission → sortie/transfert) à partir des horaires du dossier.
|
|
6. Module ta confiance honnêtement :
|
|
- "elevee" uniquement si tous les indices convergent
|
|
- "moyenne" si éléments ambivalents
|
|
- "faible" si information manquante ou très atypique
|
|
|
|
Réponds STRICTEMENT en JSON valide, sans texte avant ni après :
|
|
{{
|
|
"duree_passage_heures": <nombre>,
|
|
"elements_pour_hospitalisation": [<phrases littéralement extraites du dossier>],
|
|
"elements_pour_forfait": [<phrases littéralement extraites du dossier>],
|
|
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
|
|
"decision_court": "UHCD" | "Forfait Urgences",
|
|
"preuve_critere1": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (motif, symptôme, terrain à risque, traitement). Si non validé : factualise ce qui manque en citant le dossier.>",
|
|
"critere1_valide": true | false,
|
|
"preuve_critere2": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (constantes, observations IDE, durée surveillance). Si non validé : factualise.>",
|
|
"critere2_valide": true | false,
|
|
"preuve_critere3": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (actes/examens : biologie, imagerie, suture, etc.). Si non validé : factualise.>",
|
|
"critere3_valide": true | false,
|
|
"justification": "<2-3 phrases synthétiques s'appuyant explicitement sur les preuves ci-dessus, avec au moins une citation>",
|
|
"confiance": "elevee" | "moyenne" | "faible"
|
|
}}
|
|
|
|
DOSSIER PATIENT :
|
|
{dpi}
|
|
"""
|
|
|
|
|
|
def analyze_dpi(
|
|
dpi_text: str,
|
|
model: str = DEFAULT_MODEL,
|
|
timeout: int = DEFAULT_TIMEOUT,
|
|
ollama_url: str = OLLAMA_URL,
|
|
) -> Dict[str, Any]:
|
|
"""Soumet un DPI urgences à un LLM Ollama et retourne la décision JSON.
|
|
|
|
Args:
|
|
dpi_text: Texte du dossier patient (concaténation des onglets ou DPI brut).
|
|
model: Modèle Ollama à utiliser (default qwen2.5:7b — 100% accuracy bench).
|
|
timeout: Timeout HTTP en secondes.
|
|
ollama_url: Endpoint Ollama (default localhost:11434/api/generate).
|
|
|
|
Returns:
|
|
Dict avec :
|
|
decision: "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION"
|
|
elements_pour_hospitalisation: List[str]
|
|
elements_pour_forfait: List[str]
|
|
duree_passage_heures: float
|
|
justification: str
|
|
confiance: "elevee" | "moyenne" | "faible"
|
|
_elapsed_s: float (latence)
|
|
_model: str
|
|
En cas d'erreur :
|
|
{"_error": str, "_elapsed_s": float} (réseau / Ollama indisponible)
|
|
{"_parse_error": True, "_raw": str, "_elapsed_s": float} (JSON invalide)
|
|
"""
|
|
payload = {
|
|
"model": model,
|
|
"prompt": PROMPT_TEMPLATE.format(dpi=dpi_text),
|
|
"stream": False,
|
|
"format": "json",
|
|
"keep_alive": "5m",
|
|
"options": {
|
|
"temperature": 0.1,
|
|
"num_predict": 1500,
|
|
"num_ctx": 16384,
|
|
},
|
|
}
|
|
data = json.dumps(payload).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
ollama_url,
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
t0 = time.time()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
body = json.loads(resp.read().decode("utf-8"))
|
|
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
elapsed = round(time.time() - t0, 1)
|
|
logger.warning("analyze_dpi: Ollama indisponible (%s) après %.1fs", e, elapsed)
|
|
return {"_error": str(e), "_elapsed_s": elapsed, "_model": model}
|
|
|
|
elapsed = time.time() - t0
|
|
|
|
raw_response = body.get("response", "").strip()
|
|
raw_thinking = body.get("thinking", "").strip()
|
|
|
|
candidates = [raw_response]
|
|
if not raw_response and raw_thinking:
|
|
last_close = raw_thinking.rfind("}")
|
|
last_open = raw_thinking.rfind("{", 0, last_close)
|
|
if last_open != -1 and last_close != -1:
|
|
candidates.append(raw_thinking[last_open:last_close + 1])
|
|
|
|
parsed = None
|
|
for cand in candidates:
|
|
cleaned = cand
|
|
if cleaned.startswith("```"):
|
|
cleaned = cleaned.split("\n", 1)[-1]
|
|
if cleaned.endswith("```"):
|
|
cleaned = cleaned.rsplit("```", 1)[0]
|
|
cleaned = cleaned.strip()
|
|
try:
|
|
parsed = json.loads(cleaned)
|
|
break
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if parsed is None:
|
|
return {
|
|
"_parse_error": True,
|
|
"_raw": (raw_response or raw_thinking)[:500],
|
|
"_elapsed_s": round(elapsed, 1),
|
|
"_model": model,
|
|
}
|
|
|
|
parsed["_elapsed_s"] = round(elapsed, 1)
|
|
parsed["_model"] = model
|
|
parsed["_eval_count"] = body.get("eval_count")
|
|
return parsed
|