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:
parent
7861abd734
commit
06b5cda214
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue