feat(medical): feature Patients — dossier clinique chiffré + langue poussée (RPC)

Première feature médicale (features/medical/patients/), valide le patron features/medical
ET le round-trip d'écriture RPC live.
- store : table patient_fiche (identité/contact/clinique CHIFFRÉES, PC-only) + language ;
  save_fiche/fiche/fiches/set_fiche_language.
- service : list_patients (profils on-device × fiche centrale), get_patient (profile_dump
  riche + fiche), save_fiche (central, ne touche pas la tablette), set_language
  (profile_dump → +language → profile_upsert → miroir central).
- router /api/patients/{serial}[/{profile_id}][/fiche|/language] ; enregistré dans _FEATURES.
- UI : section Patients (liste + détail, fiche clinique éditable par rubriques, sélecteur
  de langue + push sur la tablette).
- tests +4 (fiche chiffrée, merge, get, push langue). 46/46.
- validé LIVE vc19 : profile_upsert (langue es + prompt riche ':' intact) round-trip + delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-07-01 18:12:01 +02:00
parent 8d90c49e54
commit 06f08bd82f
7 changed files with 299 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.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
from .features.technical.voices.bridge import VoiceBridge from .features.technical.voices.bridge import VoiceBridge
@ -33,7 +34,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) _FEATURES = (operator_router, fleet_router, voices_router, patients_router)
def _install_basic_auth(app: FastAPI) -> None: def _install_basic_auth(app: FastAPI) -> None:

View File

@ -93,6 +93,16 @@ CREATE TABLE IF NOT EXISTS voice_deployments (
deployed_at INTEGER, deployed_at INTEGER,
PRIMARY KEY (voice_id, serial) PRIMARY KEY (voice_id, serial)
); );
-- Dossier patient (médical) : identité/contact/clinique CHIFFRÉS (PII santé, restent
-- sur le PC, jamais poussés sur la tablette). `language` = miroir de l'opérationnel poussé.
CREATE TABLE IF NOT EXISTS patient_fiche (
serial TEXT NOT NULL,
profile_id TEXT NOT NULL,
fiche_enc BLOB, -- JSON {civil, contact, clinical} chiffré
language TEXT, -- langue opérationnelle (miroir du profil on-device)
updated_at INTEGER,
PRIMARY KEY (serial, profile_id)
);
""" """
@ -458,6 +468,51 @@ class Store:
rows = self._db.execute("SELECT * FROM voices ORDER BY voice_id").fetchall() rows = self._db.execute("SELECT * FROM voices ORDER BY voice_id").fetchall()
return [self._voice_dict(r, v) for r in rows] return [self._voice_dict(r, v) for r in rows]
# ---- dossier patient (médical, chiffré) -------------------------------
def save_fiche(self, serial: str, profile_id: str, fiche: dict[str, Any],
language: str | None, *, now: int) -> None:
"""Enregistre la fiche clinique chiffrée (identité/contact/clinique). Le
contenu = PII santé chiffré ; `language` en clair (miroir opérationnel)."""
v = self._require_vault()
with self._lock:
self._db.execute(
"INSERT INTO patient_fiche(serial, profile_id, fiche_enc, language, updated_at) "
"VALUES(?,?,?,?,?) ON CONFLICT(serial, profile_id) DO UPDATE SET "
"fiche_enc=excluded.fiche_enc, language=excluded.language, updated_at=excluded.updated_at",
(serial, profile_id, v.seal(json.dumps(fiche)), language, now))
self._db.commit()
def set_fiche_language(self, serial: str, profile_id: str, language: str | None, *, now: int) -> None:
with self._lock:
self._db.execute(
"INSERT INTO patient_fiche(serial, profile_id, language, updated_at) VALUES(?,?,?,?) "
"ON CONFLICT(serial, profile_id) DO UPDATE SET language=excluded.language, updated_at=excluded.updated_at",
(serial, profile_id, language, now))
self._db.commit()
def fiche(self, serial: str, profile_id: str) -> dict[str, Any] | None:
v = self._require_vault()
with self._lock:
row = self._db.execute(
"SELECT * FROM patient_fiche WHERE serial=? AND profile_id=?",
(serial, profile_id)).fetchone()
if not row:
return None
fx = v.open(row["fiche_enc"])
return {"fiche": json.loads(fx) if fx else {}, "language": row["language"],
"updated_at": row["updated_at"]}
def fiches(self, serial: str) -> dict[str, dict[str, Any]]:
v = self._require_vault()
with self._lock:
rows = self._db.execute(
"SELECT * FROM patient_fiche WHERE serial=?", (serial,)).fetchall()
out = {}
for r in rows:
fx = v.open(r["fiche_enc"])
out[r["profile_id"]] = {"fiche": json.loads(fx) if fx else {}, "language": r["language"]}
return out
# ---- audit (§8) ------------------------------------------------------- # ---- audit (§8) -------------------------------------------------------
def audit(self, action: str, *, actor: str | None = None, target: str | None = None, def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
detail: dict[str, Any] | None = None, now: int) -> None: detail: dict[str, Any] | None = None, now: int) -> None:

