""" 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": , "elements_pour_hospitalisation": [], "elements_pour_forfait": [], "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**.")