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.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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
{ 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"); } }
|
||||||
|
|
|
||||||
|
|
@ -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