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:
dom
2026-02-11 12:43:34 +01:00
parent 7e69f994b0
commit 9d07894c6f
12 changed files with 1013 additions and 26 deletions

View File

@@ -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():

View File

@@ -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>

View File

@@ -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';

View File

@@ -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 %}