feat: ajout viewer Flask CIM-10 avec config Ollama centralisée et chronométrage

Ajoute une interface web Flask pour visualiser les dossiers médicaux CIM-10,
avec temps de traitement par PDF, sélecteur de modèle Ollama, et centralisation
de la config Ollama dans src/config.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-10 20:11:07 +01:00
parent fc68fc6f6b
commit 037d255aa0
10 changed files with 721 additions and 7 deletions

View File

@@ -10,3 +10,4 @@ edsnlp[ml]>=0.17.0
faiss-cpu>=1.7.0 faiss-cpu>=1.7.0
sentence-transformers>=2.2.0 sentence-transformers>=2.2.0
requests>=2.28.0 requests>=2.28.0
flask>=3.0.0

View File

@@ -28,6 +28,13 @@ NER_MODEL = "Jean-Baptiste/camembert-ner"
NER_CONFIDENCE_THRESHOLD = 0.80 NER_CONFIDENCE_THRESHOLD = 0.80
# --- Configuration Ollama ---
OLLAMA_URL = "http://localhost:11434"
OLLAMA_MODEL = "mistral-large-3:675b-cloud"
OLLAMA_TIMEOUT = 120
# --- Configuration RAG --- # --- Configuration RAG ---
RAG_INDEX_DIR = BASE_DIR / "data" / "rag_index" RAG_INDEX_DIR = BASE_DIR / "data" / "rag_index"
@@ -103,6 +110,7 @@ class DossierMedical(BaseModel):
biologie_cle: list[BiologieCle] = Field(default_factory=list) biologie_cle: list[BiologieCle] = Field(default_factory=list)
imagerie: list[Imagerie] = Field(default_factory=list) imagerie: list[Imagerie] = Field(default_factory=list)
complications: list[str] = Field(default_factory=list) complications: list[str] = Field(default_factory=list)
processing_time_s: float | None = None
# --- Rapport d'anonymisation --- # --- Rapport d'anonymisation ---

View File

