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:
parent
06f08bd82f
commit
afdf033f8f
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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") + `<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 ----------------------------------------------------------------
|
||||
async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } }
|
||||
|
|
|
|||
|
|
@ -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") == []
|
||||
Loading…
Reference in New Issue