feat: dashboard métriques + vue CPAM agrégée dans le viewer

Ajout d'un dashboard global (distribution confiance DP, top 15 codes CIM-10,
types GHM, sévérité) et d'une page listant tous les contrôles CPAM agrégés.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-13 18:11:21 +01:00
parent 906a2797e5
commit ee661dae1d
4 changed files with 353 additions and 0 deletions

View File

@@ -13,6 +13,8 @@ from markupsafe import Markup
from werkzeug.utils import secure_filename
from collections import Counter
from ..config import STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical, ALLOWED_EXTENSIONS, UPLOAD_MAX_SIZE_MB
from .. import config as cfg
from .referentiels import ReferentielManager
@@ -54,6 +56,104 @@ def compute_group_stats(items: list[dict]) -> dict:
}
def compute_dashboard_stats(groups: dict[str, list[dict]]) -> dict:
"""Calcule les statistiques globales du pipeline pour le dashboard."""
total_dossiers = len(groups)
total_fichiers = 0
total_das = 0
total_actes = 0
total_alertes = 0
total_cma = 0
total_cpam = 0
dp_confidence: Counter = Counter()
dp_validity: Counter = Counter()
code_counter: Counter = Counter()
ghm_types: Counter = Counter()
severity_dist: Counter = Counter()
processing_times: list[float] = []
for items in groups.values():
total_fichiers += len(items)
for item in items:
d = item["dossier"]
total_das += len(d.diagnostics_associes)
total_actes += len(d.actes_ccam)
total_alertes += len(d.alertes_codage)
total_cpam += len(d.controles_cpam)
if d.processing_time_s is not None:
processing_times.append(d.processing_time_s)
# DP confidence & validity
dp = d.diagnostic_principal
if dp:
conf = dp.cim10_confidence or "none"
dp_confidence[conf] += 1
if dp.cim10_suggestion:
dp_validity["valide"] += 1
code_counter[dp.cim10_suggestion] += 1
else:
dp_validity["absent"] += 1
else:
dp_confidence["none"] += 1
dp_validity["absent"] += 1
# DAS codes + CMA
for das in d.diagnostics_associes:
if das.cim10_suggestion:
code_counter[das.cim10_suggestion] += 1
if das.est_cma:
total_cma += 1
if dp and dp.est_cma:
total_cma += 1
# GHM
ghm = d.ghm_estimation
if ghm:
if ghm.type_ghm:
ghm_types[ghm.type_ghm] += 1
severity_dist[ghm.severite] += 1
top_codes = code_counter.most_common(15)
top_max = top_codes[0][1] if top_codes else 1
return {
"total_dossiers": total_dossiers,
"total_fichiers": total_fichiers,
"total_das": total_das,
"total_actes": total_actes,
"total_alertes": total_alertes,
"total_cma": total_cma,
"total_cpam": total_cpam,
"dp_confidence": dict(dp_confidence),
"dp_validity": dict(dp_validity),
"top_codes": top_codes,
"top_max": top_max,
"ghm_types": dict(ghm_types),
"severity_dist": dict(severity_dist),
"processing_time_total": sum(processing_times),
"processing_time_avg": sum(processing_times) / len(processing_times) if processing_times else 0,
}
def collect_cpam_controls(groups: dict[str, list[dict]]) -> list[dict]:
"""Collecte tous les contrôles CPAM de tous les dossiers."""
controls = []
for group_name, items in groups.items():
for item in items:
d = item["dossier"]
dp_code = d.diagnostic_principal.cim10_suggestion if d.diagnostic_principal else None
for ctrl in d.controles_cpam:
controls.append({
"group_name": group_name,
"filepath": item["path_rel"],
"ctrl": ctrl,
"dp_code": dp_code,
})
controls.sort(key=lambda c: c["ctrl"].numero_ogc)
return controls
def load_ccam_dict() -> dict[str, dict]:
"""Charge le dictionnaire CCAM pour les regroupements."""
if CCAM_DICT_PATH.exists():
@@ -255,6 +355,18 @@ def create_app() -> Flask:
current_group=current_group,
)
@app.route("/dashboard")
def dashboard():
groups = scan_dossiers()
stats = compute_dashboard_stats(groups)
return render_template("dashboard.html", stats=stats, groups=groups)
@app.route("/cpam")
def cpam_list():
groups = scan_dossiers()
controls = collect_cpam_controls(groups)
return render_template("cpam.html", controls=controls, total=len(controls), groups=groups)
@app.route("/admin/models", methods=["GET"])
def list_models():
models = fetch_ollama_models()

