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 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).

`; return; } + const list = await api(`/conversations/${serial}`).catch(e => ({ error: e.message })); + const ss = Array.isArray(list) ? list : []; + const table = list.error ? `
${esc(list.error)}
` : ss.length ? ` + + ${ss.map(s => ` + + `).join("")} +
sessionpatienttoursdébut
${esc(s.session_id)}${esc(s.profile_id || "—")}${s.turns}${fmtTs(s.started_at)} + +
` : `
aucune conversation archivée — clique « Rapatrier depuis la tablette »
`; + c.innerHTML = pageHead("Conversations", esc(deviceLabel(serial)) + " · archive clinique chiffrée (RGPD)", + ``) + + `
${table}
`; +} + +async function collectConversations(btn, serial) { + await withBusy(btn, async () => { try { + const r = await api(`/conversations/${serial}/collect`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); + toast(`${r.archived} tour(s) archivé(s) sur ${r.exported} exporté(s)${r.purged ? " · fichier device purgé" : ""}`, "ok"); + renderSection(); + } catch (e) { toast(e.message, "err"); } }); +} + +async function openSession(serial, sid) { + const host = $("#convview"); host.innerHTML = `
`; + let turns; try { turns = await api(`/conversations/${serial}/${sid}`); } catch (e) { host.innerHTML = `
${esc(e.message)}
`; return; } + host.innerHTML = `

Session ${esc(sid)}

+
${turns.length ? turns.map(t => { const me = t.role === "PATIENT"; + return `
+
+
${me ? "Patient" : "Kazeia"} · ${fmtTs(t.ts)}
${esc(t.text || "")}
`; }).join("") : `
aucun tour
`}
`; +} + +function exportSession(serial, sid) { window.open(`/api/conversations/${serial}/${sid}/export`, "_blank"); } + +async function purgeSession(btn, serial, sid) { + if (!confirm(`Purger définitivement la session ${sid} de l'archive centrale ? (RGPD, irréversible)`)) return; + await withBusy(btn, async () => { try { const r = await api(`/conversations/${serial}/purge`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session_id: sid }) }); toast(`${r.deleted} tour(s) purgé(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); +} + // ---- store ---------------------------------------------------------------- async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } } diff --git a/tests/test_conversations.py b/tests/test_conversations.py new file mode 100644 index 0000000..bb441d1 --- /dev/null +++ b/tests/test_conversations.py @@ -0,0 +1,81 @@ +"""Tests feature Conversations : archive chiffrée, sessions, purge, collect (RPC mocké).""" + +import nacl.pwhash + +from kazeia_central.core.adb import Adb +from kazeia_central.core.store import Store +from kazeia_central.features.medical.conversations import service as csvc + +_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN +_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN +NOW = 1_700_000_000_000 + + +def _store(tmp_path): + s = Store(tmp_path / "c.db", kdf_ops=_OPS, kdf_mem=_MEM) + s.unlock("pw") + return s + + +class _ConvAdb(Adb): + """Faux adb : conversations_export (file-drop) + pull NDJSON + export_purge.""" + def __init__(self, ndjson, count): + super().__init__() + self._nd = ndjson + self._count = count + self.purged = [] + + def content_call(self, method, *, arg_json=None, extras=None, serial=None): + if method == "conversations_export": + return {"ok": "true", "path": "/data/local/tmp/kazeia/exports/e.ndjson", + "count": str(self._count)} + if method == "export_purge": + self.purged.append(arg_json) + return {"ok": "true"} + return {"ok": "true"} + + def pull(self, remote, local, *, serial=None): + with open(local, "w", encoding="utf-8") as f: + f.write(self._nd) + + +def test_collect_archives_richtext_and_purges(tmp_path): + s = _store(tmp_path) + nd = ('{"_meta":{"count":2}}\n' + '{"id":"t1","profile_id":"p1","session_id":"s1","timestamp":100,"role":"PATIENT","text":"bonjour: ca va ?"}\n' + '{"id":"t2","profile_id":"p1","session_id":"s1","timestamp":101,"role":"KAZEIA","text":"oui\\net toi ?"}\n') + adb = _ConvAdb(nd, 2) + r = csvc.collect(adb, s, "D1", now=NOW) + assert r["exported"] == 2 and r["archived"] == 2 and r["purged"] is True + assert adb.purged # export_purge appelé (plaintext MVP) + turns = s.session_turns("D1", "s1") + assert [t["text"] for t in turns] == ["bonjour: ca va ?", "oui\net toi ?"] # ':' et saut de ligne intacts + assert b"bonjour" not in (tmp_path / "c.db").read_bytes() # PII chiffrée au repos + + +def test_collect_dedupes_on_recollect(tmp_path): + s = _store(tmp_path) + nd = ('{"_meta":{}}\n{"id":"t1","profile_id":"p1","session_id":"s1","timestamp":1,"role":"PATIENT","text":"a"}\n') + csvc.collect(_ConvAdb(nd, 1), s, "D1", now=NOW) + r2 = csvc.collect(_ConvAdb(nd, 1), s, "D1", now=NOW) # re-pull → 0 nouveau + assert r2["exported"] == 1 and r2["archived"] == 0 + + +def test_collect_empty_still_purges(tmp_path): + s = _store(tmp_path) + adb = _ConvAdb("", 0) + r = csvc.collect(adb, s, "D1", now=NOW) + assert r["exported"] == 0 and r["archived"] == 0 and r["purged"] is True + + +def test_sessions_and_purge(tmp_path): + s = _store(tmp_path) + s.archive_turns("D1", [ + {"id": "t1", "profile_id": "p1", "session_id": "s1", "timestamp": 1, "role": "PATIENT", "text": "a"}, + {"id": "t2", "profile_id": "p1", "session_id": "s1", "timestamp": 2, "role": "KAZEIA", "text": "b"}], now=NOW) + sess = csvc.sessions(s, "D1") + assert sess[0]["session_id"] == "s1" and sess[0]["turns"] == 2 and sess[0]["profile_id"] == "p1" + bundle = csvc.export_session(s, "D1", "s1") + assert bundle["turn_count"] == 2 and bundle["turns"][0]["text"] == "a" + assert csvc.purge(s, "D1", session_id="s1", now=NOW)["deleted"] == 2 + assert csvc.sessions(s, "D1") == []