From 06f08bd82f17499b86001c28438c950e2987bb35 Mon Sep 17 00:00:00 2001 From: alf Date: Wed, 1 Jul 2026 18:12:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(medical):=20feature=20Patients=20=E2=80=94?= =?UTF-8?q?=20dossier=20clinique=20chiffr=C3=A9=20+=20langue=20pouss=C3=A9?= =?UTF-8?q?e=20(RPC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- kazeia_central/app.py | 3 +- kazeia_central/core/store/db.py | 55 +++++++++++++ .../features/medical/patients/__init__.py | 0 .../features/medical/patients/router.py | 43 ++++++++++ .../features/medical/patients/service.py | 62 ++++++++++++++ kazeia_central/web/app.js | 56 ++++++++++++- tests/test_patients.py | 82 +++++++++++++++++++ 7 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 kazeia_central/features/medical/patients/__init__.py create mode 100644 kazeia_central/features/medical/patients/router.py create mode 100644 kazeia_central/features/medical/patients/service.py create mode 100644 tests/test_patients.py diff --git a/kazeia_central/app.py b/kazeia_central/app.py index dcdc19f..29da409 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.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 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 # 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: diff --git a/kazeia_central/core/store/db.py b/kazeia_central/core/store/db.py index b07b282..8e952fd 100644 --- a/kazeia_central/core/store/db.py +++ b/kazeia_central/core/store/db.py @@ -93,6 +93,16 @@ CREATE TABLE IF NOT EXISTS voice_deployments ( deployed_at INTEGER, 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() 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) ------------------------------------------------------- def audit(self, action: str, *, actor: str | None = None, target: str | None = None, detail: dict[str, Any] | None = None, now: int) -> None: diff --git a/kazeia_central/features/medical/patients/__init__.py b/kazeia_central/features/medical/patients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kazeia_central/features/medical/patients/router.py b/kazeia_central/features/medical/patients/router.py new file mode 100644 index 0000000..6484e34 --- /dev/null +++ b/kazeia_central/features/medical/patients/router.py @@ -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 diff --git a/kazeia_central/features/medical/patients/service.py b/kazeia_central/features/medical/patients/service.py new file mode 100644 index 0000000..c6a9d7d --- /dev/null +++ b/kazeia_central/features/medical/patients/service.py @@ -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} diff --git a/kazeia_central/web/app.js b/kazeia_central/web/app.js index 1f16cb4..8b583a8 100644 --- a/kazeia_central/web/app.js +++ b/kazeia_central/web/app.js @@ -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: "audit", ic: "📋", label: "Audit", render: renderAudit }, { 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: "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.") }, @@ -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 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") + `
👤

Choisis une tablette (en haut) pour voir ses patients.

`; return; } + const list = await api(`/patients/${serial}`).catch(e => ({ error: e.message })); + const ps = Array.isArray(list) ? list : []; + const table = list.error ? `
${esc(list.error)}
` : ps.length ? ` + + ${ps.map(p => ` + + + `).join("")} +
patientvoixlanguefichetours
${esc(p.full_name || p.display_name || p.profile_id)}
${esc(p.profile_id)}
${esc(p.voice_id || "—")}${p.language ? badge(p.language, "primary") : ``}${p.has_fiche ? badge("remplie", "ok") : `vide`}${p.turn_count ?? 0}
` : `
aucun profil patient sur cette tablette
`; + c.innerHTML = pageHead("Patients & profils", esc(deviceLabel(serial))) + `
${table}
`; +} + +const finput = (id, label, val) => ``; +const ftext = (id, label, val) => `
${label}
`; +const subhead = (t) => `
${t}
`; + +async function openPatient(serial, pid) { + const host = $("#patdetail"); host.innerHTML = `
`; + let d; try { d = await api(`/patients/${serial}/${pid}`); } catch (e) { host.innerHTML = `
${esc(e.message)}
`; 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 = `

Patient · ${esc(prof.display_name || pid)}

+ ${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" : "—") + + `
langue du pipeline
` + + (prof.system_prompt_override ? `
prompt override : ${esc((prof.system_prompt_override || "").slice(0, 90))}…
` : ""))} + ${card("Fiche clinique chiffrée · PC uniquement", + 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) + + `
`, "full")} +
`; +} + +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 ---------------------------------------------------------------- async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } } diff --git a/tests/test_patients.py b/tests/test_patients.py new file mode 100644 index 0000000..cee58e0 --- /dev/null +++ b/tests/test_patients.py @@ -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