View File

@@ -250,6 +250,14 @@
{% block sidebar %}{% endblock %}
</nav>
<div class="sidebar-admin" style="border-top:1px solid #1e293b;padding:0.5rem 1rem;">
<a href="/dashboard" style="display:block;color:#94a3b8;text-decoration:none;font-size:0.8rem;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#e2e8f0'" onmouseout="this.style.color='#94a3b8'">
Dashboard
</a>
<a href="/cpam" style="display:block;color:#94a3b8;text-decoration:none;font-size:0.8rem;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#e2e8f0'" onmouseout="this.style.color='#94a3b8'">
Contrôles CPAM
</a>
<a href="/admin/referentiels" style="display:block;color:#94a3b8;text-decoration:none;font-size:0.8rem;padding:0.35rem 0;transition:color 0.15s;"
onmouseover="this.style.color='#e2e8f0'" onmouseout="this.style.color='#94a3b8'">
Référentiels RAG

View File

@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Contrôles CPAM{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<a class="back" href="/">&larr; Retour à la liste</a>
<div style="display:flex;align-items:center;gap:0.75rem;margin-top:1rem;margin-bottom:1rem;">
<h2 style="margin:0;">Contrôles CPAM</h2>
<span class="badge" style="background:#fef3c7;color:#b45309;font-size:0.85rem;padding:4px 12px;">{{ total }}</span>
</div>
{% if not controls %}
<div class="card">
<p>Aucun contrôle CPAM trouvé dans les dossiers.</p>
</div>
{% else %}
<div class="card" style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Dossier</th>
<th>OGC</th>
<th>Titre</th>
<th>Décision</th>
<th>Codes contestés</th>
<th>Contre-argumentation</th>
</tr>
</thead>
<tbody>
{% for c in controls %}
<tr>
<td>
<a href="/dossier/{{ c.filepath }}" style="color:#3b82f6;text-decoration:none;font-weight:600;">
{{ c.group_name | format_dossier_name }}
</a>
{% if c.dp_code %}
<div style="font-size:0.7rem;color:#64748b;margin-top:2px;">DP: {{ c.dp_code }}</div>
{% endif %}
</td>
<td style="font-weight:600;">{{ c.ctrl.numero_ogc }}</td>
<td style="max-width:200px;">{{ c.ctrl.titre }}</td>
<td>
{% if 'retient' in c.ctrl.decision_ucr|lower %}
<span class="badge" style="background:#d1fae5;color:#065f46;">{{ c.ctrl.decision_ucr }}</span>
{% elif 'confirme' in c.ctrl.decision_ucr|lower %}
<span class="badge" style="background:#fee2e2;color:#dc2626;">{{ c.ctrl.decision_ucr }}</span>
{% else %}
<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ c.ctrl.decision_ucr }}</span>
{% endif %}
</td>
<td>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;">
{% if c.ctrl.dp_ucr %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.65rem;">DP: {{ c.ctrl.dp_ucr }}</span>{% endif %}
{% if c.ctrl.da_ucr %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.65rem;">DA: {{ c.ctrl.da_ucr }}</span>{% endif %}
{% if c.ctrl.dr_ucr %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.65rem;">DR: {{ c.ctrl.dr_ucr }}</span>{% endif %}
{% if c.ctrl.actes_ucr %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.65rem;">Actes: {{ c.ctrl.actes_ucr }}</span>{% endif %}
</div>
</td>
<td style="max-width:300px;">
{% if c.ctrl.contre_argumentation %}
<details>
<summary>{{ c.ctrl.contre_argumentation[:80] }}{% if c.ctrl.contre_argumentation|length > 80 %}…{% endif %}</summary>
<pre>{{ c.ctrl.contre_argumentation }}</pre>
</details>
{% else %}
<span style="color:#94a3b8;font-size:0.8rem;"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name | format_dossier_name }}</div>
{% for item in items %}
{% if 'fusionne' in item.name %}
<a href="/dossier/{{ item.path_rel }}" class="sidebar-fusionne">&#9733; Fusionné</a>
{% else %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name | format_doc_name }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<a class="back" href="/">&larr; Retour à la liste</a>
<h2 style="margin-top:1rem;">Dashboard</h2>
{# ---- Cartes métriques ---- #}
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:1.5rem;">
{% set cards = [
("Dossiers", stats.total_dossiers, "#3b82f6", "#dbeafe"),
("Fichiers", stats.total_fichiers, "#6366f1", "#e0e7ff"),
("DAS total", stats.total_das, "#1d4ed8", "#dbeafe"),
("Actes total", stats.total_actes, "#3730a3", "#e0e7ff"),
("Alertes", stats.total_alertes, "#c2410c", "#ffedd5"),
("CMA", stats.total_cma, "#dc2626", "#fee2e2"),
("Contrôles CPAM", stats.total_cpam, "#b45309", "#fef3c7"),
("Temps total", stats.processing_time_total | format_duration, "#065f46", "#d1fae5"),
] %}
{% for label, value, fg, bg in cards %}
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">{{ label }}</div>
<div style="font-size:1.5rem;font-weight:700;color:{{ fg }};margin-top:0.25rem;">{{ value }}</div>
</div>
{% endfor %}
</div>
{# ---- Temps moyen ---- #}
{% if stats.processing_time_avg %}
<div class="card" style="margin-bottom:1rem;">
<div style="font-size:0.8rem;color:#64748b;">Temps moyen par fichier : <strong style="color:#0f172a;">{{ stats.processing_time_avg | format_duration }}</strong></div>
</div>
{% endif %}
{# ---- Distribution confiance DP ---- #}
{% set conf = stats.dp_confidence %}
{% set conf_total = (conf.get('high', 0) + conf.get('medium', 0) + conf.get('low', 0) + conf.get('none', 0)) or 1 %}
<div class="card section">
<h3>Confiance DP</h3>
<div style="display:flex;height:28px;border-radius:6px;overflow:hidden;margin-bottom:0.5rem;">
{% if conf.get('high', 0) %}
<div style="width:{{ (conf.get('high', 0) / conf_total * 100)|round(1) }}%;background:#16a34a;" title="Haute : {{ conf.get('high', 0) }}"></div>
{% endif %}
{% if conf.get('medium', 0) %}
<div style="width:{{ (conf.get('medium', 0) / conf_total * 100)|round(1) }}%;background:#ca8a04;" title="Moyenne : {{ conf.get('medium', 0) }}"></div>
{% endif %}
{% if conf.get('low', 0) %}
<div style="width:{{ (conf.get('low', 0) / conf_total * 100)|round(1) }}%;background:#dc2626;" title="Basse : {{ conf.get('low', 0) }}"></div>
{% endif %}
{% if conf.get('none', 0) %}
<div style="width:{{ (conf.get('none', 0) / conf_total * 100)|round(1) }}%;background:#94a3b8;" title="Aucune : {{ conf.get('none', 0) }}"></div>
{% endif %}
</div>
<div style="display:flex;gap:1.5rem;font-size:0.8rem;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16a34a;margin-right:4px;"></span>Haute : {{ conf.get('high', 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ca8a04;margin-right:4px;"></span>Moyenne : {{ conf.get('medium', 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#dc2626;margin-right:4px;"></span>Basse : {{ conf.get('low', 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#94a3b8;margin-right:4px;"></span>Aucune : {{ conf.get('none', 0) }}</span>
</div>
</div>
{# ---- Top 15 codes CIM-10 ---- #}
{% if stats.top_codes %}
<div class="card section">
<h3>Top 15 codes CIM-10</h3>
{% for code, count in stats.top_codes %}
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.4rem;">
<code style="min-width:60px;font-size:0.8rem;font-weight:600;">{{ code }}</code>
<div style="flex:1;height:20px;background:#f1f5f9;border-radius:4px;overflow:hidden;">
<div style="width:{{ (count / stats.top_max * 100)|round(1) }}%;height:100%;background:#3b82f6;border-radius:4px;"></div>
</div>
<span style="min-width:30px;text-align:right;font-size:0.8rem;color:#64748b;">{{ count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{# ---- Distribution GHM types ---- #}
{% set ghm = stats.ghm_types %}
{% set ghm_total = (ghm.get('C', 0) + ghm.get('M', 0) + ghm.get('K', 0)) or 1 %}
{% if ghm.get('C', 0) or ghm.get('M', 0) or ghm.get('K', 0) %}
<div class="card section">
<h3>Types GHM</h3>
<div style="display:flex;height:28px;border-radius:6px;overflow:hidden;margin-bottom:0.5rem;">
{% if ghm.get('C', 0) %}
<div style="width:{{ (ghm.get('C', 0) / ghm_total * 100)|round(1) }}%;background:#dc2626;" title="Chirurgical : {{ ghm.get('C', 0) }}"></div>
{% endif %}
{% if ghm.get('M', 0) %}
<div style="width:{{ (ghm.get('M', 0) / ghm_total * 100)|round(1) }}%;background:#3b82f6;" title="Médical : {{ ghm.get('M', 0) }}"></div>
{% endif %}
{% if ghm.get('K', 0) %}
<div style="width:{{ (ghm.get('K', 0) / ghm_total * 100)|round(1) }}%;background:#f59e0b;" title="Interventionnel : {{ ghm.get('K', 0) }}"></div>
{% endif %}
</div>
<div style="display:flex;gap:1.5rem;font-size:0.8rem;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#dc2626;margin-right:4px;"></span>C — Chirurgical : {{ ghm.get('C', 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3b82f6;margin-right:4px;"></span>M — Médical : {{ ghm.get('M', 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f59e0b;margin-right:4px;"></span>K — Interventionnel : {{ ghm.get('K', 0) }}</span>
</div>
</div>
{% endif %}
{# ---- Distribution sévérité ---- #}
{% set sev = stats.severity_dist %}
{% set sev_total = (sev.get(1, 0) + sev.get(2, 0) + sev.get(3, 0) + sev.get(4, 0)) or 1 %}
{% if sev.get(1, 0) or sev.get(2, 0) or sev.get(3, 0) or sev.get(4, 0) %}
<div class="card section">
<h3>Sévérité GHM</h3>
<div style="display:flex;height:28px;border-radius:6px;overflow:hidden;margin-bottom:0.5rem;">
{% if sev.get(1, 0) %}
<div style="width:{{ (sev.get(1, 0) / sev_total * 100)|round(1) }}%;background:#16a34a;" title="Niveau 1 : {{ sev.get(1, 0) }}"></div>
{% endif %}
{% if sev.get(2, 0) %}
<div style="width:{{ (sev.get(2, 0) / sev_total * 100)|round(1) }}%;background:#ca8a04;" title="Niveau 2 : {{ sev.get(2, 0) }}"></div>
{% endif %}
{% if sev.get(3, 0) %}
<div style="width:{{ (sev.get(3, 0) / sev_total * 100)|round(1) }}%;background:#f97316;" title="Niveau 3 : {{ sev.get(3, 0) }}"></div>
{% endif %}
{% if sev.get(4, 0) %}
<div style="width:{{ (sev.get(4, 0) / sev_total * 100)|round(1) }}%;background:#dc2626;" title="Niveau 4 : {{ sev.get(4, 0) }}"></div>
{% endif %}
</div>
<div style="display:flex;gap:1.5rem;font-size:0.8rem;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16a34a;margin-right:4px;"></span>Niveau 1 : {{ sev.get(1, 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ca8a04;margin-right:4px;"></span>Niveau 2 : {{ sev.get(2, 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f97316;margin-right:4px;"></span>Niveau 3 : {{ sev.get(3, 0) }}</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#dc2626;margin-right:4px;"></span>Niveau 4 : {{ sev.get(4, 0) }}</span>
</div>
</div>
{% endif %}
{% endblock %}