diff --git a/kazeia_central/app.py b/kazeia_central/app.py index 29da409..6d29175 100644 --- a/kazeia_central/app.py +++ b/kazeia_central/app.py @@ -22,6 +22,7 @@ from .core.adb import Adb from .core.context import VERSION, Ctx from .core.operator_router import make_router as operator_router from .core.store import Store +from .features.medical.conversations.router import make_router as conversations_router from .features.medical.patients.router import make_router as patients_router from .features.technical.fleet.router import make_router as fleet_router from .features.technical.voices.autosync import VoiceAutoSync @@ -34,7 +35,7 @@ _OPERATOR = os.environ.get("KAZEIA_USER") or "local" # Routers de features à composer (ordre libre ; les littéraux vs /{serial} sont gérés # DANS chaque router). Ajouter une feature = ajouter sa fabrique ici. -_FEATURES = (operator_router, fleet_router, voices_router, patients_router) +_FEATURES = (operator_router, fleet_router, voices_router, patients_router, conversations_router) def _install_basic_auth(app: FastAPI) -> None: diff --git a/kazeia_central/core/store/db.py b/kazeia_central/core/store/db.py index 8e952fd..e7f2c1f 100644 --- a/kazeia_central/core/store/db.py +++ b/kazeia_central/core/store/db.py @@ -325,6 +325,33 @@ class Store: ).fetchone() return row["m"] if row else None + def archived_sessions(self, serial: str) -> list[dict[str, Any]]: + """Sessions archivées (métadonnées, pas de vault) : id, profil, nb tours, + début/fin. Pour lister le rapatrié dans l'UI.""" + with self._lock: + rows = self._db.execute( + "SELECT session_id, profile_id, COUNT(*) turns, MIN(ts) started_at, " + "MAX(ts) ended_at, MAX(archived_at) archived_at FROM turns WHERE serial=? " + "GROUP BY session_id ORDER BY started_at DESC", (serial,)).fetchall() + return [dict(r) for r in rows] + + def purge_turns(self, serial: str, *, session_id: str | None = None, + profile_id: str | None = None, before: int | None = None) -> int: + """Purge (RGPD) des tours archivés selon filtre. Renvoie le nombre supprimé.""" + q = "DELETE FROM turns WHERE serial=?" + args: list = [serial] + if session_id: + q += " AND session_id=?"; args.append(session_id) + if profile_id: + q += " AND profile_id=?"; args.append(profile_id) + if before: + q += " AND ts"; args.append(before) + with self._lock: + before_ct = self._db.total_changes + self._db.execute(q, args) + self._db.commit() + return self._db.total_changes - before_ct + # ---- archive voix (enrôlement, §6 VOICE_ENROLLMENT_SPEC) -------------- def _voices_dir(self) -> Path: d = Path(self.path).parent / "voices" diff --git a/kazeia_central/features/medical/conversations/__init__.py b/kazeia_central/features/medical/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kazeia_central/features/medical/conversations/router.py b/kazeia_central/features/medical/conversations/router.py new file mode 100644 index 0000000..2ce0bb1 --- /dev/null +++ b/kazeia_central/features/medical/conversations/router.py @@ -0,0 +1,54 @@ +"""Router Conversations (médical, RGPD) : rapatriement chiffré, lecture, export, purge.""" + +from __future__ import annotations + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from kazeia_central.core.context import Ctx +from . import service as svc + + +class _Collect(BaseModel): + profile_id: str | None = None + since: int | None = None + until: int | None = None + + +class _Purge(BaseModel): + session_id: str | None = None + profile_id: str | None = None + before: int | None = None + + +def make_router(ctx: Ctx) -> APIRouter: + r = APIRouter(prefix="/api/conversations", tags=["conversations"]) + g, now = ctx.guard, ctx.now_ms + + @r.post("/{serial}/collect") + def collect(serial: str, body: _Collect): + return g(lambda: svc.collect(ctx.adb, ctx.store, serial, profile_id=body.profile_id, + since=body.since, until=body.until, now=now(), actor=ctx.operator)) + + @r.get("/{serial}") + def sessions(serial: str): + return g(lambda: svc.sessions(ctx.store, serial)) + + @r.get("/{serial}/{session_id}") + def session_turns(serial: str, session_id: str): + return g(lambda: svc.session_turns(ctx.store, serial, session_id)) + + @r.get("/{serial}/{session_id}/export") + def export_session(serial: str, session_id: str): + data = g(lambda: svc.export_session(ctx.store, serial, session_id)) + return JSONResponse(data, headers={ + "content-disposition": f'attachment; filename="conv_{serial}_{session_id}.json"'}) + + @r.post("/{serial}/purge") + def purge(serial: str, body: _Purge): + return g(lambda: svc.purge(ctx.store, serial, session_id=body.session_id, + profile_id=body.profile_id, before=body.before, + now=now(), actor=ctx.operator)) + + return r diff --git a/kazeia_central/features/medical/conversations/service.py b/kazeia_central/features/medical/conversations/service.py new file mode 100644 index 0000000..c655594 --- /dev/null +++ b/kazeia_central/features/medical/conversations/service.py @@ -0,0 +1,80 @@ +"""Service Conversations (médical, cœur RGPD). + +Rapatriement des conversations cliniques : `conversations_export` (RPC file-drop NDJSON +sur le device) → `adb pull` → archive **chiffrée** dans le store → **`export_purge` +immédiat** (l'export est PLAINTEXT MVP côté app §écarts, donc ne doit pas traîner sur le +stockage device). Puis lecture / export JSON / purge RGPD depuis l'archive centrale. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from typing import Any + +from kazeia_central.core.adb import Adb +from kazeia_central.core.provider import ProviderClient +from kazeia_central.core.store import Store + + +def collect(adb: Adb, store: Store, serial: str, *, profile_id: str | None = None, + since: int | None = None, until: int | None = None, now: int, + actor: str | None = None) -> dict[str, Any]: + """Exporte (device) → pull → archive chiffrée (dédoublonnée) → purge device immédiate.""" + c = ProviderClient(adb, serial) + res = c.conversations_export(profile_id=profile_id, since=since, until=until) + path = res.get("path") + exported = int(res.get("count") or 0) + archived = 0 + if path and exported: + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, "export.ndjson") + adb.pull(path, local, serial=serial) + turns = [] + with open(local, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + obj = json.loads(line) + if "_meta" in obj: # ligne d'en-tête {"_meta": {...}} + continue + turns.append(obj) + archived = store.archive_turns(serial, turns, now=now) + # PLAINTEXT MVP : purger le fichier device tout de suite (pas de PII qui traîne). + purged = False + if path: + try: + c.export_purge(path) + purged = True + except Exception: + pass + store.audit("conversations_collect", actor=actor, target=serial, + detail={"exported": exported, "archived": archived, "purged": purged}, now=now) + return {"exported": exported, "archived": archived, "purged": purged} + + +def sessions(store: Store, serial: str) -> list[dict[str, Any]]: + return store.archived_sessions(serial) + + +def session_turns(store: Store, serial: str, session_id: str) -> list[dict[str, Any]]: + return store.session_turns(serial, session_id) + + +def export_session(store: Store, serial: str, session_id: str) -> dict[str, Any]: + """Bundle JSON exportable d'une session (déchiffré).""" + turns = store.session_turns(serial, session_id) + return {"serial": serial, "session_id": session_id, "turn_count": len(turns), + "profile_id": turns[0]["profile_id"] if turns else None, "turns": turns} + + +def purge(store: Store, serial: str, *, session_id: str | None = None, + profile_id: str | None = None, before: int | None = None, + now: int, actor: str | None = None) -> dict[str, Any]: + n = store.purge_turns(serial, session_id=session_id, profile_id=profile_id, before=before) + store.audit("conversations_purge", actor=actor, target=serial, + detail={"session_id": session_id, "profile_id": profile_id, "before": before, + "deleted": n}, now=now) + return {"deleted": n} diff --git a/kazeia_central/web/app.js b/kazeia_central/web/app.js index 8b583a8..589d019 100644 --- a/kazeia_central/web/app.js +++ b/kazeia_central/web/app.js @@ -33,7 +33,7 @@ const SECTIONS = [ { id: "audit", ic: "📋", label: "Audit", render: renderAudit }, { group: "⚕️ Médical / clinique", domain: "med" }, { id: "patients", ic: "👤", label: "Patients & profils", render: renderPatients }, - { id: "conversations", ic: "💬", label: "Conversations", render: () => stub("Conversations cliniques", "Rapatriement chiffré des conversations + export PDF/JSON + purge + collecteur de fond. Cœur RGPD. Dépend de conversations_export côté app patiente.") }, + { id: "conversations", ic: "💬", label: "Conversations", render: renderConversations }, { id: "rag", ic: "📚", label: "RAG thérapeutique", render: () => stub("Corpus RAG thérapeutique", "Authoring du corpus clinique + test retrieval + publication OTA. Dépend de rag_upsert_json côté app patiente.") }, { id: "questionnaires", ic: "📝", label: "Questionnaires", render: () => stub("Questionnaires cliniques", "Passation d'échelles/questionnaires cliniques au patient et suivi des scores dans le temps. À concevoir avec toi.") }, ]; @@ -276,5 +276,49 @@ async function pushLanguage(btn, serial, pid) { await withBusy(btn, async () => { try { await api(`/patients/${serial}/${pid}/language`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ language }) }); toast(`langue « ${language} » poussée sur la tablette`, "ok"); } catch (e) { toast(e.message, "err"); } }); } +// ---- CONVERSATIONS (médical, RGPD) ---------------------------------------- +async function renderConversations(c) { + const serial = S.selected; + if (!serial) { c.innerHTML = pageHead("Conversations") + `
Choisis une tablette (en haut).
| session | patient | tours | début | |
|---|---|---|---|---|
| ${esc(s.session_id)} | ${esc(s.profile_id || "—")} | ${s.turns} | +${fmtTs(s.started_at)} | ++ + |