feat(voice): ingestion depuis le stockage ADMIN (manifestes record-time §4.2)

Comble le gap : les voix fraîches + leur manifeste (scope/owner/reference_text) vivent
dans Android/data/com.kazeia.admin/files/voix/ (VoiceStorage.kt), pas dans /voices.
- admin_ingest.py : list_manifests (ls + pull .json), delete_admin_voice. Schéma fondé
  sur VoiceStorage.save().
- orchestrator : list_admin_voices, ingest_voice (pull→archive→scope/owner/consentement
  →verrou si exclusive→enroll ASR→deploy selon scope), sync_admin. pending = archivée,
  ni enrôlée ni déployée. exclusive owner absent = verrou_profil_absent.
- _patient_omnivoice_dir robuste : env → wav_path legacy → sonde MODELS_DIR device
  (post-migration /voices.wav_path est VIDE pour une voix déployée).
- store : colonnes scope/owner_name/consent_text_enc (consentement chiffré, PII) +
  migration + set_voice_manifest. consent_text déchiffré dans voice_record/voices.
- autosync → sync_admin (le déclencheur auto ingère depuis l'admin).
- API : GET /voices/{serial}/admin, POST /voices/{serial}/admin/sync.
- enroll = ASR du 16s (migration §6) ; reference_text gardé comme trace de consentement.
- tests +8. 46/46. Validé live : manifeste synthétique → .ovsp déployé sur tablette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-23 10:30:58 +02:00
parent 7861abd734
commit 06b5cda214
6 changed files with 392 additions and 5 deletions

View File

@ -249,6 +249,17 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
def voices_list(serial: str): def voices_list(serial: str):
return _guard(lambda: vo.list_voices(adb, store, serial)) 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") @app.post("/api/voices/{serial}/{voice_id}/prepare")
def voices_prepare(serial: str, voice_id: str): def voices_prepare(serial: str, voice_id: str):
# pull + archive chiffré + transcription (ASR OmniVoice, à relire avant enrôlement) # pull + archive chiffré + transcription (ASR OmniVoice, à relire avant enrôlement)

View File

@ -79,6 +79,9 @@ CREATE TABLE IF NOT EXISTS voices (
deployed_at INTEGER, -- .ovsp poussé sur la tablette deployed_at INTEGER, -- .ovsp poussé sur la tablette
wav_deleted_at INTEGER, -- WAV supprimé de la tablette wav_deleted_at INTEGER, -- WAV supprimé de la tablette
locked_profile_id TEXT, -- assignation exclusive à un profil patient (NULL = généraliste) 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) PRIMARY KEY (serial, voice_id)
); );
""" """
@ -116,6 +119,11 @@ class Store:
# Migration OmniVoice (23/06/2026) : cvps_bytes → ovsp_bytes. # Migration OmniVoice (23/06/2026) : cvps_bytes → ovsp_bytes.
if "cvps_bytes" in cols and "ovsp_bytes" not in cols: if "cvps_bytes" in cols and "ovsp_bytes" not in cols:
self._db.execute("ALTER TABLE voices RENAME COLUMN cvps_bytes TO ovsp_bytes") 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: def close(self) -> None:
with self._lock: with self._lock:
@ -352,6 +360,15 @@ class Store:
with self._lock: with self._lock:
self._voice_upsert(serial, voice_id, locked_profile_id=None) 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: def locked_profile(self, serial: str, voice_id: str) -> str | None:
with self._lock: with self._lock:
row = self._db.execute( row = self._db.execute(
@ -369,6 +386,7 @@ class Store:
return None return None
d = dict(row) d = dict(row)
d["transcription"] = v.open(d.pop("transcription_enc")) 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")) d["has_wav"] = bool(d.get("wav_file"))
return d return d
@ -385,6 +403,7 @@ class Store:
for r in rows: for r in rows:
d = dict(r) d = dict(r)
d["transcription"] = v.open(d.pop("transcription_enc")) 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")) d["has_wav"] = bool(d.get("wav_file"))
out.append(d) out.append(d)
return out return out

View File

@ -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()`) :
<ADMIN_VOIX_DIR>/<id>.wav + <ADMIN_VOIX_DIR>/<id>.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)

View File

@ -16,7 +16,7 @@ import threading
import time import time
from typing import Any from typing import Any
from .orchestrator import sync_device from .orchestrator import sync_admin
class VoiceAutoSync: class VoiceAutoSync:
@ -82,7 +82,7 @@ class VoiceAutoSync:
return st return st
self._set(serial, {"state": "sync_en_cours", "at": self._now()}) self._set(serial, {"state": "sync_en_cours", "at": self._now()})
try: try:
rep = sync_device(self.adb, self.store, self.bridge, serial, rep = sync_admin(self.adb, self.store, self.bridge, serial,
delete_source=self.delete_source, now=self._now(), actor="auto") delete_source=self.delete_source, now=self._now(), actor="auto")
ok = sum(1 for r in rep if r.get("ok")) ok = sum(1 for r in rep if r.get("ok"))
st = {"state": "termine", "at": self._now(), "enrolled": ok, st = {"state": "termine", "at": self._now(), "enrolled": ok,

View File

@ -16,13 +16,14 @@ transcribe) puis `enroll_deploy` (enroll+push+delete). Le WAV est archivé DÈS
from __future__ import annotations from __future__ import annotations
import os import os
import posixpath
import tempfile import tempfile
from typing import Any from typing import Any
from ..adb import Adb from ..adb import Adb
from ..provider import ProviderClient from ..provider import ProviderClient
from ..store import Store from ..store import Store
from . import transfer from . import admin_ingest, transfer
from .bridge import VoiceBridge 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) store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{voice_id}", now=now)
result["wav_deleted"] = True result["wav_deleted"] = True
return result 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 (`<MODELS_DIR>/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

View File

@ -200,10 +200,148 @@ def test_autosync_blocked_when_store_locked(tmp_path):
def test_autosync_enabled_runs_sync(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 from kazeia_central.voice.autosync import VoiceAutoSync
s = _store(tmp_path) 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) a.set_enabled(True, delete_source=False)
assert a.status()["enabled"] is True assert a.status()["enabled"] is True
st = a.sync_now("D1") st = a.sync_now("D1")
assert st["state"] == "termine" and st["enrolled"] == 1 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)