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