Files
rpa_vision_v3/demo/facturation_urgences/demo_app.py
Dom 5ea4960e65
Some checks failed
tests / Lint (ruff + black) (push) Successful in 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
backup: snapshot post-démo GHT 2026-05-19
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>
2026-05-19 14:55:06 +02:00

239 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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**.")