feat: Phase 4 — viewer enrichi, non-cumul CCAM, fusion multi-PDFs + rebuild FAISS (21 141 vecteurs)
- Viewer : badges compteurs (DAS, actes, alertes, CMA), raisonnement LLM pliable, regroupement CCAM, navigation patient, alertes NON-CUMUL en rouge - Non-cumul CCAM : 3 règles heuristiques (même base, même regroupement/jour, paires incompatibles) - Fusion multi-PDFs : merge_dossiers() avec priorité Trackare, spécificité CIM-10, déduplication, champ source_files - Index FAISS reconstruit : 21 141 vecteurs (CCAM dict 8 257 + CIM-10 alpha 306) - 192 tests unitaires passent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import requests
|
||||
from flask import Flask, abort, render_template, request, jsonify
|
||||
from markupsafe import Markup
|
||||
|
||||
from ..config import STRUCTURED_DIR, OLLAMA_URL, DossierMedical
|
||||
from ..config import STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical
|
||||
from .. import config as cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,11 +20,53 @@ logger = logging.getLogger(__name__)
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_group_stats(items: list[dict]) -> dict:
|
||||
"""Calcule des statistiques agrégées pour un groupe de dossiers.
|
||||
|
||||
Returns:
|
||||
{das_count, alertes_count, actes_count, cma_count}
|
||||
"""
|
||||
das_count = 0
|
||||
alertes_count = 0
|
||||
actes_count = 0
|
||||
cma_count = 0
|
||||
|
||||
for item in items:
|
||||
d = item["dossier"]
|
||||
das_count += len(d.diagnostics_associes)
|
||||
alertes_count += len(d.alertes_codage)
|
||||
actes_count += len(d.actes_ccam)
|
||||
for diag in d.diagnostics_associes:
|
||||
if diag.est_cma:
|
||||
cma_count += 1
|
||||
if d.diagnostic_principal and d.diagnostic_principal.est_cma:
|
||||
cma_count += 1
|
||||
|
||||
return {
|
||||
"das_count": das_count,
|
||||
"alertes_count": alertes_count,
|
||||
"actes_count": actes_count,
|
||||
"cma_count": cma_count,
|
||||
}
|
||||
|
||||
|
||||
def load_ccam_dict() -> dict[str, dict]:
|
||||
"""Charge le dictionnaire CCAM pour les regroupements."""
|
||||
if CCAM_DICT_PATH.exists():
|
||||
try:
|
||||
data = json.loads(CCAM_DICT_PATH.read_text(encoding="utf-8"))
|
||||
return data
|
||||
except Exception:
|
||||
logger.warning("Impossible de charger le dictionnaire CCAM")
|
||||
return {}
|
||||
|
||||
|
||||
def scan_dossiers() -> dict[str, list[dict]]:
|
||||
"""Scanne output/structured/ et retourne les fichiers groupés par sous-dossier.
|
||||
|
||||
Returns:
|
||||
{"racine": [{name, path_rel, dossier}, ...], "sous-dossier": [...]}
|
||||
Chaque groupe contient aussi une clé "stats" avec les compteurs agrégés.
|
||||
"""
|
||||
groups: dict[str, list[dict]] = {}
|
||||
|
||||
@@ -112,6 +154,24 @@ def confidence_label(value: str | None) -> str:
|
||||
return _CONFIDENCE_LABELS.get(value, value)
|
||||
|
||||
|
||||
_SEVERITY_STYLES = {
|
||||
"severe": ("Sévère", "#dc2626", "#fee2e2"),
|
||||
"modere": ("Modéré", "#92400e", "#fef3c7"),
|
||||
"leger": ("Léger", "#065f46", "#d1fae5"),
|
||||
}
|
||||
|
||||
|
||||
def severity_badge(value: str | None) -> Markup:
|
||||
if not value or value not in _SEVERITY_STYLES:
|
||||
return Markup("")
|
||||
label, fg, bg = _SEVERITY_STYLES[value]
|
||||
return Markup(
|
||||
f'<span style="display:inline-block;padding:2px 8px;border-radius:9999px;'
|
||||
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">'
|
||||
f'{label}</span>'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -121,16 +181,35 @@ def create_app() -> Flask:
|
||||
|
||||
app.jinja_env.filters["confidence_badge"] = confidence_badge
|
||||
app.jinja_env.filters["confidence_label"] = confidence_label
|
||||
app.jinja_env.filters["severity_badge"] = severity_badge
|
||||
|
||||
ccam_dict = load_ccam_dict()
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
groups = scan_dossiers()
|
||||
return render_template("index.html", groups=groups)
|
||||
group_stats = {name: compute_group_stats(items) for name, items in groups.items()}
|
||||
return render_template("index.html", groups=groups, group_stats=group_stats)
|
||||
|
||||
@app.route("/dossier/<path:filepath>")
|
||||
def detail(filepath: str):
|
||||
dossier = load_dossier(filepath)
|
||||
return render_template("detail.html", dossier=dossier, filepath=filepath)
|
||||
# Trouver les fichiers du même groupe pour la navigation
|
||||
groups = scan_dossiers()
|
||||
siblings = []
|
||||
current_group = None
|
||||
rel_parts = Path(filepath).parts
|
||||
if len(rel_parts) > 1:
|
||||
current_group = str(Path(*rel_parts[:-1]))
|
||||
siblings = groups.get(current_group, [])
|
||||
return render_template(
|
||||
"detail.html",
|
||||
dossier=dossier,
|
||||
filepath=filepath,
|
||||
ccam_dict=ccam_dict,
|
||||
siblings=siblings,
|
||||
current_group=current_group,
|
||||
)
|
||||
|
||||
@app.route("/admin/models", methods=["GET"])
|
||||
def list_models():
|
||||
|
||||
@@ -173,6 +173,31 @@
|
||||
ul.bullet li { margin-bottom: 0.25rem; }
|
||||
a.back { font-size: 0.85rem; color: #3b82f6; text-decoration: none; }
|
||||
a.back:hover { text-decoration: underline; }
|
||||
|
||||
/* Badges compteurs */
|
||||
.badge-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-das { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-actes { background: #e0e7ff; color: #3730a3; }
|
||||
.badge-alertes { background: #ffedd5; color: #c2410c; }
|
||||
.badge-cma { background: #fee2e2; color: #dc2626; }
|
||||
.badge-regroup { background: #f0fdf4; color: #166534; font-size: 0.65rem; }
|
||||
.badge-fusion { background: #ede9fe; color: #5b21b6; }
|
||||
|
||||
/* Alertes non-cumul (rouge) vs standard (orange) */
|
||||
.alerte-noncumul { color: #dc2626; font-weight: 600; }
|
||||
.alerte-standard { color: #9a3412; }
|
||||
|
||||
/* Source files */
|
||||
.source-files { font-size: 0.8rem; color: #64748b; margin-top: 0.5rem; }
|
||||
.source-files code { background: #f1f5f9; padding: 1px 4px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
{% block sidebar %}
|
||||
<div class="group-title">Navigation</div>
|
||||
<a href="/">Retour à la liste</a>
|
||||
{% if siblings %}
|
||||
<div class="group-title" style="margin-top:1rem;">{{ current_group }}</div>
|
||||
{% for sib in siblings %}
|
||||
<a href="/dossier/{{ sib.path_rel }}" {% if sib.path_rel == filepath %}style="color:#e2e8f0;border-left-color:#3b82f6;background:#1e293b;"{% endif %}>
|
||||
{{ sib.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="group-title" style="margin-top:1.5rem;">Actions</div>
|
||||
<button id="reprocess-btn" style="width:100%;padding:0.6rem;background:#3b82f6;color:white;border:none;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;font-weight:600;margin-bottom:0.5rem;">🔄 Relancer l'étude</button>
|
||||
<button id="reprocess-btn" style="width:100%;padding:0.6rem;background:#3b82f6;color:white;border:none;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;font-weight:600;margin-bottom:0.5rem;">Relancer l'étude</button>
|
||||
<div id="reprocess-status" style="font-size:0.75rem;padding:0.25rem;"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,6 +37,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if dossier.source_files %}
|
||||
<div class="source-files" style="margin-top:0.75rem;">
|
||||
<label style="font-size:0.7rem;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;">Documents sources</label>
|
||||
<div style="margin-top:0.25rem;">
|
||||
{% for sf in dossier.source_files %}
|
||||
<code>{{ sf }}</code>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ---- Séjour ---- #}
|
||||
@@ -57,7 +75,11 @@
|
||||
<h3 style="color:#c2410c;">Alertes de codage ({{ dossier.alertes_codage|length }})</h3>
|
||||
<ul style="margin:0;padding-left:1.2rem;">
|
||||
{% for alerte in dossier.alertes_codage %}
|
||||
<li style="font-size:0.85rem;color:#9a3412;margin-bottom:0.25rem;">{{ alerte }}</li>
|
||||
{% if alerte.startswith('NON-CUMUL') %}
|
||||
<li class="alerte-noncumul" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
|
||||
{% else %}
|
||||
<li class="alerte-standard" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -73,13 +95,17 @@
|
||||
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
|
||||
{{ dp.cim10_confidence | confidence_badge }}
|
||||
{% if dp.est_cma %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">CMA</span>{% endif %}
|
||||
{% if dp.niveau_severite == 'severe' %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">Sévère</span>
|
||||
{% elif dp.niveau_severite == 'modere' %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.75rem;">Modéré</span>
|
||||
{% elif dp.niveau_severite == 'leger' %}<span class="badge" style="background:#d1fae5;color:#065f46;font-size:0.75rem;">Léger</span>{% endif %}
|
||||
{{ dp.niveau_severite | severity_badge }}
|
||||
{% endif %}
|
||||
{% if dp.justification %}
|
||||
<div style="margin-top:0.5rem;font-size:0.8rem;color:#475569;">{{ dp.justification }}</div>
|
||||
{% endif %}
|
||||
{% if dp.raisonnement %}
|
||||
<details style="margin-top:0.5rem;">
|
||||
<summary>Raisonnement LLM</summary>
|
||||
<pre>{{ dp.raisonnement }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if dp.sources_rag %}
|
||||
<details>
|
||||
<summary>Sources RAG ({{ dp.sources_rag|length }})</summary>
|
||||
@@ -107,17 +133,22 @@
|
||||
</td>
|
||||
<td>{% if das.cim10_suggestion %}<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ das.cim10_suggestion }}</span>{% endif %}</td>
|
||||
<td>{{ das.cim10_confidence | confidence_badge }}</td>
|
||||
<td>
|
||||
{% if das.niveau_severite == 'severe' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Sévère</span>
|
||||
{% elif das.niveau_severite == 'modere' %}<span class="badge" style="background:#fef3c7;color:#92400e;">Modéré</span>
|
||||
{% elif das.niveau_severite == 'leger' %}<span class="badge" style="background:#d1fae5;color:#065f46;">Léger</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{{ das.niveau_severite | severity_badge }}</td>
|
||||
<td style="font-size:0.8rem;color:#475569;">{{ das.justification or '' }}</td>
|
||||
</tr>
|
||||
{% if das.raisonnement %}
|
||||
<tr>
|
||||
<td colspan="5" style="padding:0 0.75rem 0.5rem;">
|
||||
<details>
|
||||
<summary>Raisonnement LLM</summary>
|
||||
<pre>{{ das.raisonnement }}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if das.sources_rag %}
|
||||
<tr>
|
||||
<td colspan="4" style="padding:0 0.75rem 0.5rem;">
|
||||
<td colspan="5" style="padding:0 0.75rem 0.5rem;">
|
||||
<details>
|
||||
<summary>Sources RAG ({{ das.sources_rag|length }})</summary>
|
||||
{% for src in das.sources_rag %}
|
||||
@@ -139,12 +170,19 @@
|
||||
<div class="card section">
|
||||
<h3>Actes CCAM ({{ dossier.actes_ccam|length }})</h3>
|
||||
<table>
|
||||
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Date</th><th>Validité</th></tr></thead>
|
||||
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Regroupement</th><th>Date</th><th>Validité</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in dossier.actes_ccam %}
|
||||
<tr>
|
||||
<td>{{ a.texte }}</td>
|
||||
<td>{% if a.code_ccam_suggestion %}<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ a.code_ccam_suggestion }}</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if a.code_ccam_suggestion and ccam_dict.get(a.code_ccam_suggestion, {}).get('regroupement') %}
|
||||
<span class="badge badge-regroup">{{ ccam_dict[a.code_ccam_suggestion]['regroupement'] }}</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ a.date or '' }}</td>
|
||||
<td>
|
||||
{% if a.validite == 'valide' %}<span class="badge" style="background:#d1fae5;color:#065f46;">Valide</span>
|
||||
@@ -246,28 +284,28 @@
|
||||
document.getElementById('reprocess-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('reprocess-btn');
|
||||
const status = document.getElementById('reprocess-status');
|
||||
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Traitement en cours...';
|
||||
status.textContent = '';
|
||||
status.style.color = '#3b82f6';
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/reprocess/{{ filepath }}', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.ok) {
|
||||
status.textContent = '✓ ' + data.message;
|
||||
status.textContent = data.message;
|
||||
status.style.color = '#16a34a';
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
status.textContent = '✗ ' + (data.error || 'Erreur');
|
||||
status.textContent = (data.error || 'Erreur');
|
||||
status.style.color = '#dc2626';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Relancer l\'étude';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = '✗ Erreur réseau';
|
||||
status.textContent = 'Erreur réseau';
|
||||
status.style.color = '#dc2626';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Relancer l\'étude';
|
||||
|
||||
@@ -31,12 +31,30 @@
|
||||
{% set ns.count = ns.count + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<h3 style="display:flex;align-items:baseline;gap:0.75rem;">
|
||||
{% set stats = group_stats.get(group_name, {}) %}
|
||||
<h3 style="display:flex;align-items:baseline;gap:0.75rem;flex-wrap:wrap;">
|
||||
{{ group_name }}
|
||||
<span style="font-size:0.75rem;font-weight:400;color:#64748b;">
|
||||
{{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|round(1) }}s{% endif %}
|
||||
</span>
|
||||
{% if stats %}
|
||||
<span class="badge-count badge-das">{{ stats.das_count }} DAS</span>
|
||||
<span class="badge-count badge-actes">{{ stats.actes_count }} actes</span>
|
||||
{% if stats.alertes_count %}<span class="badge-count badge-alertes">{{ stats.alertes_count }} alertes</span>{% endif %}
|
||||
{% if stats.cma_count %}<span class="badge-count badge-cma">{{ stats.cma_count }} CMA</span>{% endif %}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if items|length > 1 %}
|
||||
{% for item in items if 'fusionne' in item.name %}
|
||||
{% if loop.first %}
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<a href="/dossier/{{ item.path_rel }}" class="badge-count badge-fusion" style="text-decoration:none;font-size:0.8rem;padding:4px 12px;">
|
||||
Vue patient fusionnée
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem;">
|
||||
{% for item in items %}
|
||||
<a href="/dossier/{{ item.path_rel }}" style="text-decoration:none;color:inherit;">
|
||||
@@ -44,9 +62,15 @@
|
||||
<div style="font-weight:600;font-size:0.9rem;margin-bottom:0.4rem;color:#0f172a;">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
{% if item.dossier.document_type %}
|
||||
<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ item.dossier.document_type }}</span>
|
||||
{% endif %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-bottom:0.4rem;">
|
||||
{% if item.dossier.document_type %}
|
||||
<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ item.dossier.document_type }}</span>
|
||||
{% endif %}
|
||||
{% if item.dossier.source_files %}<span class="badge badge-fusion">fusionné</span>{% endif %}
|
||||
{% if item.dossier.diagnostics_associes %}<span class="badge-count badge-das">{{ item.dossier.diagnostics_associes|length }} DAS</span>{% endif %}
|
||||
{% if item.dossier.actes_ccam %}<span class="badge-count badge-actes">{{ item.dossier.actes_ccam|length }} actes</span>{% endif %}
|
||||
{% if item.dossier.alertes_codage %}<span class="badge-count badge-alertes">{{ item.dossier.alertes_codage|length }} alertes</span>{% endif %}
|
||||
</div>
|
||||
{% if item.dossier.diagnostic_principal %}
|
||||
<div style="margin-top:0.5rem;font-size:0.8rem;color:#334155;">
|
||||
<strong>DP :</strong> {{ item.dossier.diagnostic_principal.texte[:80] }}{% if item.dossier.diagnostic_principal.texte|length > 80 %}…{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user