@@ -6,6 +6,7 @@ import argparse
import json import json
import logging import logging
import sys import sys
import time
from pathlib import Path from pathlib import Path
from .anonymization.anonymizer import Anonymizer from .anonymization.anonymizer import Anonymizer
@@ -29,6 +30,7 @@ _use_rag = True
def process_pdf(pdf_path: Path) -> tuple[str, DossierMedical, AnonymizationReport]: def process_pdf(pdf_path: Path) -> tuple[str, DossierMedical, AnonymizationReport]:
"""Traite un PDF : extraction → parsing → anonymisation → extraction CIM-10.""" """Traite un PDF : extraction → parsing → anonymisation → extraction CIM-10."""
t0 = time.time()
logger.info("Traitement de %s", pdf_path.name) logger.info("Traitement de %s", pdf_path.name)
# 1. Extraction texte # 1. Extraction texte
@@ -67,8 +69,10 @@ def process_pdf(pdf_path: Path) -> tuple[str, DossierMedical, AnonymizationRepor
dossier = extract_medical_info(parsed, anonymized_text, edsnlp_result, use_rag=_use_rag) dossier = extract_medical_info(parsed, anonymized_text, edsnlp_result, use_rag=_use_rag)
dossier.source_file = pdf_path.name dossier.source_file = pdf_path.name
dossier.document_type = doc_type dossier.document_type = doc_type
dossier.processing_time_s = round(time.time() - t0, 2)
logger.info(" DP : %s", dossier.diagnostic_principal) logger.info(" DP : %s", dossier.diagnostic_principal)
logger.info(" DAS : %d, Actes : %d", len(dossier.diagnostics_associes), len(dossier.actes_ccam)) logger.info(" DAS : %d, Actes : %d", len(dossier.diagnostics_associes), len(dossier.actes_ccam))
logger.info(" Temps de traitement : %.2fs", dossier.processing_time_s)
return anonymized_text, dossier, report return anonymized_text, dossier, report

View File

@@ -8,15 +8,10 @@ from typing import Optional
import requests import requests
from ..config import Diagnostic, DossierMedical, RAGSource from ..config import Diagnostic, DossierMedical, RAGSource, OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Configuration Ollama
OLLAMA_URL = "http://localhost:11434/api/generate"
OLLAMA_MODEL = "mistral-small3.2:24b"
OLLAMA_TIMEOUT = 120 # secondes
# Singleton pour le modèle d'embedding (chargé une seule fois) # Singleton pour le modèle d'embedding (chargé une seule fois)
_embed_model = None _embed_model = None
@@ -107,7 +102,7 @@ def _call_ollama(prompt: str) -> dict | None:
"""Appelle Ollama et parse la réponse JSON.""" """Appelle Ollama et parse la réponse JSON."""
try: try:
response = requests.post( response = requests.post(
OLLAMA_URL, f"{OLLAMA_URL}/api/generate",
json={ json={
"model": OLLAMA_MODEL, "model": OLLAMA_MODEL,
"prompt": prompt, "prompt": prompt,

0
src/viewer/__init__.py Normal file
View File

20
src/viewer/__main__.py Normal file
View File

@@ -0,0 +1,20 @@
"""Point d'entrée : python -m src.viewer [--host 127.0.0.1] [--port 5000] [--debug]."""
import argparse
from .app import create_app
def main():
parser = argparse.ArgumentParser(description="Viewer CIM-10 T2A")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=5000)
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
app = create_app()
app.run(host=args.host, port=args.port, debug=args.debug)
if __name__ == "__main__":
main()

150
src/viewer/app.py Normal file
View File

@@ -0,0 +1,150 @@
"""App Flask — viewer CIM-10 T2A."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import requests
from flask import Flask, abort, render_template, request, jsonify
from markupsafe import Markup
from ..config import STRUCTURED_DIR, OLLAMA_URL, DossierMedical
from .. import config as cfg
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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": [...]}
"""
groups: dict[str, list[dict]] = {}
for json_path in sorted(STRUCTURED_DIR.rglob("*.json")):
rel = json_path.relative_to(STRUCTURED_DIR)
parts = rel.parts
if len(parts) == 1:
group_name = "racine"
else:
group_name = str(Path(*parts[:-1]))
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
dossier = DossierMedical.model_validate(data)
except Exception:
logger.warning("Impossible de charger %s", json_path)
continue
groups.setdefault(group_name, []).append({
"name": json_path.stem,
"path_rel": str(rel),
"dossier": dossier,
})
return groups
def load_dossier(path_rel: str) -> DossierMedical:
"""Charge un JSON et le désérialise. Vérifie contre le path traversal."""
safe_path = (STRUCTURED_DIR / path_rel).resolve()
if not safe_path.is_relative_to(STRUCTURED_DIR.resolve()):
abort(403)
if not safe_path.exists():
abort(404)
data = json.loads(safe_path.read_text(encoding="utf-8"))
return DossierMedical.model_validate(data)
def fetch_ollama_models() -> list[str]:
"""Appelle GET {OLLAMA_URL}/api/tags pour lister les modèles disponibles."""
try:
resp = requests.get(f"{cfg.OLLAMA_URL}/api/tags", timeout=5)
resp.raise_for_status()
models = resp.json().get("models", [])
return [m["name"] for m in models]
except Exception:
logger.warning("Impossible de contacter Ollama pour lister les modèles")
return []
# ---------------------------------------------------------------------------
# Filtres Jinja2
# ---------------------------------------------------------------------------
_CONFIDENCE_COLORS = {
"high": ("#16a34a", "#dcfce7"),
"medium": ("#ca8a04", "#fef9c3"),
"low": ("#dc2626", "#fee2e2"),
}
_CONFIDENCE_LABELS = {
"high": "Haute",
"medium": "Moyenne",
"low": "Basse",
}
def confidence_badge(value: str | None) -> Markup:
if not value:
return Markup("")
fg, bg = _CONFIDENCE_COLORS.get(value, ("#6b7280", "#f3f4f6"))
label = _CONFIDENCE_LABELS.get(value, 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>'
)
def confidence_label(value: str | None) -> str:
if not value:
return ""
return _CONFIDENCE_LABELS.get(value, value)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def create_app() -> Flask:
app = Flask(__name__)
app.jinja_env.filters["confidence_badge"] = confidence_badge
app.jinja_env.filters["confidence_label"] = confidence_label
@app.route("/")
def index():
groups = scan_dossiers()
return render_template("index.html", groups=groups)
@app.route("/dossier/<path:filepath>")
def detail(filepath: str):
dossier = load_dossier(filepath)
return render_template("detail.html", dossier=dossier, filepath=filepath)
@app.route("/admin/models", methods=["GET"])
def list_models():
models = fetch_ollama_models()
return jsonify({"models": models, "current": cfg.OLLAMA_MODEL})
@app.route("/admin/models", methods=["POST"])
def set_model():
data = request.get_json(silent=True) or {}
new_model = data.get("model", "").strip()
if not new_model:
return jsonify({"error": "Champ 'model' requis"}), 400
cfg.OLLAMA_MODEL = new_model
logger.info("Modèle Ollama changé : %s", new_model)
return jsonify({"ok": True, "model": cfg.OLLAMA_MODEL})
return app

View File

@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Viewer CIM-10{% endblock %} — T2A</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f1f5f9;
color: #1e293b;
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 280px;
min-width: 280px;
background: #0f172a;
color: #cbd5e1;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
overflow-y: auto;
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid #1e293b;
}
.sidebar-header h1 {
font-size: 1.1rem;
color: #e2e8f0;
font-weight: 700;
}
.sidebar-header p {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem 0;
overflow-y: auto;
}
.sidebar-nav .group-title {
padding: 0.5rem 1rem 0.25rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
font-weight: 700;
}
.sidebar-nav a {
display: block;
padding: 0.4rem 1rem;
color: #94a3b8;
text-decoration: none;
font-size: 0.8rem;
border-left: 3px solid transparent;
transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-nav a:hover {
color: #e2e8f0;
background: #1e293b;
border-left-color: #3b82f6;
}
/* Admin section */
.sidebar-admin {
padding: 1rem;
border-top: 1px solid #1e293b;
font-size: 0.8rem;
}
.sidebar-admin label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
color: #94a3b8;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sidebar-admin select {
width: 100%;
padding: 0.4rem;
border-radius: 6px;
border: 1px solid #334155;
background: #1e293b;
color: #e2e8f0;
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.sidebar-admin button {
width: 100%;
padding: 0.45rem;
border-radius: 6px;
border: none;
background: #3b82f6;
color: #fff;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.sidebar-admin button:hover { background: #2563eb; }
.sidebar-admin .status-msg {
margin-top: 0.35rem;
font-size: 0.7rem;
min-height: 1rem;
}
/* Main content */
.main {
margin-left: 280px;
flex: 1;
padding: 2rem;
max-width: 1100px;
}
/* Utilities */
.card {
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
padding: 1.25rem;
margin-bottom: 1rem;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
}
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e8f0; }
th { font-weight: 600; color: #475569; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
tr.anomalie { background: #fef2f2; }
details { margin-top: 0.35rem; }
details summary {
cursor: pointer;
font-size: 0.75rem;
color: #3b82f6;
}
details pre {
font-size: 0.75rem;
background: #f8fafc;
padding: 0.5rem;
border-radius: 6px;
margin-top: 0.25rem;
white-space: pre-wrap;
word-break: break-word;
}
h2 { font-size: 1.1rem; margin-bottom: 0.75rem; color: #0f172a; }
h3 { font-size: 0.95rem; margin-bottom: 0.5rem; color: #334155; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.info-item label { display: block; font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.info-item span { font-size: 0.9rem; }
.section { margin-bottom: 1.5rem; }
ul.bullet { list-style: disc; padding-left: 1.5rem; font-size: 0.85rem; }
ul.bullet li { margin-bottom: 0.25rem; }
a.back { font-size: 0.85rem; color: #3b82f6; text-decoration: none; }
a.back:hover { text-decoration: underline; }
</style>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h1>T2A Viewer</h1>
<p>Visualisation CIM-10</p>
</div>
<nav class="sidebar-nav" id="sidebar-nav">
{% block sidebar %}{% endblock %}
</nav>
<div class="sidebar-admin">
<label for="model-select">Modèle Ollama</label>
<select id="model-select"><option>Chargement…</option></select>
<button id="model-apply">Appliquer</button>
<div class="status-msg" id="model-status"></div>
</div>
</aside>
<!-- Main -->
<div class="main">
{% block content %}{% endblock %}
</div>
<script>
(function() {
const sel = document.getElementById('model-select');
const btn = document.getElementById('model-apply');
const status = document.getElementById('model-status');
function loadModels() {
fetch('/admin/models')
.then(r => r.json())
.then(d => {
sel.innerHTML = '';
if (d.models && d.models.length) {
d.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
if (m === d.current) opt.selected = true;
sel.appendChild(opt);
});
} else {
sel.innerHTML = '<option>Aucun modèle</option>';
}
})
.catch(() => { sel.innerHTML = '<option>Erreur</option>'; });
}
btn.addEventListener('click', function() {
const model = sel.value;
if (!model || model === 'Aucun modèle' || model === 'Erreur') return;
status.textContent = '…';
status.style.color = '#94a3b8';
fetch('/admin/models', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: model})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
status.textContent = 'Modèle appliqué';
status.style.color = '#16a34a';
} else {
status.textContent = d.error || 'Erreur';
status.style.color = '#dc2626';
}
})
.catch(() => {
status.textContent = 'Erreur réseau';
status.style.color = '#dc2626';
});
});
loadModels();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block title %}{{ dossier.source_file or filepath }}{% endblock %}
{% block sidebar %}
<div class="group-title">Navigation</div>
<a href="/">Retour à la liste</a>
{% endblock %}
{% block content %}
<a class="back" href="/">&larr; Retour à la liste</a>
{# ---- En-tête ---- #}
<div class="card" style="margin-top:1rem;">
<h2>{{ dossier.source_file or filepath }}</h2>
<div class="info-grid">
{% if dossier.document_type %}
<div class="info-item">
<label>Type de document</label>
<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ dossier.document_type }}</span>
</div>
{% endif %}
{% if dossier.processing_time_s is not none %}
<div class="info-item">
<label>Temps de traitement</label>
<span>{{ dossier.processing_time_s }}s</span>
</div>
{% endif %}
</div>
</div>
{# ---- Séjour ---- #}
{% set s = dossier.sejour %}
{% if s.sexe or s.age or s.date_entree or s.date_sortie or s.duree_sejour is not none or s.imc or s.poids or s.taille %}
<div class="card section">
<h3>Séjour</h3>
<div class="info-grid">
{% if s.sexe %}<div class="info-item"><label>Sexe</label><span>{{ s.sexe }}</span></div>{% endif %}
{% if s.age is not none %}<div class="info-item"><label>Âge</label><span>{{ s.age }} ans</span></div>{% endif %}
{% if s.date_entree %}<div class="info-item"><label>Entrée</label><span>{{ s.date_entree }}</span></div>{% endif %}
{% if s.date_sortie %}<div class="info-item"><label>Sortie</label><span>{{ s.date_sortie }}</span></div>{% endif %}
{% if s.duree_sejour is not none %}<div class="info-item"><label>Durée</label><span>{{ s.duree_sejour }} jour(s)</span></div>{% endif %}
{% if s.mode_entree %}<div class="info-item"><label>Mode entrée</label><span>{{ s.mode_entree }}</span></div>{% endif %}
{% if s.mode_sortie %}<div class="info-item"><label>Mode sortie</label><span>{{ s.mode_sortie }}</span></div>{% endif %}
{% if s.poids %}<div class="info-item"><label>Poids</label><span>{{ s.poids }} kg</span></div>{% endif %}
{% if s.taille %}<div class="info-item"><label>Taille</label><span>{{ s.taille }} cm</span></div>{% endif %}
{% if s.imc %}<div class="info-item"><label>IMC</label><span>{{ s.imc }}</span></div>{% endif %}
</div>
</div>
{% endif %}
{# ---- Diagnostic principal ---- #}
{% if dossier.diagnostic_principal %}
{% set dp = dossier.diagnostic_principal %}
<div class="card section">
<h3>Diagnostic principal</h3>
<div style="font-size:0.95rem;margin-bottom:0.5rem;">{{ dp.texte }}</div>
{% if dp.cim10_suggestion %}
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
{{ dp.cim10_confidence | confidence_badge }}
{% endif %}
{% if dp.justification %}
<div style="margin-top:0.5rem;font-size:0.8rem;color:#475569;">{{ dp.justification }}</div>
{% endif %}
{% if dp.sources_rag %}
<details>
<summary>Sources RAG ({{ dp.sources_rag|length }})</summary>
{% for src in dp.sources_rag %}
<pre>{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
{{ src.extrait or '' }}</pre>
{% endfor %}
</details>
{% endif %}
</div>
{% endif %}
{# ---- Diagnostics associés ---- #}
{% if dossier.diagnostics_associes %}
<div class="card section">
<h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3>
<table>
<thead><tr><th>Texte</th><th>CIM-10</th><th>Confiance</th><th>Justification</th></tr></thead>
<tbody>
{% for das in dossier.diagnostics_associes %}
<tr>
<td>{{ das.texte }}</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 style="font-size:0.8rem;color:#475569;">{{ das.justification or '' }}</td>
</tr>
{% if das.sources_rag %}
<tr>
<td colspan="4" style="padding:0 0.75rem 0.5rem;">
<details>
<summary>Sources RAG ({{ das.sources_rag|length }})</summary>
{% for src in das.sources_rag %}
<pre>{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
{{ src.extrait or '' }}</pre>
{% endfor %}
</details>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# ---- Actes CCAM ---- #}
{% if dossier.actes_ccam %}
<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></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>{{ a.date or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# ---- Biologie clé ---- #}
{% if dossier.biologie_cle %}
<div class="card section">
<h3>Biologie clé ({{ dossier.biologie_cle|length }})</h3>
<table>
<thead><tr><th>Test</th><th>Valeur</th><th>Anomalie</th></tr></thead>
<tbody>
{% for b in dossier.biologie_cle %}
<tr{% if b.anomalie %} class="anomalie"{% endif %}>
<td>{{ b.test }}</td>
<td>{{ b.valeur or '' }}</td>
<td>{% if b.anomalie %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Oui</span>{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# ---- Imagerie ---- #}
{% if dossier.imagerie %}
<div class="card section">
<h3>Imagerie ({{ dossier.imagerie|length }})</h3>
{% for img in dossier.imagerie %}
<div style="margin-bottom:0.5rem;">
<strong>{{ img.type }}</strong>
{% if img.score %} — Score : {{ img.score }}{% endif %}
{% if img.conclusion %}
<div style="font-size:0.85rem;color:#475569;">{{ img.conclusion }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{# ---- Traitements de sortie ---- #}
{% if dossier.traitements_sortie %}
<div class="card section">
<h3>Traitements de sortie ({{ dossier.traitements_sortie|length }})</h3>
<table>
<thead><tr><th>Médicament</th><th>Posologie</th><th>Code ATC</th></tr></thead>
<tbody>
{% for t in dossier.traitements_sortie %}
<tr>
<td>{{ t.medicament }}</td>
<td>{{ t.posologie or '' }}</td>
<td>{% if t.code_atc %}<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ t.code_atc }}</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# ---- Antécédents ---- #}
{% if dossier.antecedents %}
<div class="card section">
<h3>Antécédents ({{ dossier.antecedents|length }})</h3>
<ul class="bullet">
{% for a in dossier.antecedents %}
<li>{{ a }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# ---- Complications ---- #}
{% if dossier.complications %}
<div class="card section">
<h3>Complications ({{ dossier.complications|length }})</h3>
<ul class="bullet">
{% for c in dossier.complications %}
<li>{{ c }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Accueil{% endblock %}
{% block sidebar %}
{% for group_name, items in groups.items() %}
<div class="group-title">{{ group_name }}</div>
{% for item in items %}
<a href="/dossier/{{ item.path_rel }}">{{ item.name }}</a>
{% endfor %}
{% endfor %}
{% endblock %}
{% block content %}
<h2>Dossiers médicaux traités</h2>
{% if not groups %}
<div class="card">
<p>Aucun dossier trouvé dans <code>output/structured/</code>.</p>
<p style="margin-top:0.5rem;font-size:0.85rem;color:#64748b">
Lancez le pipeline avec <code>python -m src.main</code> pour générer des fichiers.
</p>
</div>
{% endif %}
{% for group_name, items in groups.items() %}
<div class="section">
{% set ns = namespace(total=0.0, count=0) %}
{% for item in items %}
{% if item.dossier.processing_time_s is not none %}
{% set ns.total = ns.total + item.dossier.processing_time_s %}
{% set ns.count = ns.count + 1 %}
{% endif %}
{% endfor %}
<h3 style="display:flex;align-items:baseline;gap:0.75rem;">
{{ 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>
</h3>
<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;">
<div class="card" style="cursor:pointer;transition:box-shadow 0.15s;">
<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 %}
{% 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 %}
</div>
{% if item.dossier.diagnostic_principal.cim10_suggestion %}
<div style="margin-top:0.25rem;">
<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ item.dossier.diagnostic_principal.cim10_suggestion }}</span>
{{ item.dossier.diagnostic_principal.cim10_confidence | confidence_badge }}
</div>
{% endif %}
{% endif %}
{% if item.dossier.processing_time_s is not none %}
<div style="margin-top:0.5rem;font-size:0.75rem;color:#64748b;">
Traitement : {{ item.dossier.processing_time_s }}s
</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
{% endblock %}