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.
| patient | voix | langue | fiche | tours | |
|---|---|---|---|---|---|
| ${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} | +