diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py index 4cb9de1..57ce2a0 100644 --- a/kazeia_central/api/app.py +++ b/kazeia_central/api/app.py @@ -249,6 +249,17 @@ def create_app(adb: Adb | None = None, store: Store | None = None, def voices_list(serial: str): return _guard(lambda: vo.list_voices(adb, store, serial)) + # Voix enregistrées via l'admin (manifestes record-time : scope/owner/consentement). + # Déclarées avant /{serial}/{voice_id}/... — "admin" ne doit pas être pris pour un id. + @app.get("/api/voices/{serial}/admin") + def voices_admin_list(serial: str): + return _guard(lambda: vo.list_admin_voices(adb, store, serial)) + + @app.post("/api/voices/{serial}/admin/sync") + def voices_admin_sync(serial: str, body: _Sync): + return _guard(lambda: vo.sync_admin(adb, store, bridge, serial, + delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR)) + @app.post("/api/voices/{serial}/{voice_id}/prepare") def voices_prepare(serial: str, voice_id: str): # pull + archive chiffré + transcription (ASR OmniVoice, à relire avant enrôlement) diff --git a/kazeia_central/store/db.py b/kazeia_central/store/db.py index 945ff05..dfdb48d 100644 --- a/kazeia_central/store/db.py +++ b/kazeia_central/store/db.py @@ -79,6 +79,9 @@ CREATE TABLE IF NOT EXISTS voices ( deployed_at INTEGER, -- .ovsp poussé sur la tablette wav_deleted_at INTEGER, -- WAV supprimé de la tablette locked_profile_id TEXT, -- assignation exclusive à un profil patient (NULL = généraliste) + scope TEXT, -- exclusive | global | pending (du manifeste admin §4.2) + owner_name TEXT, -- libellé propriétaire (manifeste) + consent_text_enc BLOB, -- reference_text = phrase de consentement (PII) → chiffré PRIMARY KEY (serial, voice_id) ); """ @@ -116,6 +119,11 @@ class Store: # Migration OmniVoice (23/06/2026) : cvps_bytes → ovsp_bytes. if "cvps_bytes" in cols and "ovsp_bytes" not in cols: self._db.execute("ALTER TABLE voices RENAME COLUMN cvps_bytes TO ovsp_bytes") + # Ingestion manifeste admin (§4.2) : scope/propriétaire/consentement. + for col, decl in (("scope", "TEXT"), ("owner_name", "TEXT"), + ("consent_text_enc", "BLOB")): + if col not in cols: + self._db.execute(f"ALTER TABLE voices ADD COLUMN {col} {decl}") def close(self) -> None: with self._lock: @@ -352,6 +360,15 @@ class Store: with self._lock: self._voice_upsert(serial, voice_id, locked_profile_id=None) + def set_voice_manifest(self, serial: str, voice_id: str, *, scope: str | None, + owner_name: str | None, consent_text: str | None, now: int) -> None: + """Métadonnées du manifeste admin (§4.2) : portée + propriétaire + consentement + (chiffré). Le consentement = phrase nominative → PII.""" + v = self._require_vault() + with self._lock: + self._voice_upsert(serial, voice_id, scope=scope, owner_name=owner_name, + consent_text_enc=v.seal(consent_text)) + def locked_profile(self, serial: str, voice_id: str) -> str | None: with self._lock: row = self._db.execute( @@ -369,6 +386,7 @@ class Store: return None d = dict(row) d["transcription"] = v.open(d.pop("transcription_enc")) + d["consent_text"] = v.open(d.pop("consent_text_enc", None)) d["has_wav"] = bool(d.get("wav_file")) return d @@ -385,6 +403,7 @@ class Store: for r in rows: d = dict(r) d["transcription"] = v.open(d.pop("transcription_enc")) + d["consent_text"] = v.open(d.pop("consent_text_enc", None)) d["has_wav"] = bool(d.get("wav_file")) out.append(d) return out diff --git a/kazeia_central/voice/admin_ingest.py b/kazeia_central/voice/admin_ingest.py new file mode 100644 index 0000000..cd67294 --- /dev/null +++ b/kazeia_central/voice/admin_ingest.py @@ -0,0 +1,78 @@ +"""Ingestion des voix enregistrées via l'app admin (manifestes record-time). + +VOICE_DEPLOYMENT_SPEC §4.2 : une voix fraîchement enregistrée + son manifeste vivent +dans le **stockage ADMIN** `Android/data/com.kazeia.admin/files/voix/` +(`VoiceStorage.kt`), PAS dans le provider patient `/voices`. C'est là que `scope` +(exclusive|global|pending), `owner_profile_id` et `reference_text` (phrase de +consentement) vivent ensemble. Kazeia-central doit ingérer DEPUIS cette source. + +Py3.14-safe (adb seulement). Layout (source : `VoiceStorage.save()`) : + /.wav + /.json +manifeste : {id, name, state, wav, wav_size_bytes, duration_seconds, created_at, + reference_text, source_device, scope, owner_profile_id?, owner_name?} +""" + +from __future__ import annotations + +import json +import os +import posixpath +import tempfile +from typing import Any + +from ..adb import Adb + +# getExternalFilesDir("voix") de com.kazeia.admin. /sdcard = /storage/emulated/0. +ADMIN_VOIX_DIR = os.environ.get( + "KAZEIA_ADMIN_VOIX_DIR", "/sdcard/Android/data/com.kazeia.admin/files/voix") + +VALID_SCOPES = ("exclusive", "global", "pending") + + +def _normalize(m: dict, *, dir_path: str) -> dict[str, Any]: + vid = m.get("id") + scope = m.get("scope") or "pending" + if scope not in VALID_SCOPES: + scope = "pending" + wav = m.get("wav") or (f"{vid}.wav" if vid else None) + return { + "id": vid, + "name": m.get("name") or vid, + "scope": scope, + "owner_profile_id": m.get("owner_profile_id"), + "owner_name": m.get("owner_name"), + "reference_text": m.get("reference_text") or "", + "duration_seconds": m.get("duration_seconds"), + "created_at": m.get("created_at"), + "source_device": m.get("source_device"), + "wav_device_path": posixpath.join(dir_path, wav) if wav else None, + "manifest_device_path": posixpath.join(dir_path, f"{vid}.json") if vid else None, + } + + +def list_manifests(adb: Adb, serial: str, *, dir_path: str = ADMIN_VOIX_DIR) -> list[dict[str, Any]]: + """Liste + parse les manifestes voix du stockage admin (rien de lourd : on ne + pull que les `.json`, pas les WAV). Voix sans manifeste valide = ignorée.""" + out = adb.shell(f"ls {dir_path} 2>/dev/null", serial=serial) + names = [ln.strip() for ln in out.splitlines() if ln.strip().endswith(".json")] + voices: list[dict[str, Any]] = [] + with tempfile.TemporaryDirectory() as td: + for n in names: + local = os.path.join(td, n) + try: + adb.pull(posixpath.join(dir_path, n), local, serial=serial) + with open(local, encoding="utf-8") as f: + m = json.load(f) + except Exception: + continue # manifeste illisible/corrompu → on saute, pas de crash global + if m.get("id"): + voices.append(_normalize(m, dir_path=dir_path)) + return voices + + +def delete_admin_voice(adb: Adb, serial: str, voice: dict, *, dir_path: str = ADMIN_VOIX_DIR) -> None: + """Supprime WAV + manifeste du stockage admin (après archivage côté PC).""" + for key in ("wav_device_path", "manifest_device_path"): + p = voice.get(key) + if p: + adb.shell(f"rm -f {p}", serial=serial) diff --git a/kazeia_central/voice/autosync.py b/kazeia_central/voice/autosync.py index 3e11c37..59c3a40 100644 --- a/kazeia_central/voice/autosync.py +++ b/kazeia_central/voice/autosync.py @@ -16,7 +16,7 @@ import threading import time from typing import Any -from .orchestrator import sync_device +from .orchestrator import sync_admin class VoiceAutoSync: @@ -82,8 +82,8 @@ class VoiceAutoSync: return st self._set(serial, {"state": "sync_en_cours", "at": self._now()}) try: - rep = sync_device(self.adb, self.store, self.bridge, serial, - delete_source=self.delete_source, now=self._now(), actor="auto") + rep = sync_admin(self.adb, self.store, self.bridge, serial, + delete_source=self.delete_source, now=self._now(), actor="auto") ok = sum(1 for r in rep if r.get("ok")) st = {"state": "termine", "at": self._now(), "enrolled": ok, "total": len(rep), "report": rep} diff --git a/kazeia_central/voice/orchestrator.py b/kazeia_central/voice/orchestrator.py index 670d171..9ba816e 100644 --- a/kazeia_central/voice/orchestrator.py +++ b/kazeia_central/voice/orchestrator.py @@ -16,13 +16,14 @@ transcribe) puis `enroll_deploy` (enroll+push+delete). Le WAV est archivé DÈS from __future__ import annotations import os +import posixpath import tempfile from typing import Any from ..adb import Adb from ..provider import ProviderClient from ..store import Store -from . import transfer +from . import admin_ingest, transfer from .bridge import VoiceBridge @@ -182,3 +183,143 @@ def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voic store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{voice_id}", now=now) result["wav_deleted"] = True return result + + +# ===== Ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) ===== +# Source de vérité des voix fraîches : `Android/data/com.kazeia.admin/files/voix/` +# (scope/owner/reference_text ensemble), pas le provider patient `/voices`. + +# MODELS_DIR candidats côté patient (dev legacy / prod scoped-storage). +_CANDIDATE_MODELS_DIRS = ( + "/data/local/tmp/kazeia/models", + "/sdcard/Android/data/com.kazeia/files/kazeia/models", +) + + +def _patient_omnivoice_dir(adb: Adb, serial: str) -> str: + """Dossier `.ovsp` cible côté patient (`/omnivoice/voices`). Les + manifestes admin ne le donnent pas, et post-migration `/voices.wav_path` est VIDE + pour une voix déployée → on dérive en cascade : override env → wav_path legacy + (marqueur MODELS_DIR) → sonde des dossiers modèles connus sur le device.""" + env = os.environ.get("KAZEIA_OMNIVOICE_DIR") + if env: + return env + for r in _provider_voices(adb, serial): + if r.wav_path and transfer._WAV_MARKER in r.wav_path: + return transfer.omnivoice_dir_for(r.wav_path) + for base in _CANDIDATE_MODELS_DIRS: + if base in adb.shell(f"ls -d {base} 2>/dev/null", serial=serial): + return posixpath.join(base, "omnivoice", "voices") + raise ValueError( + "dossier omnivoice indéterminable (ni /voices wav_path, ni dossier modèles connu ; " + "définir KAZEIA_OMNIVOICE_DIR)") + + +def _admin_status(man: dict, rec: dict, deployed: bool) -> str: + if man["scope"] == "pending": + return "pending" # ni enrôlée ni déployable tant que non résolue + if deployed: + return "deployed" + if rec.get("enrolled_at"): + return "enrolled" + if rec.get("archived_at"): + return "archived" + return "to_enroll" + + +def list_admin_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]: + """Voix enregistrées via l'admin (manifestes) croisées avec l'archive store et + l'état de déploiement. C'est la liste à piloter depuis l'UI.""" + manifests = admin_ingest.list_manifests(adb, serial) + recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {} + try: + deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial)) + except Exception: + deployed = set() + out = [] + for man in manifests: + rec = recs.get(man["id"], {}) + is_dep = man["id"] in deployed + out.append({ + "voice_id": man["id"], "name": man["name"], "scope": man["scope"], + "owner_profile_id": man["owner_profile_id"], "owner_name": man["owner_name"], + "reference_text": man["reference_text"], "duration_seconds": man["duration_seconds"], + "deployed_on_device": is_dep, "archived": bool(rec.get("archived_at")), + "enrolled": bool(rec.get("enrolled_at")), "status": _admin_status(man, rec, is_dep), + }) + return out + + +def ingest_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, manifest: dict, *, + delete_source: bool, now: int, actor: str | None = None) -> dict[str, Any]: + """Ingère UNE voix admin : pull WAV → archive chiffrée → métadonnées (scope/owner/ + consentement) → verrou si exclusive → enrôle (ASR du 16 s, §6) → déploie selon scope. + `pending` = archivée mais NI enrôlée NI déployée (cadre à fixer à l'attribution).""" + vid = manifest["id"] + scope = manifest["scope"] + owner = manifest.get("owner_profile_id") + + # 1) Archiver le WAV (depuis le stockage admin) s'il ne l'est pas déjà. + data = store.voice_wav_bytes(serial, vid) + if data is None: + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, f"{vid}.wav") + transfer.pull_wav(adb, serial, manifest["wav_device_path"], local) + data = open(local, "rb").read() + store.archive_voice_wav(serial, vid, data, + source_wav_path=manifest["wav_device_path"], now=now) + + # 2) Métadonnées manifeste (scope/propriétaire/consentement chiffré) + verrou. + store.set_voice_manifest(serial, vid, scope=scope, owner_name=manifest.get("owner_name"), + consent_text=manifest.get("reference_text"), now=now) + if scope == "exclusive" and owner: + store.lock_voice(serial, vid, owner, now=now) + store.audit("voice_admin_ingest", actor=actor, target=f"{serial}/{vid}", + detail={"scope": scope, "owner": owner}, now=now) + + # 3) pending → on s'arrête (archivée, à résoudre plus tard). + if scope == "pending": + return {"voice_id": vid, "scope": scope, "deployed": False, "reason": "pending"} + + # 4) exclusive → le profil propriétaire doit être présent sur la tablette. + if scope == "exclusive" and owner: + _, present = _profiles_using(adb, serial) + if owner not in present: + return {"voice_id": vid, "scope": scope, "deployed": False, + "reason": "verrou_profil_absent", "profile": owner} + + # 5) Enrôler (texte vide → ASR auto du segment 16 s, §6) + déployer sur la tablette. + ov = _patient_omnivoice_dir(adb, serial) + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, f"{vid}.wav") + open(local, "wb").write(data) + ovsp = os.path.join(td, f"{vid}.ovsp") + info = bridge.enroll(local, "", ovsp) + store.set_voice_transcription(serial, vid, info.get("ref_text", ""), None, now=now) + store.mark_voice_enrolled(serial, vid, info["bytes"], now=now) + remote = transfer.push_ovsp(adb, serial, ovsp, ov, vid) + store.mark_voice_deployed(serial, vid, now=now) + store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{vid}", + detail={"ovsp_bytes": info["bytes"], "scope": scope}, now=now) + + result = {"voice_id": vid, "scope": scope, "deployed": True, "deployed_to": remote, + "ovsp_bytes": info["bytes"], "wav_deleted": False} + if delete_source: + admin_ingest.delete_admin_voice(adb, serial, manifest) + store.mark_voice_wav_deleted(serial, vid, now=now) + store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{vid}", now=now) + result["wav_deleted"] = True + return result + + +def sync_admin(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *, + delete_source: bool, now: int, actor: str | None = None) -> list[dict[str, Any]]: + """Ingère toutes les voix admin en attente d'une tablette (rapport par voix).""" + report: list[dict[str, Any]] = [] + for man in admin_ingest.list_manifests(adb, serial): + try: + report.append({"ok": True, **ingest_voice(adb, store, bridge, serial, man, + delete_source=delete_source, now=now, actor=actor)}) + except Exception as e: + report.append({"ok": False, "voice_id": man.get("id"), "error": str(e)}) + return report diff --git a/tests/test_voice.py b/tests/test_voice.py index 1c37054..3b91caf 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -200,10 +200,148 @@ def test_autosync_blocked_when_store_locked(tmp_path): def test_autosync_enabled_runs_sync(tmp_path): + # autosync ingère depuis le stockage admin (manifestes) → adb admin + bridge ASR. from kazeia_central.voice.autosync import VoiceAutoSync s = _store(tmp_path) - a = VoiceAutoSync(_VoiceAdb([("amir", _WP, 1)]), s, _FakeBridge()) + a = VoiceAutoSync(_AdminAdb([_manifest("paul", "global")]), s, _BridgeASR()) a.set_enabled(True, delete_source=False) assert a.status()["enabled"] is True st = a.sync_now("D1") assert st["state"] == "termine" and st["enrolled"] == 1 + + +# ---- ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) ---- +import json as _json +import os as _os + +from kazeia_central.voice import admin_ingest + +_ADMIN = admin_ingest.ADMIN_VOIX_DIR +_PWP = "/data/local/tmp/kazeia/models/../voix/voix/x.wav" +_OVDIR = "/data/local/tmp/kazeia/models/omnivoice/voices" + + +def _manifest(vid, scope, owner=None): + m = {"id": vid, "name": vid.title(), "state": "recorded", "wav": f"{vid}.wav", + "duration_seconds": 16.0, "created_at": 1, + "reference_text": f"Phrase de consentement de {vid} — SECRET-{vid}", + "source_device": "tablet-admin-recording", "scope": scope} + if owner: + m["owner_profile_id"] = owner + m["owner_name"] = "Patient " + owner + return m + + +class _AdminAdb(Adb): + def __init__(self, manifests, profiles=(), deployed=()): + super().__init__() + self._manifests = {m["id"]: m for m in manifests} + self._profiles = list(profiles) + self._deployed = list(deployed) + self.pushed = [] + self.removed = [] + + def ready_devices(self): + return [AdbDevice("D1", "device", {})] + + def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True): + if path == "voices": + return [{"id": "x", "wav_path": _PWP, "wav_exists": 0}] + if path == "profiles": + return [{"id": p, "voice_id": None} for p in self._profiles] + return [] + + def shell(self, command, *, serial=None, timeout=None): + if command.startswith(f"ls {_ADMIN}"): + return "\n".join(f"{i}.json" for i in self._manifests) + if command.startswith("ls "): # dossier omnivoice (déployés) + return "\n".join(f"{d}.ovsp" for d in self._deployed) + if "rm -f" in command: + self.removed.append(command.split("rm -f ")[1].split(" ")[0]) + return "" + + def pull(self, remote, local, *, serial=None): + if remote.endswith(".json"): + vid = _os.path.basename(remote)[:-5] + with open(local, "w", encoding="utf-8") as f: + _json.dump(self._manifests[vid], f) + else: + open(local, "wb").write(b"WAV:" + remote.encode()) + + def push(self, local, remote, *, serial=None): + self.pushed.append((open(local, "rb").read(), remote)) + + +class _BridgeASR: + def enroll(self, wav, text, out): + open(out, "wb").write(b"OVSP-asr") + return {"bytes": 1234, "t_ref": 62, "ref_text": "transcription asr du segment"} + + +def test_admin_list_manifests_parse(): + adb = _AdminAdb([_manifest("marie", "exclusive", owner="p1"), _manifest("paul", "global")]) + out = {v["id"]: v for v in admin_ingest.list_manifests(adb, "D1")} + assert out["marie"]["scope"] == "exclusive" and out["marie"]["owner_profile_id"] == "p1" + assert out["marie"]["wav_device_path"] == _os.path.join(_ADMIN, "marie.wav") + assert out["paul"]["scope"] == "global" and out["paul"]["owner_profile_id"] is None + + +def test_set_voice_manifest_consent_encrypted(tmp_path): + s = _store(tmp_path) + s.set_voice_manifest("D1", "marie", scope="exclusive", owner_name="Patient p1", + consent_text="Phrase SECRET-marie", now=NOW) + rec = s.voice_record("D1", "marie") + assert rec["scope"] == "exclusive" and rec["consent_text"] == "Phrase SECRET-marie" + raw = (tmp_path / "c.db").read_bytes() + assert b"SECRET-marie" not in raw # consentement (PII) chiffré au repos + + +def test_ingest_pending_archives_no_deploy(tmp_path): + s = _store(tmp_path) + adb = _AdminAdb([_manifest("anon", "pending")]) + man = admin_ingest.list_manifests(adb, "D1")[0] + r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) + assert r["deployed"] is False and r["reason"] == "pending" + assert adb.pushed == [] # rien déployé + assert s.voice_record("D1", "anon")["archived_at"] == NOW # mais WAV archivé + assert s.voice_record("D1", "anon")["scope"] == "pending" + + +def test_ingest_global_deploys(tmp_path): + s = _store(tmp_path) + adb = _AdminAdb([_manifest("paul", "global")]) + man = admin_ingest.list_manifests(adb, "D1")[0] + r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) + assert r["deployed"] is True and r["scope"] == "global" + assert adb.pushed and adb.pushed[0][1] == _os.path.join(_OVDIR, "paul.ovsp") + assert s.voice_record("D1", "paul")["locked_profile_id"] is None + + +def test_ingest_exclusive_locks_and_deploys(tmp_path): + s = _store(tmp_path) + adb = _AdminAdb([_manifest("marie", "exclusive", owner="p1")], profiles=["p1", "p2"]) + man = admin_ingest.list_manifests(adb, "D1")[0] + r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) + assert r["deployed"] is True + assert s.voice_record("D1", "marie")["locked_profile_id"] == "p1" + + +def test_ingest_exclusive_owner_absent_skips_deploy(tmp_path): + s = _store(tmp_path) + adb = _AdminAdb([_manifest("marie", "exclusive", owner="p_absent")], profiles=["p1"]) + man = admin_ingest.list_manifests(adb, "D1")[0] + r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) + assert r["deployed"] is False and r["reason"] == "verrou_profil_absent" + assert adb.pushed == [] + # archivée + verrouillée quand même (juste pas déployée ici) + assert s.voice_record("D1", "marie")["locked_profile_id"] == "p_absent" + + +def test_sync_admin_delete_source_cleans_admin(tmp_path): + s = _store(tmp_path) + adb = _AdminAdb([_manifest("paul", "global")]) + rep = vo.sync_admin(adb, s, _BridgeASR(), "D1", delete_source=True, now=NOW) + assert rep[0]["ok"] and rep[0]["deployed"] and rep[0]["wav_deleted"] + # WAV + manifeste supprimés du stockage admin + assert any(p.endswith("paul.wav") for p in adb.removed) + assert any(p.endswith("paul.json") for p in adb.removed)