- 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>
263 lines
8.3 KiB
Python
263 lines
8.3 KiB
Python
"""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, CCAM_DICT_PATH, DossierMedical
|
|
from .. import config as cfg
|
|
|
|
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]] = {}
|
|
|
|
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)
|
|
|
|
|
|
_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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create_app() -> Flask:
|
|
app = Flask(__name__)
|
|
|
|
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()
|
|
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)
|
|
# 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():
|
|
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})
|
|
|
|
@app.route("/reprocess/<path:filepath>", methods=["POST"])
|
|
def reprocess(filepath: str):
|
|
"""Relance le traitement d'un dossier."""
|
|
from ..main import process_pdf, write_outputs
|
|
|
|
dossier = load_dossier(filepath)
|
|
source_file = dossier.source_file
|
|
if not source_file:
|
|
return jsonify({"error": "Fichier source introuvable"}), 400
|
|
|
|
# Chercher le PDF source dans input/
|
|
input_dir = Path(__file__).parent.parent.parent / "input"
|
|
pdf_path = None
|
|
for p in input_dir.rglob(source_file):
|
|
if p.is_file():
|
|
pdf_path = p
|
|
break
|
|
|
|
if not pdf_path:
|
|
return jsonify({"error": f"PDF source '{source_file}' introuvable"}), 404
|
|
|
|
try:
|
|
anonymized_text, new_dossier, report = process_pdf(pdf_path)
|
|
stem = pdf_path.stem.replace(" ", "_")
|
|
subdir = None
|
|
if pdf_path.parent != input_dir:
|
|
subdir = pdf_path.parent.name
|
|
write_outputs(stem, anonymized_text, new_dossier, report, subdir=subdir)
|
|
return jsonify({"ok": True, "message": "Traitement terminé"})
|
|
except Exception as e:
|
|
logger.exception("Erreur lors du retraitement")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
return app
|