View File

@ -0,0 +1,43 @@
"""Router Patients (médical) : dossier clinique + langue par patient."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from kazeia_central.core.context import Ctx
from . import service as svc
class _Fiche(BaseModel):
fiche: dict
language: str | None = None
class _Lang(BaseModel):
language: str
def make_router(ctx: Ctx) -> APIRouter:
r = APIRouter(prefix="/api/patients", tags=["patients"])
g, now = ctx.guard, ctx.now_ms
@r.get("/{serial}")
def list_patients(serial: str):
return g(lambda: svc.list_patients(ctx.adb, ctx.store, serial))
@r.get("/{serial}/{profile_id}")
def get_patient(serial: str, profile_id: str):
return g(lambda: svc.get_patient(ctx.adb, ctx.store, serial, profile_id))
@r.put("/{serial}/{profile_id}/fiche")
def save_fiche(serial: str, profile_id: str, body: _Fiche):
return g(lambda: svc.save_fiche(ctx.store, serial, profile_id, body.fiche,
body.language, now=now(), actor=ctx.operator))
@r.put("/{serial}/{profile_id}/language")
def set_language(serial: str, profile_id: str, body: _Lang):
return g(lambda: svc.set_language(ctx.adb, ctx.store, serial, profile_id,
body.language, now=now(), actor=ctx.operator))
return r

View File

@ -0,0 +1,62 @@
"""Service Patients (médical) : dossier clinique chiffré côté central + profils
on-device pilotés par RPC (profile_dump/upsert base64-JSON, vc19).
Séparation stricte : la **fiche clinique** (identité/contact/clinique) est PII santé
elle reste chiffrée sur le PC, jamais poussée sur la tablette. Seul l'**opérationnel**
(langue, voix, prompt) vit sur le profil on-device et se pousse via `profile_upsert`.
"""
from __future__ import annotations
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 list_patients(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
"""Profils on-device de la tablette croisés avec le dossier clinique central."""
profs = ProviderClient(adb, serial).profiles()
fiches = store.fiches(serial) if store.is_unlocked else {}
out = []
for p in profs:
f = fiches.get(p.id, {})
civil = (f.get("fiche") or {}).get("civil") or {}
out.append({
"profile_id": p.id, "display_name": p.display_name, "voice_id": p.voice_id,
"has_pin": p.has_pin, "is_default": p.is_default, "turn_count": p.turn_count,
"has_fiche": bool(f.get("fiche")), "language": f.get("language"),
"full_name": civil.get("full_name"),
})
return out
def get_patient(adb: Adb, store: Store, serial: str, profile_id: str) -> dict[str, Any]:
"""Détail : profil on-device riche (prompt/langue via profile_dump) + fiche clinique."""
prof = ProviderClient(adb, serial).profile_dump(profile_id)
rec = store.fiche(serial, profile_id) or {}
return {"profile": prof, "fiche": rec.get("fiche") or {},
"language": rec.get("language") or prof.get("language")}
def save_fiche(store: Store, serial: str, profile_id: str, fiche: dict, language: str | None,
*, now: int, actor: str | None = None) -> dict[str, Any]:
"""Enregistre la fiche clinique chiffrée côté central (ne touche PAS la tablette)."""
store.save_fiche(serial, profile_id, fiche, language, now=now)
store.audit("patient_fiche_saved", actor=actor, target=f"{serial}/{profile_id}", now=now)
return {"ok": True, "profile_id": profile_id}
def set_language(adb: Adb, store: Store, serial: str, profile_id: str, language: str,
*, now: int, actor: str | None = None) -> dict[str, Any]:
"""Pousse la langue sur le profil on-device (profile_dump → +language →
profile_upsert ; PIN absent = inchangé) et la reflète dans le dossier central."""
c = ProviderClient(adb, serial)
prof = c.profile_dump(profile_id)
prof["language"] = language
c.profile_upsert(prof)
store.set_fiche_language(serial, profile_id, language, now=now)
store.audit("patient_language_set", actor=actor, target=f"{serial}/{profile_id}",
detail={"language": language}, now=now)
return {"profile_id": profile_id, "language": language, "pushed": True}

View File

@ -32,7 +32,7 @@ const SECTIONS = [
{ id: "config", ic: "⚙", label: "Config & moteur", render: () => stub("Config & moteur", "Config sampling (température, presets…) et prompts moteur, avec push par tablette. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") }, { id: "config", ic: "⚙", label: "Config & moteur", render: () => stub("Config & moteur", "Config sampling (température, presets…) et prompts moteur, avec push par tablette. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") },
{ 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: () => stub("Patients & fiche patient", "Dossier patient chiffré côté Kazeia-central (identité, contact, clinique) + profils on-device + langue par patient. La fiche est constructible maintenant ; la propagation de la langue passe par l'app patiente (PROFILE_LANGUAGE_SPEC livrée).") }, { 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: () => 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: "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.") },
@ -222,5 +222,59 @@ async function reenrollVoice(btn, vid) { if (!confirm(`Ré-enrôler « ${vid} »
async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
// ---- PATIENTS (médical) ---------------------------------------------------
const LANGS = ["fr", "en", "de", "es", "it", "pt", "nl", "ar", "zh", "ja", "ko"];
async function renderPatients(c) {
const serial = S.selected;
if (!serial) { c.innerHTML = pageHead("Patients & profils") + `<div class="card"><div class="empty"><div class="big">👤</div><p>Choisis une tablette (en haut) pour voir ses patients.</p></div></div>`; return; }
const list = await api(`/patients/${serial}`).catch(e => ({ error: e.message }));
const ps = Array.isArray(list) ? list : [];
const table = list.error ? `<div class="err">${esc(list.error)}</div>` : ps.length ? `<table>
<tr><th>patient</th><th>voix</th><th>langue</th><th>fiche</th><th>tours</th><th></th></tr>
${ps.map(p => `<tr><td><b>${esc(p.full_name || p.display_name || p.profile_id)}</b><div class="muted mono">${esc(p.profile_id)}</div></td>
<td>${esc(p.voice_id || "—")}</td><td>${p.language ? badge(p.language, "primary") : `<span class="muted"></span>`}</td>
<td>${p.has_fiche ? badge("remplie", "ok") : `<span class="muted">vide</span>`}</td><td>${p.turn_count ?? 0}</td>
<td><button class="btn-sm" onclick="openPatient('${serial}','${p.profile_id}')">ouvrir</button></td></tr>`).join("")}
</table>` : `<div class="empty">aucun profil patient sur cette tablette</div>`;
c.innerHTML = pageHead("Patients & profils", esc(deviceLabel(serial))) + `<div class="card full">${table}</div><div id="patdetail"></div>`;
}
const finput = (id, label, val) => `<label style="display:flex;align-items:center;gap:10px;margin:5px 0"><span style="width:150px;color:var(--muted);font-size:13px">${label}</span><input id="${id}" value="${esc(val || "")}" style="flex:1"></label>`;
const ftext = (id, label, val) => `<div style="margin:8px 0"><div class="muted" style="font-size:12px;margin-bottom:3px">${label}</div><textarea id="${id}" rows="3" style="width:100%;font:inherit;padding:8px 11px;border:1px solid var(--line-strong);border-radius:8px;resize:vertical">${esc(val || "")}</textarea></div>`;
const subhead = (t) => `<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:12px 0 6px;font-weight:700">${t}</div>`;
async function openPatient(serial, pid) {
const host = $("#patdetail"); host.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
let d; try { d = await api(`/patients/${serial}/${pid}`); } catch (e) { host.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; return; }
const prof = d.profile || {}, f = d.fiche || {}, civ = f.civil || {}, con = f.contact || {}, cli = f.clinical || {};
const curLang = d.language || prof.language || "fr";
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Patient · ${esc(prof.display_name || pid)}</h1></div><div class="grid">
${card("Profil on-device (tablette)", kv("nom affiché", esc(prof.display_name || "—")) + kv("voix", esc(prof.voice_id || "—")) + kv("PIN", prof.has_pin ? "🔒 défini" : "—")
+ `<div class="kv"><span class="k">langue du pipeline</span><span class="v"><select id="lang">${LANGS.map(l => `<option ${l === curLang ? "selected" : ""}>${l}</option>`).join("")}</select> <button class="btn-sm btn-primary" onclick="pushLanguage(this,'${serial}','${pid}')">pousser</button></span></div>`
+ (prof.system_prompt_override ? `<div class="muted" style="margin-top:8px;font-size:12px">prompt override : ${esc((prof.system_prompt_override || "").slice(0, 90))}…</div>` : ""))}
${card("Fiche clinique <span class='badge'>chiffrée · PC uniquement</span>",
subhead("Identité civile") + finput("civ_name", "Nom complet", civ.full_name) + finput("civ_birth", "Naissance", civ.birth_date) + finput("civ_sex", "Sexe", civ.sex)
+ subhead("Contact / référent") + finput("con_ref", "Soignant référent", con.referent) + finput("con_fam", "Proche / famille", con.family) + finput("con_tel", "Téléphone", con.phone)
+ subhead("Clinique") + finput("cli_path", "Pathologie / contexte", cli.pathology) + finput("cli_treat", "Traitement", cli.treatment) + finput("cli_all", "Allergies", cli.allergies) + ftext("cli_notes", "Notes cliniques", cli.notes)
+ `<div style="margin-top:12px"><button class="btn-primary" onclick="saveFiche(this,'${serial}','${pid}')">Enregistrer la fiche</button></div>`, "full")}
</div>`;
}
async function saveFiche(btn, serial, pid) {
const gv = (id) => (($("#" + id) || {}).value || "").trim();
const fiche = {
civil: { full_name: gv("civ_name"), birth_date: gv("civ_birth"), sex: gv("civ_sex") },
contact: { referent: gv("con_ref"), family: gv("con_fam"), phone: gv("con_tel") },
clinical: { pathology: gv("cli_path"), treatment: gv("cli_treat"), allergies: gv("cli_all"), notes: gv("cli_notes") },
};
const language = ($("#lang") || {}).value || null;
await withBusy(btn, async () => { try { await api(`/patients/${serial}/${pid}/fiche`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fiche, language }) }); toast("fiche enregistrée (chiffrée)", "ok"); } catch (e) { toast(e.message, "err"); } });
}
async function pushLanguage(btn, serial, pid) {
const language = ($("#lang") || {}).value;
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"); } });
}
// ---- 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"); } }

82
tests/test_patients.py Normal file
View File

@ -0,0 +1,82 @@
"""Tests feature Patients : fiche clinique chiffrée + service (profils via RPC mocké)."""
import base64
import json as _json
import nacl.pwhash
from kazeia_central.core.adb import Adb
from kazeia_central.core.store import Store
from kazeia_central.features.medical.patients import service as svc
_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 _PatAdb(Adb):
"""Faux adb : /profiles (query) + profile_dump/upsert (RPC base64)."""
def __init__(self, profiles):
super().__init__()
self._p = profiles
self.upserted = []
def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True):
if path == "profiles":
return [dict(p, has_pin=0, is_default=0, turn_count=0) for p in self._p]
return []
def content_call(self, method, *, arg_json=None, extras=None, serial=None):
if method == "profile_dump_json":
prof = next((p for p in self._p if p["id"] == arg_json["id"]), None)
if not prof:
return {"ok": "false", "error": "not_found"}
payload = {**prof, "language": prof.get("language", "fr")}
return {"ok": "true", "result_b64": base64.b64encode(_json.dumps(payload).encode()).decode()}
if method == "profile_upsert_json":
self.upserted.append(arg_json)
return {"ok": "true"}
return {"ok": "true"}
def test_fiche_roundtrip_encrypted(tmp_path):
s = _store(tmp_path)
s.save_fiche("D1", "p1", {"civil": {"full_name": "Jean SECRET-NAME"}, "clinical": {"notes": "confidentiel"}}, "es", now=NOW)
r = s.fiche("D1", "p1")
assert r["fiche"]["civil"]["full_name"] == "Jean SECRET-NAME" and r["language"] == "es"
assert b"SECRET-NAME" not in (tmp_path / "c.db").read_bytes() # PII chiffrée au repos
assert s.fiches("D1")["p1"]["language"] == "es"
def test_list_patients_merges_fiche(tmp_path):
s = _store(tmp_path)
s.save_fiche("D1", "p1", {"civil": {"full_name": "Mme D."}}, "fr", now=NOW)
adb = _PatAdb([{"id": "p1", "display_name": "Profil 1", "voice_id": "damien"},
{"id": "p2", "display_name": "Profil 2", "voice_id": None}])
out = {p["profile_id"]: p for p in svc.list_patients(adb, s, "D1")}
assert out["p1"]["has_fiche"] and out["p1"]["full_name"] == "Mme D." and out["p1"]["language"] == "fr"
assert out["p2"]["has_fiche"] is False and out["p2"]["language"] is None
def test_get_patient_combines_profile_and_fiche(tmp_path):
s = _store(tmp_path)
s.save_fiche("D1", "p1", {"clinical": {"pathology": "anxiété"}}, "de", now=NOW)
adb = _PatAdb([{"id": "p1", "display_name": "P1", "voice_id": "damien", "language": "de"}])
d = svc.get_patient(adb, s, "D1", "p1")
assert d["profile"]["display_name"] == "P1" and d["fiche"]["clinical"]["pathology"] == "anxiété"
assert d["language"] == "de"
def test_set_language_pushes_and_mirrors(tmp_path):
s = _store(tmp_path)
adb = _PatAdb([{"id": "p1", "display_name": "P1", "voice_id": "damien"}])
r = svc.set_language(adb, s, "D1", "p1", "es", now=NOW)
assert r["pushed"] and r["language"] == "es"
assert adb.upserted and adb.upserted[0]["id"] == "p1" and adb.upserted[0]["language"] == "es" # poussé sur device
assert s.fiche("D1", "p1")["language"] == "es" # miroir central