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>
This commit is contained in:
184
demo/facturation_urgences/RESULTATS.md
Normal file
184
demo/facturation_urgences/RESULTATS.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Évaluation comparative de LLMs pour la décision de facturation urgences
|
||||
|
||||
> Démo aiva-vision — décision automatisée *forfait urgences vs requalification en hospitalisation MCO*
|
||||
> Date : 28 avril 2026
|
||||
|
||||
## Contexte et enjeu business
|
||||
|
||||
À la sortie d'un passage aux urgences, deux régimes de facturation T2A/PMSI sont possibles :
|
||||
|
||||
- **Forfait urgences** (FFU/ATU) : passage simple, retour à domicile — valorisation ~30-200 €
|
||||
- **Requalification en hospitalisation MCO** (GHM) : séjour court avec surveillance prolongée, soins continus, transfert spécialisé — valorisation 1 000 à 5 000 €+
|
||||
|
||||
L'écart financier est massif et la décision repose sur des critères PMSI/ATIH lisibles dans le DPI urgences (durée de présence, surveillance scopée, oxygénothérapie, soins IV itératifs, transferts, etc.). Aujourd'hui cette décision est prise manuellement par les médecins DIM, avec un risque significatif de **sous-codage** (manque à gagner) ou de **sur-codage** (risque de contrôle ATIH).
|
||||
|
||||
L'objectif de cette évaluation est d'identifier le LLM le plus pertinent pour assister cette décision dans le cadre d'aiva-vision, en respectant la contrainte d'un **déploiement 100 % local** (RGPD/HDS).
|
||||
|
||||
## Méthodologie
|
||||
|
||||
### Jeu d'évaluation
|
||||
|
||||
15 DPI urgences synthétiques en français médical réaliste, structurés en trois catégories :
|
||||
|
||||
| Catégorie | N | Description |
|
||||
|---|---|---|
|
||||
| Simple (forfait clair) | 5 | Entorse, plaie suturée, colique néphrétique soulagée, fièvre virale, asthme léger |
|
||||
| Complexe (hospit. évidente) | 5 | Pneumopathie hypoxémiante, OAP, AVC thrombolysé, sepsis, SCA NSTEMI |
|
||||
| Borderline | 5 | Douleur thoracique tropos négatives, gériatrie post-malaise UHCD 27 h, intoxication médicamenteuse, vertige rotatoire, pyélonéphrite simple |
|
||||
|
||||
Les cas borderline sont la **vraie métrique business** — ils représentent les situations où la décision est ambiguë et où le LLM apporte de la valeur.
|
||||
|
||||
### Tâche évaluée
|
||||
|
||||
Pour chaque DPI, le modèle doit produire un JSON structuré contenant :
|
||||
|
||||
- `decision` : `FORFAIT_URGENCE` ou `REQUALIFICATION_HOSPITALISATION`
|
||||
- `elements_pour_hospitalisation` : faits littéralement extraits du DPI
|
||||
- `elements_pour_forfait` : faits littéralement extraits du DPI
|
||||
- `duree_passage_heures` : calculée depuis les horaires du dossier
|
||||
- `justification` : 2-3 phrases
|
||||
- `confiance` : `elevee` / `moyenne` / `faible`
|
||||
|
||||
Le prompt impose une extraction littérale (pas d'invention) et une modulation honnête de la confiance.
|
||||
|
||||
### Modèles évalués
|
||||
|
||||
Onze modèles, locaux et cloud, balayant le spectre des architectures et tailles disponibles fin avril 2026 :
|
||||
|
||||
- **Médicaux spécialisés** : `medgemma:4b`, `medgemma:27b-it` (Q4_K_S), `Apollo2-9B`
|
||||
- **Fine-tune T2A maison** : `t2a-gemma3-27b-q4`
|
||||
- **Généralistes locaux** : `qwen2.5:7b`, `qwen2.5:14b`, `gemma4`
|
||||
- **Reasoning models** : `DeepSeek-R1`, `qwen3-next:80b-cloud`
|
||||
- **Top tier cloud** : `gemma3:27b-cloud`, `gpt-oss:120b-cloud`
|
||||
|
||||
## Résultats
|
||||
|
||||
### Classement global
|
||||
|
||||
| # | Modèle | VRAM | Type | Accuracy | Simple | Complex | Border | FN | Latence |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | t2a-gemma3-27b-q4 (fine-tune maison) | 16 GB* | Local FT | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 52 s |
|
||||
| 2 | gpt-oss:120b-cloud | ~70 GB | Cloud | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 7,5 s |
|
||||
| 3 | gemma3:27b-cloud | ~16 GB | Cloud | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 5,6 s |
|
||||
| 4 | medgemma:27b-it (Q4_K_S) | 16 GB* | Local | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 61 s |
|
||||
| 5 | qwen2.5:7b | 4,7 GB | Local | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 5,1 s |
|
||||
| 6 | qwen2.5:14b | 9,0 GB | Local | 14/15 (93 %) | 5/5 | 5/5 | 4/5 | 1 | 10,5 s |
|
||||
| 7 | gemma4:latest | 9,6 GB | Local | 14/15 (93 %) | 5/5 | 5/5 | 4/5 | 0 | 3,0 s |
|
||||
| 8 | medgemma:4b | 3,3 GB | Local | 13/15 (87 %) | 5/5 | 4/5 | 4/5 | 1 | 3,5 s |
|
||||
| 9 | Apollo2-9B Q4_K_S | 5,5 GB | Local | 12/15 (80 %) | 5/5 | 3/5 | 4/5 | 3 | 9,9 s |
|
||||
| 10 | DeepSeek-R1:latest (distill 5 GB) | 5,2 GB | Local | 12/15 (80 %) | 5/5 | 3/5 | 4/5 | 3 | 4,2 s |
|
||||
| 11 | qwen3-next:80b-cloud | ~50 GB | Cloud | 8/15 (53 %) | 2/5 | 4/5 | 2/5 | 0 | 20 s |
|
||||
|
||||
*16 GB sur disque, déborde de la 12 GB VRAM de la machine de test → offload CPU. Sur DGX Spark (128 GB unified) : estimation 5-8 s/cas.
|
||||
|
||||
### Insights majeurs
|
||||
|
||||
**1. La spécialisation médicale anglophone n'aide pas sur cette tâche.**
|
||||
|
||||
`medgemma:4b` (13/15) et `Apollo2-9B` (12/15) sont battus par des généralistes plus petits ou non médicaux. Apollo2-9B, pourtant le seul modèle médical avec un support français explicite (FrenchMedMCQA, MMLU_FR), rate **deux cas complexes tranchés** (Pneumopathie hypoxémiante, SCA NSTEMI) — ce qui est rédhibitoire pour un déploiement.
|
||||
|
||||
Le levier de performance n'est pas la connaissance médicale générique, mais **la connaissance des règles T2A/PMSI/ATIH françaises**.
|
||||
|
||||
**2. Le fine-tune T2A maison atteint 100 % et égale GPT-OSS 120B.**
|
||||
|
||||
`t2a-gemma3-27b-q4` (fine-tune sur le domaine T2A) est au niveau d'un modèle 5 fois plus gros tournant en cloud. C'est la confirmation que **l'effort de fine-tuning sur le domaine PMSI paie clairement**.
|
||||
|
||||
**3. Reasoning models trop heavy pour une décision binaire structurée.**
|
||||
|
||||
`qwen3-next:80b-cloud` (53 %) "over-thinks" : le budget tokens est consommé par le raisonnement avant la sortie JSON. Quand il répond, le raisonnement est de très haute qualité (CURB-65, IDSA/ATS cités), mais le coût n'est pas justifié pour cette tâche.
|
||||
|
||||
`DeepSeek-R1:latest` (distill 5 GB local, 80 %) souffre du même problème en plus léger — 3 faux négatifs sur des urgences évidentes.
|
||||
|
||||
**4. qwen2.5:7b écrase le rapport perf/coût.**
|
||||
|
||||
Avec **4,7 GB VRAM, 5,1 s/cas et 15/15**, ce modèle généraliste atteint la performance des 27B et 120B sur cette tâche. C'est le candidat évident pour la démo immédiate sur la machine actuelle.
|
||||
|
||||
### Calibration de la confiance
|
||||
|
||||
| Modèle | Élevée | Moyenne | Faible | Commentaire |
|
||||
|---|---|---|---|---|
|
||||
| qwen2.5:14b | 7 | 8 | 0 | Meilleure modulation |
|
||||
| gemma3:27b-cloud | 13 | 2 | 0 | Acceptable |
|
||||
| qwen2.5:7b | 10 | 5 | 0 | Acceptable |
|
||||
| t2a-gemma3-27b-q4 | 15 | 0 | 0 | Aucune modulation (effet du fine-tuning) |
|
||||
| medgemma:27b-it | 15 | 0 | 0 | Aucune modulation |
|
||||
| gpt-oss:120b-cloud | 15 | 0 | 0 | Aucune modulation |
|
||||
|
||||
Les modèles 100 % accuracy ont tous tendance à sortir « élevée » systématiquement. C'est un point d'amélioration à intégrer dans une v2 du fine-tune (ajout d'exemples avec confiance modulée dans le dataset d'entraînement).
|
||||
|
||||
## Choix retenu pour la démo aiva-vision
|
||||
|
||||
**Modèle : `qwen2.5:7b`**
|
||||
|
||||
Justification selon les trois critères opérationnels :
|
||||
|
||||
| Critère | qwen2.5:7b | Détail |
|
||||
|---|---|---|
|
||||
| Aucune erreur diag | 15/15 (100 %) | À égalité avec les top tiers (27B+, 120B) |
|
||||
| Latence | 5,1 s/cas | La plus basse parmi les 100 % |
|
||||
| Ressources | 4,7 GB VRAM | La plus légère parmi les 100 % |
|
||||
|
||||
Le modèle tient sur GPU consumer (12 GB VRAM), laisse 7 GB libres pour les services aiva-vision concurrents (CLIP, FAISS, grounder UI), et atteint la performance des modèles 5-25 fois plus gros sur cette tâche. C'est l'optimum pour la démonstration.
|
||||
|
||||
## Recommandation pour le déploiement production
|
||||
|
||||
**Cible : DGX Spark (128 GB unified memory)**
|
||||
|
||||
Sur la machine cible de production, deux options crédibles :
|
||||
|
||||
**Option A — Fine-tune T2A maison (`t2a-gemma3-27b-q4`)**
|
||||
|
||||
- 100 % local, IP propriétaire, contrôle total
|
||||
- Performance équivalente à GPT-OSS 120B et MedGemma 27B sur ce benchmark
|
||||
- Latence estimée 5-8 s/cas sur DGX Spark (sans offload CPU)
|
||||
- Conforme RGPD/HDS sans réserve
|
||||
- **À privilégier** pour la production hospitalière
|
||||
|
||||
**Option B — qwen2.5:7b en première ligne, fine-tune en escalade**
|
||||
|
||||
- qwen2.5:7b répond immédiatement (5 s)
|
||||
- Fine-tune T2A appelé uniquement sur les cas où qwen2.5 sort en confiance « moyenne » ou « faible »
|
||||
- Compromis débit/qualité optimal
|
||||
|
||||
## Limites de l'évaluation
|
||||
|
||||
- **Échantillon petit** : 15 cas. Les écarts entre 14/15 et 15/15 ne sont pas statistiquement significatifs. Pour une validation prod, viser 100-300 cas labellisés couvrant l'intégralité de la grille ATIH.
|
||||
- **Cas synthétiques** : les DPI ont été générés à partir de la connaissance médicale, pas extraits de production. La validation finale nécessite des cas réels anonymisés.
|
||||
- **Calibration de confiance** : les modèles 100 % sortent tous « élevée » — leur capacité à *signaler* l'incertitude n'a pas pu être discriminée sur ce jeu.
|
||||
- **PMSI 2026** : le format **V016** (en vigueur depuis le 1er janvier 2026) introduit de nouvelles règles non couvertes ici (champ « Nombre de disciplines de service » étendu). À intégrer dans la v2 du dataset.
|
||||
|
||||
## Pistes d'amélioration
|
||||
|
||||
1. Étendre le jeu d'évaluation à 100+ cas réels anonymisés couvrant les 28 GHM les plus fréquents en MCO.
|
||||
2. Recoller le fine-tune T2A avec un dataset incluant la modulation de confiance (cas borderline annotés « moyenne »).
|
||||
3. Intégrer les évolutions PMSI 2026 (V016, nouvelles règles SMR) dans le prompt et le training.
|
||||
4. Évaluer un système hybride à deux étages (qwen2.5:7b → t2a-gemma3-27b-q4 sur escalade).
|
||||
5. Mettre en place un eval harness automatique tournant en CI sur chaque mise à jour de modèle.
|
||||
|
||||
## Annexe — Détail par cas
|
||||
|
||||
Les 15 cas et les prédictions par modèle sont consolidés dans `resultats_v2.json`.
|
||||
|
||||
Cas qui ont discriminé les modèles :
|
||||
|
||||
- **Cas 6 (Pneumopathie hypoxémiante)** : raté par medgemma:4b et Apollo2-9B — pourtant tous les critères HOSPIT sont présents (O2, scope, ATB IV, transfert pneumo, durée 22 h). Cas-test pivot.
|
||||
- **Cas 12 (Personne âgée UHCD 27 h)** : raté par qwen2.5:14b. Borderline gériatrique avec durée > 24 h comme seul critère décisif. Cas-test pivot.
|
||||
- **Cas 10 (SCA NSTEMI)** : raté par Apollo2-9B et DeepSeek-R1. Devrait être trivial pour un modèle médical.
|
||||
|
||||
## Reproductibilité
|
||||
|
||||
Tous les scripts et données sont dans `/home/dom/ai/rpa_vision_v3/demo/facturation_urgences/` :
|
||||
|
||||
```
|
||||
cas_dpi.py 15 DPI synthétiques (Python)
|
||||
run_simulation.py v1 mono-modèle (référence historique)
|
||||
run_simulation_v2.py v2 multi-modèles, prompt durci, support reasoning
|
||||
run_qwen3_only.py rerun ciblé qwen3-next:80b-cloud
|
||||
run_extra_models.py batch t2a + DeepSeek + gpt-oss
|
||||
run_medgemma27b.py test MedGemma 27B (thiagomoraes)
|
||||
run_apollo2.py test Apollo2-9B
|
||||
resultats_v2.json résultats consolidés des 11 modèles (source de vérité)
|
||||
RESULTATS.md ce document
|
||||
```
|
||||
|
||||
Lancement : `python3 -u run_simulation_v2.py`. Prérequis : Ollama démarré sur `localhost:11434`.
|
||||
416
demo/facturation_urgences/cas_dpi.py
Normal file
416
demo/facturation_urgences/cas_dpi.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
10 dossiers patients informatisés (DPI) urgences synthétiques.
|
||||
Mix simple (forfait urgence) / complexe (requalification hospitalisation MCO).
|
||||
Données entièrement fictives.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
CAS = [
|
||||
# ---------- 5 cas SIMPLES → FORFAIT_URGENCE ----------
|
||||
{
|
||||
"id": 1,
|
||||
"type": "simple",
|
||||
"titre": "Entorse cheville droite",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": ["durée < 4h", "pas de surveillance scopée", "retour domicile"],
|
||||
"dpi": dedent("""
|
||||
Mme D., 28 ans, sportive, sans antécédent.
|
||||
Admission 14h12. Motif : torsion cheville droite il y a 2h en jogging,
|
||||
douleur 6/10, marche difficile, pas de craquement.
|
||||
Constantes : TA 122/74, FC 78, SpO2 99% AA, T° 36.8, FR 14.
|
||||
Examen : œdème malléole externe, douleur palpation LLE, pas de douleur osseuse,
|
||||
critères d'Ottawa négatifs. Mobilité conservée.
|
||||
RX cheville droite : pas de fracture.
|
||||
Conduite : antalgie palier 1 (paracétamol), strapping, attelle Aircast,
|
||||
arrêt sport 10 jours, consultation médecin traitant J7.
|
||||
Sortie 16h05, autonomie marche avec attelle. Score douleur sortie 3/10.
|
||||
Durée passage : 1h53.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "simple",
|
||||
"titre": "Plaie main suturée",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": ["geste ponctuel", "durée < 3h", "retour domicile"],
|
||||
"dpi": dedent("""
|
||||
M. L., 42 ans, menuisier, à jour vaccinations (VAT 2023).
|
||||
Admission 09h40. Motif : plaie face dorsale main gauche par cutter
|
||||
il y a 30 min, saignement modéré contrôlé par compression.
|
||||
Constantes : TA 130/80, FC 82, SpO2 99% AA, T° 36.6.
|
||||
Examen : plaie linéaire 4 cm, profondeur sous-cutanée,
|
||||
absence d'atteinte tendineuse (extension 2è/3è doigts conservée),
|
||||
sensibilité distale normale, pouls radial bien perçu.
|
||||
Désinfection Bétadine, anesthésie locale Xylocaïne 1%, parage,
|
||||
suture par 6 points séparés Ethilon 4/0. Pansement.
|
||||
Pas de rappel VAT (à jour). Conseils plaie remise.
|
||||
Ablation fils J10 chez médecin traitant. Sortie 11h25.
|
||||
Durée passage : 1h45.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "simple",
|
||||
"titre": "Colique néphrétique non compliquée",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": ["soulagement obtenu", "pas de complication", "retour domicile"],
|
||||
"dpi": dedent("""
|
||||
M. K., 38 ans, sans antécédent.
|
||||
Admission 02h15. Motif : douleur lombaire droite brutale irradiant fosse iliaque,
|
||||
EVA 9/10, agitation, nausées, premier épisode.
|
||||
Constantes : TA 145/88, FC 96, SpO2 98%, T° 37.0, BU : hématurie ++.
|
||||
Examen : pas de défense, fosse lombaire droite douloureuse, pas de fièvre.
|
||||
Bio : créat 88 µmol/L, CRP < 5, leucos 8.5 G/L. ECBU envoyé.
|
||||
Scanner abdo-pelvien sans injection : calcul urétéral droit 4 mm, dilatation
|
||||
pyélocalicielle modérée, pas de complication.
|
||||
Antalgie : kétoprofène 100 mg IV puis paracétamol 1 g + phloroglucinol IV.
|
||||
Évolution : EVA 1/10 à H+2, reprise alimentation OK, miction spontanée.
|
||||
Conseils filtration urines, ordonnance AINS + alpha-bloquant (tamsulosine),
|
||||
consultation urologie sous 8 jours. Sortie 06h50.
|
||||
Durée passage : 4h35.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "simple",
|
||||
"titre": "Fièvre virale enfant",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": ["bilan rassurant", "examen normal", "retour domicile"],
|
||||
"dpi": dedent("""
|
||||
Enfant T., 4 ans, vaccinations à jour, accompagné des parents.
|
||||
Admission 19h30. Motif : fièvre 39°C depuis 24h, rhinorrhée, toux sèche,
|
||||
asthénie modérée, alimentation conservée, pas de vomissement.
|
||||
Constantes : TA 100/60, FC 130 (fébrile), SpO2 99% AA, T° 38.7, FR 24.
|
||||
Examen : pharynx érythémateux, pas de purulence, tympans normaux,
|
||||
auscultation pulmonaire libre, abdomen souple, pas de syndrome méningé,
|
||||
pas d'éruption, comportement conservé après antipyrétique.
|
||||
Pas de bilan biologique réalisé (clinique rassurante).
|
||||
Paracétamol 15 mg/kg PO, recontrôle T° à H+1 : 37.4°C.
|
||||
Diagnostic : virose ORL probable.
|
||||
Conseils parents : hydratation, paracétamol alterné si besoin,
|
||||
reconsulter si signe de gravité (geignement, marbrures, refus boisson).
|
||||
Sortie 21h00. Durée : 1h30.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "simple",
|
||||
"titre": "Crise d'asthme légère",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": ["réponse rapide aérosols", "DEP > 80% post", "retour domicile"],
|
||||
"dpi": dedent("""
|
||||
Mme R., 22 ans, asthme connu, traitement de fond Symbicort.
|
||||
Admission 17h45. Motif : dyspnée sifflante depuis 3h après effort,
|
||||
toux, oppression thoracique, première crise de la saison pollens.
|
||||
Constantes : TA 128/76, FC 102, SpO2 95% AA, T° 36.8, FR 22.
|
||||
Examen : sibilants expiratoires diffus, pas de tirage, pas de cyanose,
|
||||
parle en phrases complètes. DEP entrée : 280 L/min (60% théorique).
|
||||
Aérosols : salbutamol 5 mg + ipratropium 0.5 mg x 2 à 20 min d'intervalle,
|
||||
corticoïdes PO prednisolone 60 mg.
|
||||
Évolution H+1 : SpO2 99%, FR 16, auscultation libre, DEP 420 L/min (90%).
|
||||
Ordonnance : poursuite Symbicort, salbutamol PRN, prednisolone 5j,
|
||||
consultation pneumologue. Sortie 19h50. Durée : 2h05.
|
||||
""").strip(),
|
||||
},
|
||||
|
||||
# ---------- 5 cas COMPLEXES → REQUALIFICATION_HOSPITALISATION ----------
|
||||
{
|
||||
"id": 6,
|
||||
"type": "complexe",
|
||||
"titre": "Pneumopathie hypoxémiante",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_cles": ["O2 thérapie", "antibiothérapie IV", "surveillance scopée > 24h", "transfert service"],
|
||||
"dpi": dedent("""
|
||||
M. B., 71 ans, BPCO stade II, ex-tabac 40 PA, HTA, diabète T2.
|
||||
Admission 21h10. Motif : fièvre 39.5°C depuis 48h, toux productive
|
||||
verdâtre, dyspnée croissante, douleur basithoracique droite.
|
||||
Constantes entrée : TA 105/62, FC 118, SpO2 86% AA → 92% sous O2 4 L/min,
|
||||
T° 39.4, FR 28, marbrures genoux.
|
||||
Examen : foyer crépitant base droite, tirage modéré,
|
||||
confusion légère (GCS 14).
|
||||
Bio : CRP 287 mg/L, leucos 18.2 G/L, PCT 4.8 ng/mL, lactates 2.6,
|
||||
créat 132 µmol/L (base 90), iono normal, gaz : pH 7.42 / PaO2 58 sous O2.
|
||||
RX thorax : opacité alvéolaire systématisée lobe inférieur droit.
|
||||
Antigénurie pneumocoque +.
|
||||
CRB-65 : 3 (confusion, FR 28, âge ≥ 65).
|
||||
PEC : O2 thérapie continue, ceftriaxone 2 g IV + spiramycine,
|
||||
remplissage NaCl 1 L, scope continu, surveillance toutes 2h.
|
||||
Évolution H+18 : SpO2 95% sous O2 2 L, T° 38.0, FR 22, lactates 1.4.
|
||||
UHCD puis transfert pneumologie à H+22 pour poursuite ATB IV
|
||||
et sevrage O2. Durée passage urgences : 22h.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "complexe",
|
||||
"titre": "OAP cardiogénique",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_cles": ["VNI", "diurétiques IV répétés", "USC/USIC", "surveillance > 24h"],
|
||||
"dpi": dedent("""
|
||||
Mme V., 79 ans, cardiopathie ischémique (IDM 2019), FEVG 35%,
|
||||
HTA, FA paroxystique sous AVK, insuffisance rénale chronique stade III.
|
||||
Admission 03h25. Motif : dyspnée brutale en décubitus, orthopnée
|
||||
+++, expectoration mousseuse rosée, anxiété majeure.
|
||||
Constantes : TA 178/102, FC 124 irrégulier (FA rapide), SpO2 82% AA
|
||||
→ 90% sous O2 MHC 12 L/min, T° 36.5, FR 36, sueurs.
|
||||
Examen : crépitants bilatéraux jusqu'aux sommets, turgescence jugulaire,
|
||||
OMI bilatéraux godet +.
|
||||
ECG : FA 130 bpm, ondes T négatives en latéral, pas de sus-décalage.
|
||||
Bio : tropos 0.08 puis 0.12 µg/L (légère ascension), BNP 4 850 pg/mL,
|
||||
créat 168 µmol/L (base 130), iono : Na 132, K 4.8.
|
||||
RX thorax : surcharge alvéolo-interstitielle bilatérale.
|
||||
ETT urgences : FEVG 25%, IM modérée, OG dilatée.
|
||||
PEC : VNI (CPAP) PEP 8 cmH2O FiO2 60%, furosémide 80 mg IV bolus
|
||||
puis 40 mg x 2, dérivés nitrés IVSE, ralentissement FA par amiodarone IVSE.
|
||||
Évolution H+6 : sevrage VNI, SpO2 94% sous O2 3 L, FC 95.
|
||||
Bilan diurèse : -1.8 L à H+12. Tropos H+6 : 0.18 (stable légèrement).
|
||||
Transfert USIC à H+14 pour surveillance, gestion FA et coronarographie
|
||||
programmée. Durée passage : 14h.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "complexe",
|
||||
"titre": "AVC ischémique thrombolysé",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_cles": ["thrombolyse IV", "filière AVC", "transfert UNV", "surveillance neuro rapprochée"],
|
||||
"dpi": dedent("""
|
||||
M. P., 68 ans, HTA, dyslipidémie, tabagisme actif, FA paroxystique
|
||||
non anticoagulée (refus patient).
|
||||
Admission 11h08, début symptômes 10h30 (témoin direct épouse).
|
||||
Motif : déficit moteur hémicorps droit + aphasie d'expression brutale.
|
||||
NIHSS entrée : 14 (motricité 4, langage 3, négligence, dysarthrie).
|
||||
Constantes : TA 184/96, FC 88 irrégulier, SpO2 98%, T° 36.7, glycémie 7.2.
|
||||
ECG : FA 90 bpm.
|
||||
Activation filière AVC (thrombolyse).
|
||||
Scanner cérébral non injecté : pas d'hémorragie, ASPECTS 9.
|
||||
AngioTDM : occlusion M1 sylvienne gauche.
|
||||
PEC : altéplase 0.9 mg/kg IV (10% bolus + 90% sur 1h) débutée à 11h45
|
||||
(door-to-needle 37 min), surveillance neuro et TA toutes 15 min.
|
||||
Indication thrombectomie : transfert UNV de référence à 12h40
|
||||
(médicalisé SMUR), récupération partielle déficit pendant transport.
|
||||
Durée passage urgences : 1h32 — mais relève d'une hospitalisation
|
||||
(filière AVC complète avec UNV + thrombectomie, surveillance ≥ 24h).
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "complexe",
|
||||
"titre": "Sepsis sur pyélonéphrite obstructive",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_cles": ["antibiothérapie IV", "remplissage", "drainage urinaire", "surveillance > 48h"],
|
||||
"dpi": dedent("""
|
||||
Mme F., 64 ans, diabète T2 mal équilibré (HbA1c 9.2),
|
||||
antécédent de calculs rénaux.
|
||||
Admission 23h40. Motif : fièvre 40°C avec frissons depuis 24h,
|
||||
douleur lombaire droite, vomissements, troubles confusionnels
|
||||
apparus dans l'après-midi.
|
||||
Constantes : TA 88/52, FC 128, SpO2 96%, T° 39.8, FR 26,
|
||||
marbrures, GCS 13. qSOFA 3.
|
||||
Examen : douleur intense fosse lombaire droite, BU leucos +++ nitrites +,
|
||||
pas de défense abdominale.
|
||||
Bio : CRP 412, leucos 22, PCT 28 ng/mL (élevé +++), lactates 3.9,
|
||||
créat 198 µmol/L (base 75 → IRA), glycémie 4.1 g/L.
|
||||
ECBU : direct nombreux BGN.
|
||||
TDM abdo injecté : pyélonéphrite droite sur calcul urétéral 7 mm
|
||||
avec dilatation cavités, pas d'abcès.
|
||||
Diagnostic : sepsis sévère sur pyélonéphrite obstructive lithiasique.
|
||||
PEC : remplissage 30 mL/kg cristalloïdes (2L sur 1h),
|
||||
ceftriaxone 2 g IV + amikacine 25 mg/kg, scope continu.
|
||||
Avis urologue : drainage par sonde JJ en urgence (programmée H+4).
|
||||
Évolution H+3 : TA 105/65 après remplissage, lactates 2.1,
|
||||
FC 108. Toujours fébrile.
|
||||
Transfert bloc urgence pour JJ, puis hospitalisation urologie
|
||||
pour poursuite ATB IV et surveillance. Durée passage : 5h30.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "complexe",
|
||||
"titre": "SCA NSTEMI",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_cles": ["tropos cinétique +", "anticoagulation", "USIC", "coronarographie"],
|
||||
"dpi": dedent("""
|
||||
M. M., 58 ans, tabagique 30 PA, hypercholestérolémie non traitée,
|
||||
père décédé IDM à 60 ans.
|
||||
Admission 08h15. Motif : douleur thoracique constrictive rétrosternale
|
||||
irradiant bras gauche, apparue ce matin au réveil 06h00, durée 1h
|
||||
puis récidive 07h30 persistante, sueurs.
|
||||
Constantes : TA 142/86, FC 92, SpO2 98%, T° 36.8, FR 16.
|
||||
Examen : bruits du cœur réguliers, pas de souffle, auscultation
|
||||
pulmonaire libre, pas de signe d'IVD.
|
||||
ECG entrée : ST sous-décalé V4-V6 et DII-DIII-aVF (1 mm), ondes T
|
||||
négatives latérales, pas de sus-décalage. Pas de BBG.
|
||||
Bio H0 : tropos US 145 ng/L (N < 14), CK 220, créat 92,
|
||||
iono normal, NFS normale.
|
||||
Bio H+3 : tropos US 480 ng/L (cinétique nette), CK-MB 28.
|
||||
Diagnostic : SCA NSTEMI (NSTE-ACS, GRACE 142, TIMI 4).
|
||||
PEC : aspirine 250 mg + ticagrelor 180 mg dose de charge,
|
||||
HBPM enoxaparine 1 mg/kg/12h, atorvastatine 80 mg, IEC, b-bloquant.
|
||||
Dérivés nitrés IV pour douleur résiduelle.
|
||||
Avis cardio : coronarographie H+24 (stratification GRACE).
|
||||
Transfert USIC à H+4 pour surveillance et coro programmée.
|
||||
Durée passage urgences : 4h20.
|
||||
""").strip(),
|
||||
},
|
||||
|
||||
# ---------- 5 cas BORDERLINE — la vraie valeur business ----------
|
||||
{
|
||||
"id": 11,
|
||||
"type": "borderline",
|
||||
"titre": "Douleur thoracique jeune, bilan négatif",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": [
|
||||
"tropos x2 négatives", "ECG normaux", "patient jeune sans FdR",
|
||||
"sortie sous 12h sans surveillance scopée", "pas de transfert"
|
||||
],
|
||||
"dpi": dedent("""
|
||||
M. C., 31 ans, sans antécédent, non tabagique, pas d'ATCD familial cardiovasculaire.
|
||||
Admission 22h40. Motif : douleur thoracique antérieure intermittente depuis 4h,
|
||||
non irradiante, modulée par les changements de position, contexte de stress
|
||||
professionnel. EVA 4/10. Pas de dyspnée, pas de palpitations.
|
||||
Constantes : TA 128/74, FC 78, SpO2 99% AA, T° 36.7, FR 14. État général conservé,
|
||||
patient anxieux mais cohérent.
|
||||
Examen : auscultation cardio-pulmonaire normale, pas de douleur reproductible
|
||||
à la palpation, mollets souples.
|
||||
ECG H0 : rythme sinusal régulier, pas d'anomalie de la repolarisation,
|
||||
pas de sus ni sous-décalage. ECG H+3 : superposable.
|
||||
Bio : tropos US H0 = 6 ng/L (N < 14), tropos US H+3 = 7 ng/L (pas de cinétique).
|
||||
D-Dimères : 280 ng/mL (négatifs). NFS, iono, créat normaux.
|
||||
RX thorax : sans particularité.
|
||||
Score HEART : 2 (faible risque).
|
||||
Évolution : douleur résiduelle 1/10 sous antalgie palier 1, patient rassuré.
|
||||
Avis cardio téléphonique : pas d'argument SCA, autorisation sortie.
|
||||
Conseils reconsulter si récidive avec signes de gravité.
|
||||
Sortie 09h15 le lendemain (durée passage 10h35) — observation simple sans scope.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "borderline",
|
||||
"titre": "Personne âgée, malaise, surveillance UHCD > 24h",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_cles": [
|
||||
"durée UHCD > 24h", "bilan étiologique itératif",
|
||||
"surveillance neuro répétée", "patient gériatrique fragile"
|
||||
],
|
||||
"dpi": dedent("""
|
||||
Mme G., 84 ans, vit seule à domicile, autonome avec déambulateur.
|
||||
ATCD : HTA, FA sous AOD (apixaban), insuffisance cardiaque NYHA II,
|
||||
arthrose, ostéoporose. Polymédication 8 traitements.
|
||||
Admission 10h15 amenée par les pompiers. Motif : malaise sans prodromes
|
||||
au lever, chute de sa hauteur, pas de PC clairement objectivée
|
||||
(vit seule, retrouvée par voisine 30 min plus tard, confuse à l'arrivée).
|
||||
Hématome pariétal droit, plaie superficielle arcade.
|
||||
Constantes entrée : TA 102/58, FC 52, SpO2 96%, T° 36.4, glycémie 0.94.
|
||||
Examen : confusion légère (MMSE non évaluable), pas de déficit neuro
|
||||
focal, mobilité conservée, douleur hanche droite à la mobilisation.
|
||||
ECG : FA lente 50 bpm, pas de trouble de conduction. RX bassin/hanche : pas
|
||||
de fracture. Scanner cérébral non injecté : pas de saignement, atrophie
|
||||
cortico-sous-corticale. Bio : NFS normale, créat 112 (base 90), iono normal,
|
||||
tropos négatives, BNP 480, INR non applicable (AOD).
|
||||
Hypothèse : malaise sur bradycardie médicamenteuse (b-bloquant + AOD)
|
||||
vs étiologie cardiogénique. Holter ECG demandé.
|
||||
UHCD pour surveillance neuro et hémodynamique répétée toutes 4h.
|
||||
Évolution : nouveau malaise H+8 sans PC, FC 42, arrêt b-bloquant en accord
|
||||
avec cardio. ETT à H+18 : FEVG 50%, pas d'anomalie aiguë.
|
||||
Sortie envisagée H+24 mais maintien observation jusqu'à H+27 pour stabilité
|
||||
tensionnelle puis retour domicile avec aide à domicile renforcée.
|
||||
Durée totale passage : 27h.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "borderline",
|
||||
"titre": "Intoxication médicamenteuse volontaire",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_cles": [
|
||||
"surveillance toxico prolongée", "N-acétylcystéine IV",
|
||||
"avis psy avec hospit en aval", "durée > 18h"
|
||||
],
|
||||
"dpi": dedent("""
|
||||
Mlle T., 22 ans, étudiante, antécédents de troubles anxieux suivis,
|
||||
rupture sentimentale récente.
|
||||
Admission 23h05 amenée par sa colocataire. Motif : ingestion volontaire
|
||||
estimée à 30 cp paracétamol 500 mg (15 g) + 10 cp lorazépam 1 mg
|
||||
il y a environ 4h, dans un but suicidaire. Verbalisation des idées noires
|
||||
persistantes à l'arrivée.
|
||||
Constantes : TA 110/68, FC 84, SpO2 99% AA, T° 36.5, GCS 14
|
||||
(somnolence légère, réveillable), pupilles intermédiaires symétriques.
|
||||
Examen : pas de signes hépatiques, abdomen souple.
|
||||
Bio H+5 post-ingestion : paracétamolémie 188 mg/L (au-dessus du nomogramme
|
||||
de Rumack-Matthew, ligne de toxicité), ASAT 45, ALAT 38, TP 88%, créat 72.
|
||||
Toxique urinaire : benzodiazépines +.
|
||||
ECG : RS 84, QT normal.
|
||||
PEC : N-acétylcystéine IV protocole 21h (bolus 150 mg/kg puis 50 puis 100),
|
||||
scope continu, bilan hépatique itératif H+8 H+16 H+24.
|
||||
Évolution H+12 : ASAT 62, ALAT 58, TP 82%, paracétamolémie en
|
||||
décroissance. GCS 15 stable.
|
||||
Avis psychiatre de garde H+18 : hospitalisation en psychiatrie
|
||||
sectorielle après fin du protocole somatique, risque suicidaire
|
||||
élevé maintenu, refus de sortie.
|
||||
Transfert service psychiatrie H+22 après bilan hépato H+24 stable.
|
||||
Durée passage urgences : 22h.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "borderline",
|
||||
"titre": "Vertige rotatoire isolé bénin",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": [
|
||||
"examen neuro normal", "manœuvre de Dix-Hallpike +",
|
||||
"pas de transfert", "sortie rapide après traitement"
|
||||
],
|
||||
"dpi": dedent("""
|
||||
Mme A., 56 ans, HTA traitée, pas d'autre antécédent.
|
||||
Admission 08h30. Motif : vertige rotatoire brutal au lever ce matin,
|
||||
durée environ 30 sec puis récidive aux changements de position,
|
||||
nausées, pas de vomissement, pas de céphalée, pas de troubles auditifs.
|
||||
Constantes : TA 142/82, FC 76, SpO2 98%, T° 36.6, glycémie 5.8.
|
||||
Examen neuro : pas de déficit moteur ni sensitif, pas de syndrome
|
||||
cérébelleux, paires crâniennes normales, pas de nystagmus spontané,
|
||||
HINTS test rassurant (pas de skew, saccades de rattrapage normales).
|
||||
Manœuvre de Dix-Hallpike : nystagmus géotropique torsionnel typique
|
||||
à droite, latence courte, fatigabilité.
|
||||
Diagnostic retenu : vertige paroxystique positionnel bénin (VPPB)
|
||||
canal postérieur droit.
|
||||
PEC : manœuvre libératoire d'Epley réalisée, disparition des symptômes,
|
||||
antiémétique métopimazine PO.
|
||||
Pas de bilan biologique, pas d'imagerie (clinique typique, examen normal).
|
||||
Sortie 12h00 avec consignes positionnelles et consultation ORL programmée
|
||||
si récidive. Durée passage : 3h30.
|
||||
""").strip(),
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "borderline",
|
||||
"titre": "Pyélonéphrite simple non sévère",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_cles": [
|
||||
"pas de sepsis", "ATB IV unique puis relais PO",
|
||||
"tolérance digestive bonne", "sortie sous 8h"
|
||||
],
|
||||
"dpi": dedent("""
|
||||
Mme N., 34 ans, sans antécédent particulier, pas de grossesse,
|
||||
pas d'uropathie connue.
|
||||
Admission 16h20. Motif : douleur lombaire droite + brûlures mictionnelles
|
||||
depuis 48h, fièvre 38.7°C ce matin, frissons. Pas de vomissement,
|
||||
alimentation conservée.
|
||||
Constantes : TA 122/74, FC 92, SpO2 99%, T° 38.5, FR 16. qSOFA 0.
|
||||
Examen : douleur lombaire droite à la palpation, BU leucos +++ nitrites +,
|
||||
pas de défense abdominale, état général conservé.
|
||||
Bio : CRP 88, leucos 13 G/L, créat 76, lactates 1.2 (normaux),
|
||||
PCT 0.4 ng/mL. ECBU envoyé (en attente).
|
||||
Échographie réno-vésicale au lit : pas de dilatation, pas d'obstacle.
|
||||
Diagnostic : pyélonéphrite aiguë simple non compliquée, non sévère.
|
||||
PEC : ceftriaxone 1 g IV dose unique aux urgences, paracétamol PO,
|
||||
réhydratation orale.
|
||||
Évolution H+4 : T° 37.6, douleur EVA 2/10, tolérance alimentaire OK.
|
||||
Ordonnance : relais PO ciprofloxacine 500 mg x 2/j 7 jours,
|
||||
consultation médecin traitant 48-72h, ECBU à récupérer.
|
||||
Sortie 23h45 (durée 7h25) avec critères de reconsultation expliqués.
|
||||
""").strip(),
|
||||
},
|
||||
]
|
||||
238
demo/facturation_urgences/demo_app.py
Normal file
238
demo/facturation_urgences/demo_app.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
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**.")
|
||||
264
demo/facturation_urgences/resultats.json
Normal file
264
demo/facturation_urgences/resultats.json
Normal file
@@ -0,0 +1,264 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"titre": "Entorse cheville droite",
|
||||
"type": "simple",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_attendus": [
|
||||
"durée < 4h",
|
||||
"pas de surveillance scopée",
|
||||
"retour domicile"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "FORFAIT_URGENCE",
|
||||
"criteres": [
|
||||
"Torsion de cheville sans fracture",
|
||||
"Douleur contrôlée par antalgie",
|
||||
"Pas de surveillance prolongée",
|
||||
"Retour à domicile possible"
|
||||
],
|
||||
"justification": "La patiente présente une torsion de cheville sans fracture, avec une douleur contrôlée par antalgie et une mobilité conservée. Elle est sortie de l'hôpital avec une attelle et une consultation médicale. Ces éléments indiquent un passage simple et ponctuel, sans nécessité de surveillance prolongée en UHCD.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 3.1,
|
||||
"_eval_count": 151
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"titre": "Plaie main suturée",
|
||||
"type": "simple",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_attendus": [
|
||||
"geste ponctuel",
|
||||
"durée < 3h",
|
||||
"retour domicile"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "FORFAIT_URGENCE",
|
||||
"criteres": [
|
||||
"Plaie superficielle (4 cm)",
|
||||
"Pas d'atteinte tendineuse",
|
||||
"Sensibilité normale",
|
||||
"Saignement contrôlé",
|
||||
"Pas de surveillance prolongée",
|
||||
"Retour à domicile"
|
||||
],
|
||||
"justification": "La plaie est superficielle, sans atteinte tendineuse ni perte de sensibilité. Le saignement est contrôlé et le patient est sorti rapidement avec des conseils de soins. Il n'y a pas de signes de gravité nécessitant une hospitalisation prolongée.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.3,
|
||||
"_eval_count": 154
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"titre": "Colique néphrétique non compliquée",
|
||||
"type": "simple",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_attendus": [
|
||||
"soulagement obtenu",
|
||||
"pas de complication",
|
||||
"retour domicile"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "FORFAIT_URGENCE",
|
||||
"criteres": [
|
||||
"Douleur lombaire droite brutale avec EVA 9/10",
|
||||
"Hématurie ++",
|
||||
"Calcul urétéral droit 4 mm",
|
||||
"Réponse rapide à l'antalgie (kétoprofène, paracétamol, phloroglucinol)",
|
||||
"Reprise alimentation et miction spontanée",
|
||||
"Pas de signes de gravité (pas de fièvre, pas de défense)"
|
||||
],
|
||||
"justification": "Le patient présente une douleur lombaire aiguë avec hématurie et un calcul urétéral. Il a répondu rapidement à l'antalgie et a retrouvé une miction spontanée. Il n'y a pas de signes de complication, de surveillance prolongée ou de besoin de soins continus. La durée du passage est courte (4h35).",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.8,
|
||||
"_eval_count": 217
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"titre": "Fièvre virale enfant",
|
||||
"type": "simple",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_attendus": [
|
||||
"bilan rassurant",
|
||||
"examen normal",
|
||||
"retour domicile"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "FORFAIT_URGENCE",
|
||||
"criteres": [
|
||||
"Fièvre résolue après paracétamol",
|
||||
"Absence de signes de gravité (geignement, marbrures, refus boisson)",
|
||||
"Retour à domicile sans surveillance prolongée"
|
||||
],
|
||||
"justification": "Le patient présente une fièvre résolue après administration de paracétamol, sans signes de gravité. Il est sorti de l'hôpital après une courte durée de séjour (1h30) et sans nécessité de surveillance prolongée. Ces éléments permettent de considérer que le passage relève d'un forfait urgence.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.3,
|
||||
"_eval_count": 155
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"titre": "Crise d'asthme légère",
|
||||
"type": "simple",
|
||||
"verite_terrain": "FORFAIT_URGENCE",
|
||||
"criteres_attendus": [
|
||||
"réponse rapide aérosols",
|
||||
"DEP > 80% post",
|
||||
"retour domicile"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "FORFAIT_URGENCE",
|
||||
"criteres": [
|
||||
"Retour à domicile sans surveillance prolongée",
|
||||
"Amélioration clinique rapide avec les aérosols",
|
||||
"SpO2 stable et bonne tolérance des traitements"
|
||||
],
|
||||
"justification": "La patiente a présenté une crise d'asthme aiguë réversible avec une amélioration clinique rapide après traitement. Elle a été traitée en urgence avec des aérosols et des corticostéroïdes, puis a été mise à disposition à domicile avec une consultation pneumologique. Il n'y a pas de signes de gravité ou de nécessité de surveillance prolongée.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.4,
|
||||
"_eval_count": 160
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"titre": "Pneumopathie hypoxémiante",
|
||||
"type": "complexe",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_attendus": [
|
||||
"O2 thérapie",
|
||||
"antibiothérapie IV",
|
||||
"surveillance scopée > 24h",
|
||||
"transfert service"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres": [
|
||||
"Séjour MCO requis : surveillance scopée prolongée",
|
||||
"Soins continus IV (ceftriaxone, spiramycine)",
|
||||
"Oxygénothérapie/VNI (O2 4L/min puis 2L)",
|
||||
"Examens itératifs (RX thorax)",
|
||||
"Transfert vers service spécialisé (pneumologie)",
|
||||
"Durée > 24h en UHCD"
|
||||
],
|
||||
"justification": "Le patient présente une pneumonie lobaire droite avec hypoxémie nécessitant une oxygénothérapie continue et une surveillance étroite. La surveillance scopée, les soins IV, les examens itératifs et le transfert vers le service de pneumologie justifient une requalification en hospitalisation.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.7,
|
||||
"_eval_count": 204
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"titre": "OAP cardiogénique",
|
||||
"type": "complexe",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_attendus": [
|
||||
"VNI",
|
||||
"diurétiques IV répétés",
|
||||
"USC/USIC",
|
||||
"surveillance > 24h"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres": [
|
||||
"Séjour MCO requis : surveillance scopée prolongée",
|
||||
"Soins continus IV (VNI, dérivés nitrés)",
|
||||
"Oxygénothérapie/VNI",
|
||||
"Examens itératifs (ECG, RX thorax)",
|
||||
"Transfert vers service spécialisé (USIC)",
|
||||
"Durée > 24h en UHCD"
|
||||
],
|
||||
"justification": "La patiente a nécessité une surveillance continue avec VNI et dérivés nitrés, une oxygénothérapie à haut débit, des examens itératifs et un transfert vers le service d'Unité de Soins Intensifs Coronariens (USIC) pour une gestion de la fibrillation auriculaire et une coronarographie programmée. La durée du séjour en unité hospitalière a dépassé 24 heures, justifiant une requalification en hospitalisation.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.8,
|
||||
"_eval_count": 227
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"titre": "AVC ischémique thrombolysé",
|
||||
"type": "complexe",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_attendus": [
|
||||
"thrombolyse IV",
|
||||
"filière AVC",
|
||||
"transfert UNV",
|
||||
"surveillance neuro rapprochée"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres": [
|
||||
"Transfert vers UNV de référence pour thrombectomie",
|
||||
"Surveillance neuro et TA toutes 15 min",
|
||||
"Durée de la surveillance > 24h",
|
||||
"Thrombectomie réalisée",
|
||||
"Filière AVC complète avec UNV"
|
||||
],
|
||||
"justification": "Le patient a été transféré vers un UNV de référence pour thrombectomie, nécessitant une surveillance neuro et TA continue pendant plus de 24 heures. La présence d'une occlusion de l'artère sylvienne gauche justifie une hospitalisation complète avec suivi en filière AVC. Le patient a bénéficié d'une thrombectomie, ce qui implique une hospitalisation.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.6,
|
||||
"_eval_count": 192
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"titre": "Sepsis sur pyélonéphrite obstructive",
|
||||
"type": "complexe",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_attendus": [
|
||||
"antibiothérapie IV",
|
||||
"remplissage",
|
||||
"drainage urinaire",
|
||||
"surveillance > 48h"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres": [
|
||||
"Séjour MCO requis : surveillance scopée prolongée (scope continu), soins continus IV (remplissage), oxygénothérapie/VNI (pas spécifié mais possible), examens itératifs (TDM), transfert vers service spécialisé (urologie), durée > 24h en UHCD (5h30)",
|
||||
"Critères de gravité : sepsis sévère (qSOFA 3, CRP 412, PCT 28, lactates 3.9, créat 198), diabète mal équilibré",
|
||||
"Diagnostic : sepsis sévère sur pyélonéphrite obstructive lithiasique nécessitant une prise en charge hospitalière prolongée"
|
||||
],
|
||||
"justification": "La patiente présente un sepsis sévère avec pyélonéphrite obstructive lithiasique nécessitant une surveillance scopée prolongée, des soins continus IV, une oxygénothérapie/VNI possible, des examens itératifs, un transfert vers un service spécialisé (urologie) et une durée de séjour supérieure à 24 heures en UHCD. La présence de diabète mal équilibré et de calculs rénaux complique la prise en charge et justifie une hospitalisation prolongée.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 2.4,
|
||||
"_eval_count": 308
|
||||
},
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"titre": "SCA NSTEMI",
|
||||
"type": "complexe",
|
||||
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres_attendus": [
|
||||
"tropos cinétique +",
|
||||
"anticoagulation",
|
||||
"USIC",
|
||||
"coronarographie"
|
||||
],
|
||||
"prediction": {
|
||||
"decision": "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres": [
|
||||
"Durée du séjour en urgence > 24h",
|
||||
"Surveillance scopée prolongée (coronarographie programmée)",
|
||||
"Soins continus IV (HBPM enoxaparine, dérivés nitrés)",
|
||||
"Transfert vers service spécialisé (USIC)",
|
||||
"Critères de gravité (SCA NSTEMI, GRACE 142, TIMI 4)"
|
||||
],
|
||||
"justification": "Le patient a été admis pour une douleur thoracique, diagnostiqué comme un SCA NSTEMI. Il a été traité avec des médicaments IV, surveillé en urgence et transféré vers l'USIC pour une coronarographie programmée. La durée du séjour en urgence est supérieure à 24h, justifiant une requalification en hospitalisation.",
|
||||
"confiance": "elevee",
|
||||
"_elapsed_s": 1.7,
|
||||
"_eval_count": 212
|
||||
},
|
||||
"match": true
|
||||
}
|
||||
]
|
||||
5615
demo/facturation_urgences/resultats_v2.json
Normal file
5615
demo/facturation_urgences/resultats_v2.json
Normal file
File diff suppressed because it is too large
Load Diff
35
demo/facturation_urgences/run_apollo2.py
Normal file
35
demo/facturation_urgences/run_apollo2.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test Apollo2-9B (Q4_K_S, médical multilingue avec FR explicite)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from run_simulation_v2 import run_one_model, stats_for_results # noqa: E402
|
||||
|
||||
MODEL = "hf.co/mradermacher/Apollo2-9B-GGUF:Q4_K_S"
|
||||
|
||||
results = run_one_model(MODEL)
|
||||
s = stats_for_results(results)
|
||||
print(f"\n>>> {s['correct']}/{s['n']} ({100*s['accuracy']:.0f}%)")
|
||||
print(f" S={s['by_type'].get('simple', (0,0))} C={s['by_type'].get('complexe', (0,0))} B={s['by_type'].get('borderline', (0,0))}")
|
||||
print(f" latence={s['avg_latency_s']:.1f}s parse_err={s['parse_errors']} conf={s['confiance_distribution']}")
|
||||
|
||||
results_path = Path(__file__).parent / "resultats_v2.json"
|
||||
all_data = json.loads(results_path.read_text(encoding="utf-8"))
|
||||
all_data[MODEL] = [
|
||||
{
|
||||
"id": r["cas"]["id"],
|
||||
"titre": r["cas"]["titre"],
|
||||
"type": r["cas"]["type"],
|
||||
"verite_terrain": r["cas"]["verite_terrain"],
|
||||
"criteres_attendus": r["cas"]["criteres_cles"],
|
||||
"prediction": r["out"],
|
||||
"decision": r["decision"],
|
||||
"match": r["match"],
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
results_path.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f" → mergé dans {results_path.name}")
|
||||
48
demo/facturation_urgences/run_extra_models.py
Normal file
48
demo/facturation_urgences/run_extra_models.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tester 3 modèles additionnels et merger dans resultats_v2.json :
|
||||
- t2a-gemma3-27b-q4:latest (fine-tune T2A maison — clé du test)
|
||||
- DeepSeek-R1:latest (reasoning, FR correct)
|
||||
- gpt-oss:120b-cloud (top tier 2026)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from run_simulation_v2 import run_one_model, stats_for_results # noqa: E402
|
||||
|
||||
EXTRA_MODELS = [
|
||||
"t2a-gemma3-27b-q4:latest",
|
||||
"DeepSeek-R1:latest",
|
||||
"gpt-oss:120b-cloud",
|
||||
]
|
||||
|
||||
results_path = Path(__file__).parent / "resultats_v2.json"
|
||||
all_data = json.loads(results_path.read_text(encoding="utf-8"))
|
||||
|
||||
for model in EXTRA_MODELS:
|
||||
print(f"\n>>> Test {model}")
|
||||
results = run_one_model(model)
|
||||
s = stats_for_results(results)
|
||||
print(f" → {s['correct']}/{s['n']} ({100*s['accuracy']:.0f}%) "
|
||||
f"S={s['by_type'].get('simple', (0,0))} "
|
||||
f"C={s['by_type'].get('complexe', (0,0))} "
|
||||
f"B={s['by_type'].get('borderline', (0,0))} "
|
||||
f"latence={s['avg_latency_s']:.1f}s parse_err={s['parse_errors']}")
|
||||
|
||||
all_data[model] = [
|
||||
{
|
||||
"id": r["cas"]["id"],
|
||||
"titre": r["cas"]["titre"],
|
||||
"type": r["cas"]["type"],
|
||||
"verite_terrain": r["cas"]["verite_terrain"],
|
||||
"criteres_attendus": r["cas"]["criteres_cles"],
|
||||
"prediction": r["out"],
|
||||
"decision": r["decision"],
|
||||
"match": r["match"],
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
results_path.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f" → mergé dans {results_path.name}")
|
||||
35
demo/facturation_urgences/run_medgemma27b.py
Normal file
35
demo/facturation_urgences/run_medgemma27b.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test du MedGemma 27B (thiagomoraes Q4_K_S) et merge dans resultats_v2.json."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from run_simulation_v2 import run_one_model, stats_for_results # noqa: E402
|
||||
|
||||
MODEL = "thiagomoraes/medgemma-27b-it:Q4_K_S"
|
||||
|
||||
results = run_one_model(MODEL)
|
||||
s = stats_for_results(results)
|
||||
print(f"\n>>> {s['correct']}/{s['n']} ({100*s['accuracy']:.0f}%)")
|
||||
print(f" S={s['by_type'].get('simple', (0,0))} C={s['by_type'].get('complexe', (0,0))} B={s['by_type'].get('borderline', (0,0))}")
|
||||
print(f" latence={s['avg_latency_s']:.1f}s parse_err={s['parse_errors']} conf={s['confiance_distribution']}")
|
||||
|
||||
results_path = Path(__file__).parent / "resultats_v2.json"
|
||||
all_data = json.loads(results_path.read_text(encoding="utf-8"))
|
||||
all_data[MODEL] = [
|
||||
{
|
||||
"id": r["cas"]["id"],
|
||||
"titre": r["cas"]["titre"],
|
||||
"type": r["cas"]["type"],
|
||||
"verite_terrain": r["cas"]["verite_terrain"],
|
||||
"criteres_attendus": r["cas"]["criteres_cles"],
|
||||
"prediction": r["out"],
|
||||
"decision": r["decision"],
|
||||
"match": r["match"],
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
results_path.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f" → mergé dans {results_path.name}")
|
||||
40
demo/facturation_urgences/run_qwen3_only.py
Normal file
40
demo/facturation_urgences/run_qwen3_only.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Re-tourner qwen3-next:80b-cloud uniquement, et merger dans resultats_v2.json."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from run_simulation_v2 import run_one_model, fmt_decision, stats_for_results # noqa: E402
|
||||
|
||||
MODEL = "qwen3-next:80b-cloud"
|
||||
|
||||
results = run_one_model(MODEL)
|
||||
s = stats_for_results(results)
|
||||
print(f"\nRésultat : {s['correct']}/{s['n']} ({100*s['accuracy']:.0f} %)")
|
||||
print(f" Simple : {s['by_type'].get('simple', (0, 0))}")
|
||||
print(f" Complexe : {s['by_type'].get('complexe', (0, 0))}")
|
||||
print(f" Borderline: {s['by_type'].get('borderline', (0, 0))}")
|
||||
print(f" Confiance : {s['confiance_distribution']}")
|
||||
print(f" Latence : {s['avg_latency_s']:.1f} s/cas")
|
||||
print(f" Parse err : {s['parse_errors']}")
|
||||
|
||||
# Merger dans resultats_v2.json
|
||||
results_path = Path(__file__).parent / "resultats_v2.json"
|
||||
all_data = json.loads(results_path.read_text(encoding="utf-8"))
|
||||
all_data[MODEL] = [
|
||||
{
|
||||
"id": r["cas"]["id"],
|
||||
"titre": r["cas"]["titre"],
|
||||
"type": r["cas"]["type"],
|
||||
"verite_terrain": r["cas"]["verite_terrain"],
|
||||
"criteres_attendus": r["cas"]["criteres_cles"],
|
||||
"prediction": r["out"],
|
||||
"decision": r["decision"],
|
||||
"match": r["match"],
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
results_path.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\nMerge effectué dans {results_path}")
|
||||
193
demo/facturation_urgences/run_simulation.py
Normal file
193
demo/facturation_urgences/run_simulation.py
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simulation : pertinence de MedGemma:4b pour la décision de facturation urgences.
|
||||
|
||||
Pour chaque DPI urgences synthétique, on demande au modèle :
|
||||
- décision : FORFAIT_URGENCE vs REQUALIFICATION_HOSPITALISATION
|
||||
- critères objectifs identifiés
|
||||
- justification courte
|
||||
- confiance
|
||||
|
||||
On compare à la vérité-terrain et on produit un rapport.
|
||||
|
||||
Lancer : python run_simulation.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cas_dpi import CAS # noqa: E402
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||
MODEL = "medgemma:4b"
|
||||
|
||||
PROMPT_TEMPLATE = """Tu es un médecin DIM (Département d'Information Médicale) expert en facturation T2A/PMSI aux urgences hospitalières en France.
|
||||
|
||||
À partir du dossier patient ci-dessous, tu dois déterminer si le passage relève :
|
||||
- d'un FORFAIT_URGENCE (passage simple, geste ponctuel ou bilan rassurant, retour à domicile sans surveillance prolongée)
|
||||
- d'une REQUALIFICATION_HOSPITALISATION (séjour MCO requis : surveillance scopée prolongée, soins continus IV, oxygénothérapie/VNI, examens itératifs, transfert vers service spécialisé, durée > 24h en UHCD ou critères de gravité)
|
||||
|
||||
Réponds UNIQUEMENT en JSON valide strict, sans aucun texte avant ou après, selon ce schéma :
|
||||
{{
|
||||
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
|
||||
"criteres": [liste courte de critères objectifs (3-6 items max) que tu identifies dans le dossier],
|
||||
"justification": "2-3 phrases maximum",
|
||||
"confiance": "elevee" | "moyenne" | "faible"
|
||||
}}
|
||||
|
||||
DOSSIER PATIENT :
|
||||
{dpi}
|
||||
"""
|
||||
|
||||
|
||||
def query_medgemma(dpi_text: str, timeout: int = 300) -> dict:
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"prompt": PROMPT_TEMPLATE.format(dpi=dpi_text),
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"num_predict": 600,
|
||||
"num_ctx": 4096,
|
||||
},
|
||||
}
|
||||
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()
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
elapsed = time.time() - t0
|
||||
body = json.loads(raw)
|
||||
raw_response = body.get("response", "").strip()
|
||||
try:
|
||||
parsed = json.loads(raw_response)
|
||||
except json.JSONDecodeError:
|
||||
parsed = {"_raw": raw_response, "_parse_error": True}
|
||||
parsed["_elapsed_s"] = round(elapsed, 1)
|
||||
parsed["_eval_count"] = body.get("eval_count")
|
||||
return parsed
|
||||
|
||||
|
||||
def fmt_decision(d: str) -> str:
|
||||
return {"FORFAIT_URGENCE": "FORFAIT", "REQUALIFICATION_HOSPITALISATION": "HOSPIT"}.get(d, d or "?")
|
||||
|
||||
|
||||
def run() -> None:
|
||||
print(f"\n{'=' * 78}")
|
||||
print(f" SIMULATION MedGemma:4b — Facturation urgences (forfait vs hospit)")
|
||||
print(f" Modèle : {MODEL} | Endpoint : {OLLAMA_URL}")
|
||||
print(f" Cas : {len(CAS)} DPI synthétiques")
|
||||
print(f"{'=' * 78}\n")
|
||||
|
||||
results = []
|
||||
correct = 0
|
||||
parse_errors = 0
|
||||
total_elapsed = 0.0
|
||||
|
||||
for cas in CAS:
|
||||
gt = cas["verite_terrain"]
|
||||
print(f"--- Cas {cas['id']:>2} | {cas['type']:<8} | {cas['titre']}")
|
||||
print(f" Vérité-terrain : {fmt_decision(gt)}")
|
||||
try:
|
||||
out = query_medgemma(cas["dpi"])
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
||||
print(f" ERREUR appel Ollama : {e}\n")
|
||||
results.append({"cas": cas, "out": {"_error": str(e)}, "match": False})
|
||||
continue
|
||||
|
||||
if out.get("_parse_error"):
|
||||
parse_errors += 1
|
||||
print(f" !! Réponse non-JSON : {out.get('_raw', '')[:200]}")
|
||||
results.append({"cas": cas, "out": out, "match": False})
|
||||
print()
|
||||
continue
|
||||
|
||||
decision = out.get("decision", "?")
|
||||
match = decision == gt
|
||||
if match:
|
||||
correct += 1
|
||||
total_elapsed += out.get("_elapsed_s", 0)
|
||||
|
||||
flag = "OK" if match else "KO"
|
||||
print(f" Prédiction : {fmt_decision(decision)} [{flag}]")
|
||||
print(f" Confiance : {out.get('confiance', '?')}")
|
||||
crits = out.get("criteres", [])
|
||||
if isinstance(crits, list):
|
||||
print(f" Critères : {', '.join(str(c) for c in crits[:6])}")
|
||||
else:
|
||||
print(f" Critères : {crits}")
|
||||
justif = out.get("justification", "")
|
||||
print(f" Justification : {justif}")
|
||||
print(f" Latence : {out.get('_elapsed_s', '?')}s | tokens: {out.get('_eval_count', '?')}")
|
||||
print()
|
||||
results.append({"cas": cas, "out": out, "match": match})
|
||||
|
||||
# ---------- Synthèse ----------
|
||||
n = len(CAS)
|
||||
print(f"{'=' * 78}")
|
||||
print(f" RÉSULTAT GLOBAL")
|
||||
print(f"{'=' * 78}")
|
||||
print(f" Accuracy décision : {correct}/{n} ({100 * correct / n:.0f} %)")
|
||||
print(f" Erreurs parsing JSON : {parse_errors}/{n}")
|
||||
print(f" Latence moyenne : {total_elapsed / max(1, n - parse_errors):.1f} s/cas")
|
||||
print(f" Latence cumulée : {total_elapsed:.1f} s")
|
||||
|
||||
# Matrice de confusion
|
||||
tp = sum(1 for r in results if r["match"] and r["cas"]["verite_terrain"] == "REQUALIFICATION_HOSPITALISATION")
|
||||
tn = sum(1 for r in results if r["match"] and r["cas"]["verite_terrain"] == "FORFAIT_URGENCE")
|
||||
fp = sum(1 for r in results if not r["match"] and r["cas"]["verite_terrain"] == "FORFAIT_URGENCE")
|
||||
fn = sum(1 for r in results if not r["match"] and r["cas"]["verite_terrain"] == "REQUALIFICATION_HOSPITALISATION")
|
||||
|
||||
print(f"\n Matrice de confusion (positif = HOSPIT) :")
|
||||
print(f" Vrais positifs (HOSPIT correct) : {tp}")
|
||||
print(f" Vrais négatifs (FORFAIT correct) : {tn}")
|
||||
print(f" Faux positifs (HOSPIT à tort) : {fp} → sur-codage potentiel")
|
||||
print(f" Faux négatifs (FORFAIT à tort) : {fn} → manque à gagner facturation")
|
||||
|
||||
# Tableau récap
|
||||
print(f"\n Détail par cas :")
|
||||
print(f" {'#':<3} {'Type':<9} {'GT':<8} {'Pred':<8} {'OK?':<4} {'Conf.':<8} Titre")
|
||||
print(f" {'-' * 76}")
|
||||
for r in results:
|
||||
c = r["cas"]
|
||||
out = r["out"]
|
||||
pred = fmt_decision(out.get("decision", "?")) if not out.get("_parse_error") else "PARSE!"
|
||||
gt = fmt_decision(c["verite_terrain"])
|
||||
ok = "✓" if r["match"] else "✗"
|
||||
conf = out.get("confiance", "?")[:7] if not out.get("_parse_error") else "-"
|
||||
print(f" {c['id']:<3} {c['type']:<9} {gt:<8} {pred:<8} {ok:<4} {conf:<8} {c['titre']}")
|
||||
|
||||
# Sauvegarde JSON pour réutilisation
|
||||
out_path = Path(__file__).parent / "resultats.json"
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
[
|
||||
{
|
||||
"id": r["cas"]["id"],
|
||||
"titre": r["cas"]["titre"],
|
||||
"type": r["cas"]["type"],
|
||||
"verite_terrain": r["cas"]["verite_terrain"],
|
||||
"criteres_attendus": r["cas"]["criteres_cles"],
|
||||
"prediction": r["out"],
|
||||
"match": r["match"],
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
print(f"\n Résultats détaillés sauvegardés : {out_path}")
|
||||
print(f"{'=' * 78}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
300
demo/facturation_urgences/run_simulation_v2.py
Normal file
300
demo/facturation_urgences/run_simulation_v2.py
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simulation v2 : prompt durci + comparaison multi-modèles.
|
||||
|
||||
Améliorations vs v1 :
|
||||
- Prompt anti-fuite : pas de liste d'exemples copiable, extraction littérale
|
||||
exigée depuis le DPI.
|
||||
- Sortie enrichie : elements_pour_hospitalisation / elements_pour_forfait /
|
||||
duree_passage_heures, pour surfacer le raisonnement contradictoire.
|
||||
- Confiance calibrée : règle explicite (élevée si convergence, moyenne si
|
||||
ambivalence, faible si manque d'info).
|
||||
- Boucle multi-modèles : medgemma:4b vs concurrents généralistes, avec
|
||||
unload (keep_alive=0) entre chaque pour éviter l'accumulation VRAM.
|
||||
- Breakdown par type (simple / complexe / borderline) — la borderline est
|
||||
la vraie métrique business.
|
||||
|
||||
Lancer : python run_simulation_v2.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cas_dpi import CAS # noqa: E402
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||
|
||||
# Modèles à comparer. Chacun est unload après son tour (keep_alive=0).
|
||||
# Note : qwen3:* écarté ici car reasoning mode + format=json renvoie {} vide
|
||||
# (incompatibilité tokens "thinking" / contrainte JSON stricte).
|
||||
MODELS = [
|
||||
"medgemma:4b", # 3.3 GB — médical spécialisé
|
||||
"qwen2.5:7b", # 4.7 GB — généraliste rapide, bon FR + JSON
|
||||
"qwen2.5:14b", # 9.0 GB — généraliste large, raisonnement clinique
|
||||
"gemma4:latest", # 9.6 GB — défaut projet aiva-vision
|
||||
"gemma3:27b-cloud", # 27B — cible DGX Spark (poids identiques)
|
||||
"qwen3-next:80b-cloud", # 80B (MoE) — cible DGX Spark
|
||||
]
|
||||
|
||||
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, à calculer depuis les horaires du dossier>,
|
||||
"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 fmt_decision(d: str) -> str:
|
||||
return {"FORFAIT_URGENCE": "FORFAIT", "REQUALIFICATION_HOSPITALISATION": "HOSPIT"}.get(d, d or "?")
|
||||
|
||||
|
||||
def query_model(model: str, dpi_text: str, timeout: int = 600) -> dict:
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": PROMPT_TEMPLATE.format(dpi=dpi_text),
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"keep_alive": "5m", # garde le modèle chargé entre les cas du même run
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"num_predict": 4000, # large : qwen3-next consomme ~2500 tokens en thinking
|
||||
"num_ctx": 4096,
|
||||
"reasoning_effort": "minimal", # pour les modèles cloud à raisonnement
|
||||
},
|
||||
}
|
||||
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:
|
||||
raw = 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
|
||||
body = json.loads(raw)
|
||||
raw_response = body.get("response", "").strip()
|
||||
raw_thinking = body.get("thinking", "").strip()
|
||||
# Pour les modèles "thinking" (qwen3-next, DeepSeek-R1) si num_predict est consommé
|
||||
# par le raisonnement, response peut être vide → on tente une extraction JSON depuis thinking.
|
||||
candidates = [raw_response]
|
||||
if not raw_response and raw_thinking:
|
||||
# Cherche le dernier bloc {...} dans thinking
|
||||
last_brace_close = raw_thinking.rfind("}")
|
||||
last_brace_open = raw_thinking.rfind("{", 0, last_brace_close)
|
||||
if last_brace_open != -1 and last_brace_close != -1:
|
||||
candidates.append(raw_thinking[last_brace_open:last_brace_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:
|
||||
parsed = {"_raw": (raw_response or raw_thinking)[:400], "_parse_error": True}
|
||||
parsed["_elapsed_s"] = round(elapsed, 1)
|
||||
parsed["_eval_count"] = body.get("eval_count")
|
||||
return parsed
|
||||
|
||||
|
||||
def unload_model(model: str) -> None:
|
||||
"""Force unload via keep_alive=0."""
|
||||
payload = {"model": model, "prompt": "", "keep_alive": 0, "stream": False}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
OLLAMA_URL, data=data, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
resp.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_one_model(model: str) -> list[dict]:
|
||||
print(f"\n{'#' * 78}")
|
||||
print(f"# MODÈLE : {model}")
|
||||
print(f"{'#' * 78}")
|
||||
results = []
|
||||
for cas in CAS:
|
||||
gt = cas["verite_terrain"]
|
||||
out = query_model(model, cas["dpi"])
|
||||
if out.get("_error"):
|
||||
decision = "_ERR_"
|
||||
match = False
|
||||
elif out.get("_parse_error"):
|
||||
decision = "_PARSE_"
|
||||
match = False
|
||||
else:
|
||||
decision = out.get("decision", "?")
|
||||
match = decision == gt
|
||||
|
||||
flag = "OK" if match else "KO"
|
||||
conf = out.get("confiance", "-") if not out.get("_parse_error") else "-"
|
||||
duree = out.get("duree_passage_heures", "?") if not out.get("_parse_error") else "?"
|
||||
elapsed = out.get("_elapsed_s", "?")
|
||||
print(
|
||||
f" Cas {cas['id']:>2} [{cas['type'][:4]:<4}] GT={fmt_decision(gt):<7} "
|
||||
f"Pred={fmt_decision(decision):<7} {flag:<3} "
|
||||
f"conf={conf:<7} durée={str(duree):<5} {elapsed}s"
|
||||
)
|
||||
results.append({"cas": cas, "out": out, "match": match, "decision": decision})
|
||||
|
||||
print(f" → Unload {model}...")
|
||||
unload_model(model)
|
||||
time.sleep(2)
|
||||
return results
|
||||
|
||||
|
||||
def stats_for_results(results: list[dict]) -> dict:
|
||||
n = len(results)
|
||||
correct = sum(1 for r in results if r["match"])
|
||||
by_type = {}
|
||||
for t in ("simple", "complexe", "borderline"):
|
||||
sub = [r for r in results if r["cas"]["type"] == t]
|
||||
if sub:
|
||||
by_type[t] = (sum(1 for r in sub if r["match"]), len(sub))
|
||||
parse_errors = sum(1 for r in results if r["out"].get("_parse_error"))
|
||||
api_errors = sum(1 for r in results if r["out"].get("_error"))
|
||||
latencies = [r["out"].get("_elapsed_s", 0) for r in results if not r["out"].get("_error")]
|
||||
avg_lat = sum(latencies) / max(1, len(latencies))
|
||||
# Confiance modulée ?
|
||||
confs = [r["out"].get("confiance", "?") for r in results if not r["out"].get("_parse_error") and not r["out"].get("_error")]
|
||||
conf_distribution = {c: confs.count(c) for c in set(confs)}
|
||||
# Faux positifs / négatifs (positif = HOSPIT)
|
||||
fp = sum(1 for r in results if not r["match"] and r["cas"]["verite_terrain"] == "FORFAIT_URGENCE" and r.get("decision") == "REQUALIFICATION_HOSPITALISATION")
|
||||
fn = sum(1 for r in results if not r["match"] and r["cas"]["verite_terrain"] == "REQUALIFICATION_HOSPITALISATION" and r.get("decision") == "FORFAIT_URGENCE")
|
||||
return {
|
||||
"n": n,
|
||||
"correct": correct,
|
||||
"accuracy": correct / n,
|
||||
"by_type": by_type,
|
||||
"parse_errors": parse_errors,
|
||||
"api_errors": api_errors,
|
||||
"avg_latency_s": avg_lat,
|
||||
"confiance_distribution": conf_distribution,
|
||||
"faux_positifs_hospit": fp, # sur-codage
|
||||
"faux_negatifs_hospit": fn, # manque à gagner
|
||||
}
|
||||
|
||||
|
||||
def print_synthesis(all_results: dict[str, list[dict]]) -> None:
|
||||
print(f"\n{'=' * 78}")
|
||||
print(f" SYNTHÈSE COMPARATIVE")
|
||||
print(f"{'=' * 78}")
|
||||
header = f" {'Modèle':<22} {'Acc':<6} {'Simple':<8} {'Complex':<9} {'Border':<8} {'FP':<3} {'FN':<3} {'Lat.':<7} {'Parse':<6}"
|
||||
print(header)
|
||||
print(f" {'-' * 76}")
|
||||
for model, results in all_results.items():
|
||||
s = stats_for_results(results)
|
||||
bt = s["by_type"]
|
||||
simple_str = f"{bt.get('simple', (0, 0))[0]}/{bt.get('simple', (0, 0))[1]}"
|
||||
complexe_str = f"{bt.get('complexe', (0, 0))[0]}/{bt.get('complexe', (0, 0))[1]}"
|
||||
border_str = f"{bt.get('borderline', (0, 0))[0]}/{bt.get('borderline', (0, 0))[1]}"
|
||||
print(
|
||||
f" {model:<22} {s['correct']:>2}/{s['n']:<3} {simple_str:<8} {complexe_str:<9} "
|
||||
f"{border_str:<8} {s['faux_positifs_hospit']:<3} {s['faux_negatifs_hospit']:<3} "
|
||||
f"{s['avg_latency_s']:<6.1f}s {s['parse_errors']:<6}"
|
||||
)
|
||||
|
||||
# Détail par cas pour la lecture qualitative
|
||||
print(f"\n Détail par cas (vérité-terrain → prédiction par modèle) :")
|
||||
header2 = f" {'#':<3} {'Type':<11} {'GT':<8}"
|
||||
for m in all_results.keys():
|
||||
header2 += f" {m[:14]:<15}"
|
||||
print(header2)
|
||||
print(f" {'-' * (len(header2) - 2)}")
|
||||
for i, cas in enumerate(CAS):
|
||||
gt = fmt_decision(cas["verite_terrain"])
|
||||
line = f" {cas['id']:<3} {cas['type']:<11} {gt:<8}"
|
||||
for m, results in all_results.items():
|
||||
r = results[i]
|
||||
pred = fmt_decision(r["decision"]) if r["decision"] not in ("_ERR_", "_PARSE_") else r["decision"]
|
||||
mark = "✓" if r["match"] else "✗"
|
||||
line += f" {pred:<7} {mark:<7}"
|
||||
print(line)
|
||||
|
||||
# Distribution confiance par modèle
|
||||
print(f"\n Calibration de la confiance par modèle :")
|
||||
for model, results in all_results.items():
|
||||
s = stats_for_results(results)
|
||||
print(f" {model:<22} → {s['confiance_distribution']}")
|
||||
|
||||
print(f"\n{'=' * 78}\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"\n{'=' * 78}")
|
||||
print(f" SIMULATION v2 — Facturation urgences (multi-modèles, prompt durci)")
|
||||
print(f" Cas : {len(CAS)} DPI ({sum(1 for c in CAS if c['type']=='simple')} simples + "
|
||||
f"{sum(1 for c in CAS if c['type']=='complexe')} complexes + "
|
||||
f"{sum(1 for c in CAS if c['type']=='borderline')} borderline)")
|
||||
print(f" Modèles : {', '.join(MODELS)}")
|
||||
print(f"{'=' * 78}")
|
||||
|
||||
all_results = {}
|
||||
for model in MODELS:
|
||||
all_results[model] = run_one_model(model)
|
||||
|
||||
print_synthesis(all_results)
|
||||
|
||||
# Sauvegarde
|
||||
out_path = Path(__file__).parent / "resultats_v2.json"
|
||||
serializable = {
|
||||
model: [
|
||||
{
|
||||
"id": r["cas"]["id"],
|
||||
"titre": r["cas"]["titre"],
|
||||
"type": r["cas"]["type"],
|
||||
"verite_terrain": r["cas"]["verite_terrain"],
|
||||
"criteres_attendus": r["cas"]["criteres_cles"],
|
||||
"prediction": r["out"],
|
||||
"decision": r["decision"],
|
||||
"match": r["match"],
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
for model, results in all_results.items()
|
||||
}
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(serializable, f, ensure_ascii=False, indent=2)
|
||||
print(f" Détails sauvegardés : {out_path}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user