Backup état complet après enregistrement vidéo démo de bout en bout. À utiliser comme point de référence pour la consolidation post-démo. Changements majeurs de la session 18-19 mai : - AIVA-URGENCE : page autonome avec preset URL + auto-focus chain - Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine - Bypass LLM (static_result / static_text) dans replay_engine pour démos déterministes sans appel Ollama - Fix api_stream:3013 — replay_paused au premier polling /next - dag_execute : lift duration_ms vers top-level pour wait runtime - NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git) - scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue Anchors visuels (468) forcés dans le commit pour garantir restorabilité. DB workflows actuelle + ~12 .bak DB de la journée incluses. Sujets identifiés pour consolidation post-démo (TODO) : 1. Bug VWB recapture anchor ne régénère pas le PNG 2. Léa client accumule état mémoire (restart périodique requis) 3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel) 4. Bug coord client mss tronqué 2560x60 → mapping Y cassé 5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
239 lines
8.6 KiB
Python
239 lines
8.6 KiB
Python
"""
|
||
Démo interactive aiva-vision — Aide à la décision de facturation urgences.
|
||
|
||
Lance : streamlit run demo_app.py
|
||
"""
|
||
|
||
import json
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
import streamlit as st
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
from cas_dpi import CAS # noqa: E402
|
||
|
||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||
DEFAULT_MODEL = "qwen2.5:7b"
|
||
AVAILABLE_MODELS = [
|
||
"qwen2.5:7b",
|
||
"qwen2.5:14b",
|
||
"gemma4:latest",
|
||
"t2a-gemma3-27b-q4:latest",
|
||
"thiagomoraes/medgemma-27b-it:Q4_K_S",
|
||
"gemma3:27b-cloud",
|
||
"gpt-oss:120b-cloud",
|
||
]
|
||
|
||
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 critères PMSI/ATIH
|
||
|
||
INSTRUCTIONS STRICTES :
|
||
1. N'utilise QUE des éléments littéralement présents dans le dossier patient. N'invente AUCUN critère.
|
||
2. Identifie d'abord les éléments en faveur d'une hospitalisation, puis ceux en faveur d'un forfait, puis tranche.
|
||
3. Calcule la durée totale du passage en heures (admission → sortie/transfert) à partir des horaires du dossier.
|
||
4. 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": [<faits littéralement extraits du dossier>],
|
||
"elements_pour_forfait": [<faits littéralement extraits du dossier>],
|
||
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
|
||
"justification": "<2-3 phrases s'appuyant explicitement sur les faits ci-dessus>",
|
||
"confiance": "elevee" | "moyenne" | "faible"
|
||
}}
|
||
|
||
DOSSIER PATIENT :
|
||
{dpi}
|
||
"""
|
||
|
||
|
||
def query_model(model: str, dpi_text: str, timeout: int = 300) -> dict:
|
||
payload = {
|
||
"model": model,
|
||
"prompt": PROMPT_TEMPLATE.format(dpi=dpi_text),
|
||
"stream": False,
|
||
"format": "json",
|
||
"keep_alive": "5m",
|
||
"options": {
|
||
"temperature": 0.1,
|
||
"num_predict": 4000,
|
||
"num_ctx": 4096,
|
||
"reasoning_effort": "minimal",
|
||
},
|
||
}
|
||
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:
|
||
return {"_error": str(e), "_elapsed_s": round(time.time() - t0, 1)}
|
||
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)}
|
||
|
||
parsed["_elapsed_s"] = round(elapsed, 1)
|
||
parsed["_eval_count"] = body.get("eval_count")
|
||
return parsed
|
||
|
||
|
||
# ============================== UI ==============================
|
||
|
||
st.set_page_config(page_title="aiva-vision — Démo facturation urgences", layout="wide")
|
||
|
||
st.title("aiva-vision — Aide à la décision de facturation urgences")
|
||
st.caption("Forfait urgences vs requalification en hospitalisation MCO — décision T2A/PMSI assistée par LLM local")
|
||
|
||
# Barre latérale
|
||
with st.sidebar:
|
||
st.subheader("Configuration")
|
||
model = st.selectbox(
|
||
"Modèle LLM",
|
||
AVAILABLE_MODELS,
|
||
index=AVAILABLE_MODELS.index(DEFAULT_MODEL),
|
||
help="qwen2.5:7b est le choix par défaut (100 % accuracy, 5 s/cas, 4,7 GB VRAM)",
|
||
)
|
||
st.markdown("---")
|
||
st.subheader("Cas pré-chargés")
|
||
case_options = ["— Saisie libre —"] + [
|
||
f"#{c['id']:>2} [{c['type'][:4]}] {c['titre']}" for c in CAS
|
||
]
|
||
selected_case = st.selectbox("Charger un cas synthétique", case_options, index=0)
|
||
|
||
st.markdown("---")
|
||
st.markdown(
|
||
"**Enjeu business**\n\n"
|
||
"Forfait urgences ≈ 30-200 €\n\n"
|
||
"Requalification hospit ≈ 1 000-5 000 €+\n\n"
|
||
"Écart ≈ × 10-25"
|
||
)
|
||
|
||
# Chargement du DPI sélectionné dans la zone de saisie
|
||
default_dpi = ""
|
||
ground_truth = None
|
||
case_meta = None
|
||
if selected_case != "— Saisie libre —":
|
||
idx = case_options.index(selected_case) - 1
|
||
case_meta = CAS[idx]
|
||
default_dpi = case_meta["dpi"]
|
||
ground_truth = case_meta["verite_terrain"]
|
||
|
||
col_input, col_output = st.columns([1, 1])
|
||
|
||
with col_input:
|
||
st.subheader("Dossier patient (DPI urgences)")
|
||
dpi_text = st.text_area(
|
||
"Coller ou saisir le dossier patient",
|
||
value=default_dpi,
|
||
height=500,
|
||
placeholder="Mme X, 56 ans, admission 14h00...",
|
||
)
|
||
analyze = st.button("Analyser", type="primary", use_container_width=True)
|
||
|
||
with col_output:
|
||
st.subheader("Décision facturation")
|
||
if analyze and dpi_text.strip():
|
||
with st.spinner(f"Analyse par {model}..."):
|
||
result = query_model(model, dpi_text)
|
||
|
||
if result.get("_error"):
|
||
st.error(f"Erreur Ollama : {result['_error']}")
|
||
elif result.get("_parse_error"):
|
||
st.warning("Le modèle n'a pas produit de JSON valide.")
|
||
with st.expander("Voir la réponse brute"):
|
||
st.code(result.get("_raw", ""))
|
||
else:
|
||
decision = result.get("decision", "?")
|
||
confiance = result.get("confiance", "?")
|
||
duree = result.get("duree_passage_heures", "?")
|
||
|
||
# Décision en gros
|
||
if decision == "REQUALIFICATION_HOSPITALISATION":
|
||
st.error(f"### REQUALIFICATION HOSPITALISATION (GHM)")
|
||
st.caption("→ valorisation T2A séjour MCO (1 000-5 000 €+)")
|
||
elif decision == "FORFAIT_URGENCE":
|
||
st.success(f"### FORFAIT URGENCES (FFU/ATU)")
|
||
st.caption("→ valorisation forfaitaire (30-200 €)")
|
||
else:
|
||
st.warning(f"### Décision : {decision}")
|
||
|
||
# Comparaison avec vérité-terrain
|
||
if ground_truth:
|
||
if decision == ground_truth:
|
||
st.markdown(f"**Vérité-terrain** : `{ground_truth}` — concordance OK")
|
||
else:
|
||
st.markdown(f"**Vérité-terrain** : `{ground_truth}` — **divergence** !")
|
||
|
||
# Métriques
|
||
mcol1, mcol2, mcol3 = st.columns(3)
|
||
mcol1.metric("Confiance", confiance)
|
||
mcol2.metric("Durée passage", f"{duree} h")
|
||
mcol3.metric("Latence", f"{result.get('_elapsed_s', '?')} s")
|
||
|
||
# Justification
|
||
st.markdown("**Justification**")
|
||
st.info(result.get("justification", "—"))
|
||
|
||
# Critères pour/contre
|
||
ccol1, ccol2 = st.columns(2)
|
||
with ccol1:
|
||
st.markdown("**Éléments pour hospitalisation**")
|
||
items = result.get("elements_pour_hospitalisation", [])
|
||
if items:
|
||
for it in items:
|
||
st.markdown(f"- {it}")
|
||
else:
|
||
st.caption("(aucun)")
|
||
with ccol2:
|
||
st.markdown("**Éléments pour forfait**")
|
||
items = result.get("elements_pour_forfait", [])
|
||
if items:
|
||
for it in items:
|
||
st.markdown(f"- {it}")
|
||
else:
|
||
st.caption("(aucun)")
|
||
|
||
# JSON brut (debug)
|
||
with st.expander("Réponse JSON complète"):
|
||
st.json(result)
|
||
else:
|
||
st.caption("Saisis ou charge un DPI puis clique sur **Analyser**.")
|