"""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'' f'{label}' ) 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'' f'{label}' ) # --------------------------------------------------------------------------- # 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/") 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/", 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