feat(medical): feature Conversations — rapatriement chiffré + export/purge (RGPD)

Cœur RGPD, débloqué par conversations_export/export_purge (RPC vc19).
- store : archived_sessions (liste), purge_turns (RGPD) ; réutilise turns/archive_turns.
- service.collect : conversations_export → adb pull NDJSON → archive chiffrée (dédup) →
  export_purge IMMÉDIAT (plaintext MVP côté app) ; sessions/session_turns/export_session/purge.
- router /api/conversations/{serial}/collect|{session_id}|/export|/purge ; enregistré.
- UI : section Conversations (rapatrier, sessions, lecture en bulles patient/kazeia,
  export JSON, purge RGPD par session).
- tests +5 (archive richtext ':'+\n déchiffrée, dédup, purge auto device, sessions, purge). 50/50.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-07-01 18:52:33 +02:00
parent 06f08bd82f
commit afdf033f8f
7 changed files with 289 additions and 2 deletions

View File

@ -22,6 +22,7 @@ from .core.adb import Adb
from .core.context import VERSION, Ctx from .core.context import VERSION, Ctx
from .core.operator_router import make_router as operator_router from .core.operator_router import make_router as operator_router
from .core.store import Store 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.medical.patients.router import make_router as patients_router
from .features.technical.fleet.router import make_router as fleet_router from .features.technical.fleet.router import make_router as fleet_router
from .features.technical.voices.autosync import VoiceAutoSync 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 # 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. # 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: def _install_basic_auth(app: FastAPI) -> None:

View File

@ -325,6 +325,33 @@ class Store:
).fetchone() ).fetchone()
return row["m"] if row else None 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) -------------- # ---- archive voix (enrôlement, §6 VOICE_ENROLLMENT_SPEC) --------------
def _voices_dir(self) -> Path: def _voices_dir(self) -> Path:
d = Path(self.path).parent / "voices" d = Path(self.path).parent / "voices"

View File

@ -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

View File

@ -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}

View File

@ -33,7 +33,7 @@ const SECTIONS = [
{ id: "audit", ic: "📋", label: "Audit", render: renderAudit }, { id: "audit", ic: "📋", label: "Audit", render: renderAudit },
{ group: "⚕️ Médical / clinique", domain: "med" }, { group: "⚕️ Médical / clinique", domain: "med" },
{ id: "patients", ic: "👤", label: "Patients & profils", render: renderPatients }, { 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: "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.") }, { 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"); } }); 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") + `<div class="card"><div class="empty"><div class="big">💬</div><p>Choisis une tablette (en haut).</p></div></div>`; return; }
const list = await api(`/conversations/${serial}`).catch(e => ({ error: e.message }));
const ss = Array.isArray(list) ? list : [];
const table = list.error ? `<div class="err">${esc(list.error)}</div>` : ss.length ? `<table>
<tr><th>session</th><th>patient</th><th>tours</th><th>début</th><th></th></tr>
${ss.map(s => `<tr><td class="mono">${esc(s.session_id)}</td><td>${esc(s.profile_id || "—")}</td><td>${s.turns}</td>
<td class="muted">${fmtTs(s.started_at)}</td>
<td style="white-space:nowrap"><button class="btn-sm" onclick="openSession('${serial}','${s.session_id}')">lire</button>
<button class="btn-sm" onclick="exportSession('${serial}','${s.session_id}')">JSON</button>
<button class="btn-sm" onclick="purgeSession(this,'${serial}','${s.session_id}')">purger</button></td></tr>`).join("")}
</table>` : `<div class="empty">aucune conversation archivée clique « Rapatrier depuis la tablette »</div>`;
c.innerHTML = pageHead("Conversations", esc(deviceLabel(serial)) + " · archive clinique chiffrée (RGPD)",
`<button class="btn-primary" onclick="collectConversations(this,'${serial}')">Rapatrier depuis la tablette</button>`)
+ `<div class="card full">${table}</div><div id="convview"></div>`;
}
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 = `<div class="empty"><span class="spinner"></span></div>`;
let turns; try { turns = await api(`/conversations/${serial}/${sid}`); } catch (e) { host.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; return; }
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Session ${esc(sid)}</h1><div class="actions"><button class="btn-sm" onclick="exportSession('${serial}','${sid}')">Exporter JSON</button></div></div>
<div class="card full">${turns.length ? turns.map(t => { const me = t.role === "PATIENT";
return `<div style="display:flex;justify-content:${me ? "flex-start" : "flex-end"};margin:7px 0">
<div style="max-width:72%;padding:9px 13px;border-radius:12px;background:${me ? "var(--surface-2)" : "var(--primary-soft)"};border:1px solid var(--line)">
<div class="muted" style="font-size:11px;margin-bottom:2px">${me ? "Patient" : "Kazeia"} · ${fmtTs(t.ts)}</div>${esc(t.text || "")}</div></div>`; }).join("") : `<div class="empty">aucun tour</div>`}</div>`;
}
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 ---------------------------------------------------------------- // ---- store ----------------------------------------------------------------
async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } } async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } }

View File

@ -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") == []