feat(voice): catalogue GLOBAL (flotte) + déploiement par tablette

Refonte : une voix n'est plus liée à sa tablette d'enregistrement. Catalogue central
clé voice_id, .ovsp stocké chiffré → déployable sur N tablettes sans ré-enrôler.
- store : voices PK voice_id (+ origin_serial, ovsp_enc) + table voice_deployments
  (many-to-many) + migration de l'ancien schéma (serial,voice_id)→global. Méthodes
  globales + add/remove_deployment, voices_on, deployments_of, store/voice_ovsp_bytes.
- orchestrator : catalog(), deploy_voice/undeploy_voice (scope-conscient), list_for_tablet
  (chargées + chargeables), ingest_voice/sync_admin au catalogue global. Suppr. legacy
  /voices-based (list_voices/prepare/enroll_deploy/sync_device).
- API : GET /voices/catalog, POST /voices/catalog/{vid}/lock|unlock, GET /voices/{serial}
  (chargées+chargeables), POST /voices/{serial}/deploy|undeploy.
- UI : carte Catalogue flotte (visible sans tablette) + par tablette chargées/chargeables
  avec charger/retirer.
- tests réécrits (40/40). Validé live : catalog [], /voices/{serial} chargées+chargeables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-23 19:39:00 +02:00
parent cdee5151f4
commit 6640cfd79e
6 changed files with 540 additions and 657 deletions

View File

@ -66,6 +66,10 @@ class _Lock(BaseModel):
profile_id: str
class _Deploy(BaseModel):
voice_id: str
def _install_basic_auth(app: FastAPI) -> None:
"""Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS
sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour
@ -231,8 +235,8 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
def voices_health():
return {"encoder_available": bridge.available(), "ov_python": bridge.ov_python}
# ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} sinon "autosync" est
# capté comme un serial (FastAPI matche dans l'ordre de déclaration).
# ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} (sinon "autosync"/"catalog"
# captés comme un serial — FastAPI matche dans l'ordre de déclaration).
@app.get("/api/voices/autosync")
def autosync_status():
return autosync.status()
@ -245,12 +249,29 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
now=_now_ms())
return autosync.status()
@app.get("/api/voices/{serial}")
def voices_list(serial: str):
return _guard(lambda: vo.list_voices(adb, store, serial))
# ---- catalogue voix GLOBAL (vue sans tablette) ------------------------
@app.get("/api/voices/catalog")
def voices_catalog():
return _guard(lambda: vo.catalog(store))
@app.post("/api/voices/catalog/{voice_id}/lock")
def voices_lock(voice_id: str, body: _Lock):
_guard(lambda: store.lock_voice(voice_id, body.profile_id, now=_now_ms()))
store.audit("voice_lock", actor=_OPERATOR, target=voice_id,
detail={"profile_id": body.profile_id}, now=_now_ms())
return {"voice_id": voice_id, "locked_profile_id": body.profile_id}
@app.post("/api/voices/catalog/{voice_id}/unlock")
def voices_unlock(voice_id: str):
_guard(lambda: store.unlock_voice(voice_id, now=_now_ms()))
store.audit("voice_unlock", actor=_OPERATOR, target=voice_id, now=_now_ms())
return {"voice_id": voice_id, "locked_profile_id": None}
# ---- vue par tablette : voix chargées + chargeables -------------------
@app.get("/api/voices/{serial}")
def voices_for_tablet(serial: str):
return _guard(lambda: vo.list_for_tablet(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))
@ -260,36 +281,15 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
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)
return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id,
now=_now_ms(), actor=_OPERATOR))
@app.post("/api/voices/{serial}/deploy")
def voices_deploy(serial: str, body: _Deploy):
return _guard(lambda: vo.deploy_voice(adb, store, bridge, serial, body.voice_id,
now=_now_ms(), actor=_OPERATOR))
@app.post("/api/voices/{serial}/{voice_id}/enroll")
def voices_enroll(serial: str, voice_id: str, body: _Enroll):
return _guard(lambda: vo.enroll_deploy(
adb, store, bridge, serial, voice_id, body.transcription,
delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR))
@app.post("/api/voices/{serial}/{voice_id}/lock")
def voices_lock(serial: str, voice_id: str, body: _Lock):
_guard(lambda: store.lock_voice(serial, voice_id, body.profile_id, now=_now_ms()))
store.audit("voice_lock", actor=_OPERATOR, target=f"{serial}/{voice_id}",
detail={"profile_id": body.profile_id}, now=_now_ms())
return {"voice_id": voice_id, "locked_profile_id": body.profile_id}
@app.post("/api/voices/{serial}/{voice_id}/unlock")
def voices_unlock(serial: str, voice_id: str):
_guard(lambda: store.unlock_voice(serial, voice_id, now=_now_ms()))
store.audit("voice_unlock", actor=_OPERATOR, target=f"{serial}/{voice_id}", now=_now_ms())
return {"voice_id": voice_id, "locked_profile_id": None}
@app.post("/api/voices/{serial}/sync")
def voices_sync(serial: str, body: _Sync):
return _guard(lambda: vo.sync_device(
adb, store, bridge, serial, delete_source=body.delete_source,
now=_now_ms(), actor=_OPERATOR))
@app.post("/api/voices/{serial}/undeploy")
def voices_undeploy(serial: str, body: _Deploy):
return _guard(lambda: vo.undeploy_voice(adb, store, serial, body.voice_id,
now=_now_ms(), actor=_OPERATOR))
# UI web locale servie à la racine — APRÈS les routes /api pour ne pas les
# masquer. `html=True` → sert index.html sur "/".

View File

@ -65,24 +65,33 @@ CREATE TABLE IF NOT EXISTS audit (
target TEXT,
detail TEXT -- métadonnée d'audit (pas de PII) → clair
);
-- Catalogue voix GLOBAL (flotte) : une voix = un asset central, PAS lié à une tablette.
-- La tablette d'enregistrement n'est qu'une métadonnée (origin_serial) ; le déploiement
-- est une relation séparée (voice_deployments).
CREATE TABLE IF NOT EXISTS voices (
serial TEXT NOT NULL,
voice_id TEXT NOT NULL,
transcription_enc BLOB, -- texte parlé (PII possible) chiffré
language TEXT,
source_wav_path TEXT, -- chemin device d'origine
wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/)
wav_sha256 TEXT, -- intégrité du WAV d'origine
voice_id TEXT PRIMARY KEY,
origin_serial TEXT, -- tablette d'enregistrement (métadonnée)
name TEXT,
transcription_enc BLOB, -- texte du .ovsp (ASR) chiffré
source_wav_path TEXT, -- chemin device d'origine du WAV
wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/)
wav_sha256 TEXT,
ovsp_enc BLOB, -- artefact .ovsp chiffré déployable sans -enrôler
ovsp_bytes INTEGER,
archived_at INTEGER, -- WAV récupéré + archivé sur le PC
enrolled_at INTEGER, -- .ovsp produit
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)
archived_at INTEGER, -- WAV récupéré + archivé sur le PC
enrolled_at INTEGER, -- .ovsp produit
wav_deleted_at INTEGER, -- WAV supprimé de la tablette d'origine
locked_profile_id TEXT, -- exclusivité (= owner_profile_id ; NULL = généraliste)
scope TEXT, -- exclusive | global | pending
owner_name TEXT,
consent_text_enc BLOB -- phrase de consentement (PII) chiffré
);
-- Déploiement many-to-many : quelles tablettes ont le .ovsp de quelle voix.
CREATE TABLE IF NOT EXISTS voice_deployments (
voice_id TEXT NOT NULL,
serial TEXT NOT NULL,
deployed_at INTEGER,
PRIMARY KEY (voice_id, serial)
);
"""
@ -114,16 +123,35 @@ class Store:
"""Migrations légères idempotentes (CREATE TABLE IF NOT EXISTS ne fait pas
évoluer une table déjà créée)."""
cols = {r["name"] for r in self._db.execute("PRAGMA table_info(voices)")}
if "locked_profile_id" not in cols:
self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT")
# 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}")
if "serial" in cols: # ANCIEN schéma per-tablette → mises à niveau puis refonte
if "locked_profile_id" not in cols:
self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT")
if "cvps_bytes" in cols and "ovsp_bytes" not in cols:
self._db.execute("ALTER TABLE voices RENAME COLUMN cvps_bytes TO ovsp_bytes")
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}")
self._rebuild_voices_global()
def _rebuild_voices_global(self) -> None:
"""Refonte (serial, voice_id) → catalogue GLOBAL clé voice_id + table
voice_deployments. Les fichiers WAV chiffrés gardent leur chemin relatif
(`<serial>/<id>.wav.enc`) pas de déplacement de fichier."""
db = self._db
db.execute("ALTER TABLE voices RENAME TO voices_old")
db.executescript(_SCHEMA) # recrée voices (nouveau) + voice_deployments
db.execute(
"INSERT OR IGNORE INTO voice_deployments(voice_id, serial, deployed_at) "
"SELECT voice_id, serial, deployed_at FROM voices_old WHERE deployed_at IS NOT NULL")
db.execute(
"INSERT OR IGNORE INTO voices(voice_id, origin_serial, transcription_enc, "
"source_wav_path, wav_file, wav_sha256, ovsp_bytes, archived_at, enrolled_at, "
"wav_deleted_at, locked_profile_id, scope, owner_name, consent_text_enc) "
"SELECT voice_id, serial, transcription_enc, source_wav_path, wav_file, "
"wav_sha256, ovsp_bytes, archived_at, enrolled_at, wav_deleted_at, "
"locked_profile_id, scope, owner_name, consent_text_enc FROM voices_old")
db.execute("DROP TABLE voices_old")
def close(self) -> None:
with self._lock:
@ -293,120 +321,142 @@ class Store:
d.mkdir(parents=True, exist_ok=True)
return d
def _voice_upsert(self, serial: str, voice_id: str, **cols: Any) -> None:
"""Upsert partiel d'une ligne voix (colonnes données seulement)."""
def _voice_upsert(self, voice_id: str, **cols: Any) -> None:
"""Upsert partiel d'une voix du catalogue global (colonnes données seulement)."""
keys = list(cols)
sets = ", ".join(f"{k}=excluded.{k}" for k in keys)
self._db.execute(
f"INSERT INTO voices(serial, voice_id, {', '.join(keys)}) "
f"VALUES(?, ?, {', '.join('?' for _ in keys)}) "
f"ON CONFLICT(serial, voice_id) DO UPDATE SET {sets}",
(serial, voice_id, *[cols[k] for k in keys]),
f"INSERT INTO voices(voice_id, {', '.join(keys)}) "
f"VALUES(?, {', '.join('?' for _ in keys)}) "
f"ON CONFLICT(voice_id) DO UPDATE SET {sets}",
(voice_id, *[cols[k] for k in keys]),
)
self._db.commit()
def archive_voice_wav(self, serial: str, voice_id: str, wav_bytes: bytes, *,
source_wav_path: str | None, now: int) -> str:
"""Chiffre + archive le WAV d'origine sur le PC (le « garder sur Kazeia-central »
une fois supprimé de la tablette). Renvoie le chemin du fichier chiffré."""
def archive_voice_wav(self, voice_id: str, wav_bytes: bytes, *, origin_serial: str | None,
source_wav_path: str | None, name: str | None = None, now: int) -> str:
"""Chiffre + archive le WAV d'origine sur le PC (asset global, pas lié à une
tablette). Renvoie le chemin du fichier chiffré."""
v = self._require_vault()
sha = hashlib.sha256(wav_bytes).hexdigest()
dest = self._voices_dir() / serial / f"{voice_id}.wav.enc"
dest.parent.mkdir(parents=True, exist_ok=True)
dest = self._voices_dir() / f"{voice_id}.wav.enc"
dest.write_bytes(v.seal_bytes(wav_bytes))
with self._lock:
self._voice_upsert(serial, voice_id, source_wav_path=source_wav_path,
self._voice_upsert(voice_id, origin_serial=origin_serial, name=name,
source_wav_path=source_wav_path,
wav_file=str(dest.relative_to(self._voices_dir())),
wav_sha256=sha, archived_at=now)
return str(dest)
def voice_wav_bytes(self, serial: str, voice_id: str) -> bytes | None:
def voice_wav_bytes(self, voice_id: str) -> bytes | None:
"""Relit le WAV archivé déchiffré (pour ré-enrôlement)."""
v = self._require_vault()
with self._lock:
row = self._db.execute(
"SELECT wav_file FROM voices WHERE serial=? AND voice_id=?",
(serial, voice_id)).fetchone()
"SELECT wav_file FROM voices WHERE voice_id=?", (voice_id,)).fetchone()
if not row or not row["wav_file"]:
return None
return v.open_bytes((self._voices_dir() / row["wav_file"]).read_bytes())
def set_voice_transcription(self, serial: str, voice_id: str, text: str,
language: str | None, *, now: int) -> None:
def set_voice_transcription(self, voice_id: str, text: str, *, now: int) -> None:
v = self._require_vault()
with self._lock:
self._voice_upsert(serial, voice_id, transcription_enc=v.seal(text),
language=language)
self._voice_upsert(voice_id, transcription_enc=v.seal(text))
def mark_voice_enrolled(self, serial: str, voice_id: str, ovsp_bytes: int, *, now: int) -> None:
def store_voice_ovsp(self, voice_id: str, ovsp_bytes_data: bytes, *, now: int) -> None:
"""Stocke l'artefact `.ovsp` chiffré côté central → déployable sur N tablettes
sans -enrôler. Marque la voix enrôlée."""
v = self._require_vault()
with self._lock:
self._voice_upsert(serial, voice_id, ovsp_bytes=ovsp_bytes, enrolled_at=now)
self._voice_upsert(voice_id, ovsp_enc=v.seal_bytes(ovsp_bytes_data),
ovsp_bytes=len(ovsp_bytes_data), enrolled_at=now)
def mark_voice_deployed(self, serial: str, voice_id: str, *, now: int) -> None:
def voice_ovsp_bytes(self, voice_id: str) -> bytes | None:
"""Relit l'artefact `.ovsp` déchiffré (pour déploiement)."""
v = self._require_vault()
with self._lock:
self._voice_upsert(serial, voice_id, deployed_at=now)
row = self._db.execute(
"SELECT ovsp_enc FROM voices WHERE voice_id=?", (voice_id,)).fetchone()
return v.open_bytes(row["ovsp_enc"]) if row and row["ovsp_enc"] else None
def mark_voice_wav_deleted(self, serial: str, voice_id: str, *, now: int) -> None:
def mark_voice_wav_deleted(self, voice_id: str, *, now: int) -> None:
with self._lock:
self._voice_upsert(serial, voice_id, wav_deleted_at=now)
self._voice_upsert(voice_id, wav_deleted_at=now)
def lock_voice(self, serial: str, voice_id: str, profile_id: str, *, now: int) -> None:
def lock_voice(self, voice_id: str, profile_id: str, *, now: int) -> None:
"""Assigne une voix exclusivement à un profil patient (verrou)."""
with self._lock:
self._voice_upsert(serial, voice_id, locked_profile_id=profile_id)
self._voice_upsert(voice_id, locked_profile_id=profile_id, scope="exclusive")
def unlock_voice(self, serial: str, voice_id: str, *, now: int) -> None:
def unlock_voice(self, voice_id: str, *, now: int) -> None:
"""Lève le verrou → voix redevient généraliste."""
with self._lock:
self._voice_upsert(serial, voice_id, locked_profile_id=None)
self._voice_upsert(voice_id, locked_profile_id=None, scope="global")
def set_voice_manifest(self, serial: str, voice_id: str, *, scope: str | None,
def set_voice_manifest(self, 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,
self._voice_upsert(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, voice_id: str) -> str | None:
with self._lock:
row = self._db.execute(
"SELECT locked_profile_id FROM voices WHERE serial=? AND voice_id=?",
(serial, voice_id)).fetchone()
"SELECT locked_profile_id FROM voices WHERE voice_id=?", (voice_id,)).fetchone()
return row["locked_profile_id"] if row else None
def voice_record(self, serial: str, voice_id: str) -> dict[str, Any] | None:
# ---- déploiements voix↔tablette (many-to-many) -----------------------
def add_deployment(self, voice_id: str, serial: str, *, now: int) -> None:
with self._lock:
self._db.execute(
"INSERT INTO voice_deployments(voice_id, serial, deployed_at) VALUES(?,?,?) "
"ON CONFLICT(voice_id, serial) DO UPDATE SET deployed_at=excluded.deployed_at",
(voice_id, serial, now))
self._db.commit()
def remove_deployment(self, voice_id: str, serial: str) -> None:
with self._lock:
self._db.execute("DELETE FROM voice_deployments WHERE voice_id=? AND serial=?",
(voice_id, serial))
self._db.commit()
def deployments_of(self, voice_id: str) -> list[str]:
with self._lock:
return [r["serial"] for r in self._db.execute(
"SELECT serial FROM voice_deployments WHERE voice_id=?", (voice_id,))]
def voices_on(self, serial: str) -> set[str]:
with self._lock:
return {r["voice_id"] for r in self._db.execute(
"SELECT voice_id FROM voice_deployments WHERE serial=?", (serial,))}
# ---- lecture catalogue (global) --------------------------------------
def _voice_dict(self, row, v) -> dict[str, Any]:
d = dict(row)
d["transcription"] = v.open(d.pop("transcription_enc", None))
d["consent_text"] = v.open(d.pop("consent_text_enc", None))
d.pop("ovsp_enc", None)
d["has_wav"] = bool(d.get("wav_file"))
d["has_ovsp"] = bool(d.get("ovsp_bytes"))
d["deployed_serials"] = self.deployments_of(d["voice_id"])
return d
def voice_record(self, voice_id: str) -> dict[str, Any] | None:
v = self._require_vault()
with self._lock:
row = self._db.execute(
"SELECT * FROM voices WHERE serial=? AND voice_id=?",
(serial, voice_id)).fetchone()
if not row:
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
"SELECT * FROM voices WHERE voice_id=?", (voice_id,)).fetchone()
return self._voice_dict(row, v) if row else None
def voices(self, serial: str | None = None) -> list[dict[str, Any]]:
def voices(self) -> list[dict[str, Any]]:
"""Catalogue voix GLOBAL (toutes tablettes confondues)."""
v = self._require_vault()
with self._lock:
q = "SELECT * FROM voices"
args: tuple = ()
if serial is not None:
q += " WHERE serial=?"
args = (serial,)
rows = self._db.execute(q + " ORDER BY serial, voice_id", args).fetchall()
out = []
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
rows = self._db.execute("SELECT * FROM voices ORDER BY voice_id").fetchall()
return [self._voice_dict(r, v) for r in rows]
# ---- audit (§8) -------------------------------------------------------
def audit(self, action: str, *, actor: str | None = None, target: str | None = None,

View File

@ -1,7 +1,7 @@
"""Déclencheur automatique d'enrôlement à la connexion d'une tablette.
Piloté depuis l'interface : démarre **désarmé** (`enabled=False`). Quand l'opérateur
l'arme, toute tablette qui se **connecte** déclenche un `sync_device` (enrôle +
l'arme, toute tablette qui se **connecte** déclenche un `sync_admin` (enrôle +
déploie les voix en attente, verrou-conscient). Statut par tablette consultable.
Sécurité : ne fait rien si le store est verrouillé (les PII restent protégées) ; la

View File

@ -1,16 +1,14 @@
"""Orchestration de l'enrôlement vocal (le workflow complet).
"""Orchestration vocale — catalogue GLOBAL + déploiement par tablette.
Compose : provider (`/voices` wav_path), transfert adb, bridge (worker ov_venv),
store chiffré. Tourne sous l'API py3.14 ; le calcul lourd est délégué au worker.
Modèle (refonte 23/06/2026) : une voix est un **asset de flotte** (catalogue central,
clé `voice_id`), PAS liée à la tablette d'enregistrement. La tablette n'est qu'une
**cible de déploiement** ; l'artefact `.ovsp` est stocké chiffré côté central et
poussé sur N tablettes sans -enrôler.
Workflow (VOICE_ENROLLMENT_SPEC §1, décisions 2026-06-19) :
connexion lister voix sans .ovsp pull WAV ARCHIVER (store chiffré)
transcrire (ASR OmniVoice, relecture opérateur) enrôler (.ovsp) pousser sur la
tablette SUPPRIMER le WAV de la tablette (gardé sur Kazeia-central).
Découpé pour permettre la relecture de la transcription : `prepare` (pull+archive+
transcribe) puis `enroll_deploy` (enroll+push+delete). Le WAV est archivé DÈS prepare
jamais de suppression device sans copie PC.
Source des voix fraîches : stockage ADMIN (`admin_ingest`, manifestes scope/owner/
reference_text). Politique : `global` déployable partout ; `exclusive` seulement
sur une tablette portant le profil propriétaire ; `pending` archivée, pas déployable.
Le `.ovsp` porte l'ASR du segment 16 s (§6) ; le consentement reste en métadonnée.
"""
from __future__ import annotations
@ -26,169 +24,6 @@ from ..store import Store
from . import admin_ingest, transfer
from .bridge import VoiceBridge
def _provider_voices(adb: Adb, serial: str):
return ProviderClient(adb, serial).voices()
def _find_wav_path(adb: Adb, serial: str, voice_id: str) -> str | None:
for v in _provider_voices(adb, serial):
if v.id == voice_id:
return v.wav_path
return None
def _status(deployed: bool, rec: dict) -> str:
if deployed:
return "deployed"
if rec.get("enrolled_at"):
return "enrolled"
if rec.get("archived_at"):
return "archived"
return "pending"
def _profiles_using(adb: Adb, serial: str) -> tuple[dict[str, list[str]], set[str]]:
"""Renvoie ({voice_id: [profil_id,...]}, {profils présents}). Croise profile.voice_id."""
used: dict[str, list[str]] = {}
present: set[str] = set()
try:
for p in ProviderClient(adb, serial).profiles():
present.add(p.id)
if p.voice_id:
used.setdefault(p.voice_id, []).append(p.id)
except Exception:
pass
return used, present
def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
"""Inventaire voix de la tablette croisé avec l'archive store (statut, verrou,
profils utilisateurs) par voix."""
rows = _provider_voices(adb, serial)
try:
deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial))
except Exception:
deployed = set()
recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {}
used_by, _ = _profiles_using(adb, serial)
out = []
for v in rows:
rec = recs.get(v.id, {})
is_dep = v.id in deployed
locked = rec.get("locked_profile_id")
out.append({
"voice_id": v.id,
"wav_path": v.wav_path,
"wav_on_device": v.wav_exists,
"deployed_on_device": is_dep,
"archived": bool(rec.get("archived_at")),
"enrolled": bool(rec.get("enrolled_at")),
"wav_deleted": bool(rec.get("wav_deleted_at")),
"transcription": rec.get("transcription"),
"language": rec.get("language"),
"locked_profile_id": locked, # exclusif à ce profil, ou None = généraliste
"used_by_profiles": used_by.get(v.id, []), # profils qui sélectionnent cette voix
"status": _status(is_dep, rec),
})
return out
def sync_device(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *,
delete_source: bool, now: int, actor: str | None = None) -> list[dict[str, Any]]:
"""Enrôle + déploie les voix en attente d'une tablette (rapport par voix).
Verrou-conscient : ne déploie pas une voix exclusive sur une tablette dépourvue
du profil propriétaire. Transcrit automatiquement si pas encore relu (la relecture
fine reste possible voix par voix via prepare/enroll)."""
_, present = _profiles_using(adb, serial)
report: list[dict[str, Any]] = []
for v in list_voices(adb, store, serial):
vid = v["voice_id"]
if v["deployed_on_device"]:
continue
lock = v["locked_profile_id"]
if lock and lock not in present:
report.append({"voice_id": vid, "ok": False,
"skipped": "verrou_profil_absent", "profile": lock})
continue
try:
text = v["transcription"]
if not text:
text = prepare(adb, store, bridge, serial, vid, now=now, actor=actor)["transcription"]
res = enroll_deploy(adb, store, bridge, serial, vid, text,
delete_source=delete_source, now=now, actor=actor)
report.append({"voice_id": vid, "ok": True, **res})
except Exception as e:
report.append({"voice_id": vid, "ok": False, "error": str(e)})
return report
def _ensure_wav_archived(adb: Adb, store: Store, serial: str, voice_id: str, *,
now: int) -> bytes:
"""Garantit que le WAV est archivé (chiffré) côté PC ; le renvoie déchiffré."""
data = store.voice_wav_bytes(serial, voice_id)
if data is not None:
return data
wav_path = _find_wav_path(adb, serial, voice_id)
if not wav_path:
raise ValueError(f"voix introuvable sur la tablette: {voice_id}")
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
transfer.pull_wav(adb, serial, wav_path, local)
data = open(local, "rb").read()
store.archive_voice_wav(serial, voice_id, data, source_wav_path=wav_path, now=now)
return data
def prepare(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *,
now: int, actor: str | None = None) -> dict[str, Any]:
"""Pull + archive (chiffré) + transcription (ASR OmniVoice). Renvoie le texte à relire."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
open(local, "wb").write(data)
tr = bridge.transcribe(local)
store.set_voice_transcription(serial, voice_id, tr["text"], tr.get("language"), now=now)
store.audit("voice_prepare", actor=actor, target=f"{serial}/{voice_id}", now=now)
return {"voice_id": voice_id, "transcription": tr["text"],
"language": tr.get("language"), "model": tr.get("model")}
def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str,
transcription: str, *, delete_source: bool, now: int,
actor: str | None = None) -> dict[str, Any]:
"""Enrôle (.ovsp) → pousse sur la tablette → (option) supprime le WAV device.
Le WAV reste archivé côté PC suppression device réversible par -enrôlement."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
wav_path = _find_wav_path(adb, serial, voice_id)
ov = _patient_omnivoice_dir(adb, serial)
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
open(local, "wb").write(data)
ovsp = os.path.join(td, f"{voice_id}.ovsp")
info = bridge.enroll(local, transcription, ovsp)
store.set_voice_transcription(serial, voice_id, transcription, None, now=now)
store.mark_voice_enrolled(serial, voice_id, info["bytes"], now=now)
remote = transfer.push_ovsp(adb, serial, ovsp, ov, voice_id)
store.mark_voice_deployed(serial, voice_id, now=now)
store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{voice_id}",
detail={"ovsp_bytes": info["bytes"]}, now=now)
result = {"voice_id": voice_id, "ovsp_bytes": info["bytes"],
"deployed_to": remote, "wav_deleted": False}
if delete_source and wav_path:
transfer.delete_device_wav(adb, serial, wav_path)
store.mark_voice_wav_deleted(serial, voice_id, now=now)
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",
@ -196,11 +31,20 @@ _CANDIDATE_MODELS_DIRS = (
)
def _provider_voices(adb: Adb, serial: str):
return ProviderClient(adb, serial).voices()
def _profiles_present(adb: Adb, serial: str) -> set[str]:
try:
return {p.id for p in ProviderClient(adb, serial).profiles()}
except Exception:
return set()
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."""
"""Dossier `.ovsp` cible (`<MODELS_DIR>/omnivoice/voices`). Post-migration
`/voices.wav_path` est VIDE cascade : env wav_path legacy sonde device."""
env = os.environ.get("KAZEIA_OMNIVOICE_DIR")
if env:
return env
@ -210,14 +54,109 @@ def _patient_omnivoice_dir(adb: Adb, serial: str) -> str:
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)")
raise ValueError("dossier omnivoice indéterminable (définir KAZEIA_OMNIVOICE_DIR)")
def _deployable(rec: dict, present: set[str]) -> tuple[bool, str | None]:
"""Une voix du catalogue est-elle déployable sur une tablette ? `global` partout ;
`exclusive` seulement si le profil propriétaire est présent ; `pending` jamais."""
if rec.get("scope") == "pending":
return False, "pending"
lock = rec.get("locked_profile_id")
if lock and lock not in present:
return False, "verrou_profil_absent"
return True, None
# ===== Catalogue global =====================================================
def catalog(store: Store) -> list[dict[str, Any]]:
"""Catalogue voix GLOBAL (toutes tablettes confondues) — vue sans sélection."""
return store.voices() if store.is_unlocked else []
# ===== Enrôlement (asset central) ===========================================
def _enroll_into_store(store: Store, bridge: VoiceBridge, voice_id: str, *,
now: int, text: str = "") -> dict:
"""Enrôle depuis le WAV archivé → stocke le `.ovsp` chiffré au catalogue.
`text` vide ASR auto du segment 16 s (§6)."""
data = store.voice_wav_bytes(voice_id)
if data is None:
raise ValueError(f"WAV non archivé pour {voice_id}")
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
open(local, "wb").write(data)
ovsp = os.path.join(td, f"{voice_id}.ovsp")
info = bridge.enroll(local, text, ovsp)
store.set_voice_transcription(voice_id, info.get("ref_text", ""), now=now)
store.store_voice_ovsp(voice_id, open(ovsp, "rb").read(), now=now)
return info
# ===== Déploiement par tablette =============================================
def deploy_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *,
now: int, actor: str | None = None) -> dict[str, Any]:
"""Déploie une voix du catalogue sur une tablette : enrôle si besoin (1ʳᵉ fois),
pousse le `.ovsp` stocké, enregistre le déploiement. Respecte l'exclusivité."""
rec = store.voice_record(voice_id)
if not rec:
raise ValueError(f"voix inconnue au catalogue: {voice_id}")
ok, reason = _deployable(rec, _profiles_present(adb, serial))
if not ok:
return {"voice_id": voice_id, "deployed": False, "reason": reason}
if not store.voice_ovsp_bytes(voice_id):
_enroll_into_store(store, bridge, voice_id, now=now)
ovsp_bytes = store.voice_ovsp_bytes(voice_id)
ov = _patient_omnivoice_dir(adb, serial)
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.ovsp")
open(local, "wb").write(ovsp_bytes)
remote = transfer.push_ovsp(adb, serial, local, ov, voice_id)
store.add_deployment(voice_id, serial, now=now)
store.audit("voice_deploy", actor=actor, target=f"{serial}/{voice_id}", now=now)
return {"voice_id": voice_id, "deployed": True, "deployed_to": remote}
def undeploy_voice(adb: Adb, store: Store, serial: str, voice_id: str, *,
now: int, actor: str | None = None) -> dict[str, Any]:
"""Retire le `.ovsp` d'une tablette + ôte le déploiement (la voix reste au catalogue)."""
ov = _patient_omnivoice_dir(adb, serial)
adb.shell(f"rm -f {posixpath.join(ov, voice_id + '.ovsp')}", serial=serial)
store.remove_deployment(voice_id, serial)
store.audit("voice_undeploy", actor=actor, target=f"{serial}/{voice_id}", now=now)
return {"voice_id": voice_id, "deployed": False}
def list_for_tablet(adb: Adb, store: Store, serial: str) -> dict[str, Any]:
"""Vue d'une tablette : voix **chargées** (sur le device) et voix **chargeables**
(catalogue non déployé, filtré par exclusivité)."""
try:
on_device = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial))
except Exception:
on_device = set()
present = _profiles_present(adb, serial)
cat = store.voices() if store.is_unlocked else []
cat_ids = {r["voice_id"] for r in cat}
deployed, deployable = [], []
for rec in cat:
vid = rec["voice_id"]
base = {"voice_id": vid, "name": rec.get("name"), "scope": rec.get("scope"),
"locked_profile_id": rec.get("locked_profile_id"), "owner_name": rec.get("owner_name")}
if vid in on_device or serial in rec.get("deployed_serials", []):
deployed.append({**base, "on_device": vid in on_device, "in_catalog": True})
else:
ok, reason = _deployable(rec, present)
deployable.append({**base, "deployable": ok, "reason": reason})
# Voix présentes sur le device mais hors catalogue (legacy/externe).
for vid in sorted(on_device - cat_ids):
deployed.append({"voice_id": vid, "name": None, "scope": None, "on_device": True,
"in_catalog": False})
return {"serial": serial, "deployed": deployed, "deployable": deployable}
# ===== Ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) =====
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
return "pending"
if deployed:
return "deployed"
if rec.get("enrolled_at"):
@ -228,10 +167,9 @@ def _admin_status(man: dict, rec: dict, deployed: bool) -> str:
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."""
"""Voix enregistrées via l'admin (manifestes) croisées avec le catalogue global."""
manifests = admin_ingest.list_manifests(adb, serial)
recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {}
recs = {r["voice_id"]: r for r in store.voices()} if store.is_unlocked else {}
try:
deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial))
except Exception:
@ -252,64 +190,39 @@ def list_admin_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any
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)."""
"""Ingère UNE voix admin au **catalogue global** : pull WAV → archive chiffrée
(origin=cette tablette) métadonnées (scope/owner/consentement) verrou si
exclusive enrôle + déploie sur la tablette d'origine (selon scope)."""
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:
if store.voice_wav_bytes(vid) 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,
store.archive_voice_wav(vid, data, origin_serial=serial, name=manifest.get("name"),
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"),
store.set_voice_manifest(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.lock_voice(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:
res = deploy_voice(adb, store, bridge, serial, vid, now=now, actor=actor)
out = {"voice_id": vid, "scope": scope, "deployed": res.get("deployed", False),
"reason": res.get("reason"), "deployed_to": res.get("deployed_to"), "wav_deleted": False}
if delete_source and res.get("deployed"):
admin_ingest.delete_admin_voice(adb, serial, manifest)
store.mark_voice_wav_deleted(serial, vid, now=now)
store.mark_voice_wav_deleted(vid, now=now)
store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{vid}", now=now)
result["wav_deleted"] = True
return result
out["wav_deleted"] = True
return out
def sync_admin(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *,

View File

@ -260,25 +260,41 @@ async function selectDevice(serial) {
d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`;
}
// ---- panneau voix (enrôlement OmniVoice) ---------------------------------
// ---- panneau voix : catalogue GLOBAL (flotte) + déploiement par tablette ----
const SCOPE_COLOR = { exclusive:"var(--warn)", global:"var(--ok)", pending:"var(--muted)" };
function scopeBadge(s) {
return `<span class="badge" style="color:${SCOPE_COLOR[s]||"var(--muted)"};border-color:${SCOPE_COLOR[s]||"var(--line)"}">${s||"?"}</span>`;
}
async function voiceView() {
if (!selected) { $("detail").innerHTML = `<div id="placeholder">← sélectionne d'abord une tablette</div>`; return; }
if (!unlocked) { $("detail").innerHTML = `<div class="card"><h3>Voix</h3><div class="err">Déverrouille le store (colonne de gauche) pour gérer les voix.</div></div>`; return; }
const serial = selected;
const serial = selected; // peut être null → vue catalogue seule
const d = $("detail");
d.innerHTML = `<div class="muted">chargement des voix de <b>${serial}</b></div>`;
const [auto, admin, deployed] = await Promise.all([
api("/voices/autosync").catch(e=>({error:e.message})),
api(`/voices/${serial}/admin`).catch(e=>({error:e.message})),
api(`/voices/${serial}`).catch(e=>({error:e.message})),
]);
d.innerHTML = `<div class="muted">chargement du catalogue voix…</div>`;
const reqs = [api("/voices/catalog").catch(e=>({error:e.message})),
api("/voices/autosync").catch(e=>({error:e.message}))];
if (serial) reqs.push(api(`/voices/${serial}`).catch(e=>({error:e.message})),
api(`/voices/${serial}/admin`).catch(e=>({error:e.message})));
const [cat, auto, tablet, admin] = await Promise.all(reqs);
// 1) déclencheur auto
// 1) catalogue global (toutes tablettes) — visible SANS sélectionner de tablette
const cv = Array.isArray(cat) ? cat : [];
const catCard = card(`Catalogue voix — flotte (${cv.length})`,
cat.error ? `<div class="err">${cat.error}</div>` :
(cv.length ? `<table>
<tr><th>voix</th><th>portée</th><th>verrou</th><th>déployée sur</th><th></th></tr>
${cv.map(v=>`<tr>
<td><b>${v.name||v.voice_id}</b><div class="serial">${v.voice_id}</div></td>
<td>${scopeBadge(v.scope)}</td>
<td>${v.locked_profile_id?`🔒 ${v.owner_name||v.locked_profile_id}`:"<span class='muted'>généraliste</span>"}</td>
<td class="muted">${(v.deployed_serials||[]).length?(v.deployed_serials).join(", "):"—"}</td>
<td>${v.locked_profile_id
? `<button onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>`
: `<button onclick="lockVoice('${v.voice_id}')">verrouiller…</button>`}</td>
</tr>`).join("")}
</table>` : `<span class="muted">catalogue vide — enregistre des voix via l'app admin puis synchronise</span>`), "full");
// 2) déclencheur auto
const aStat = auto?.devices?.[serial];
const autoCard = card("Déclencheur automatique",
auto.error ? `<div class="err">${auto.error}</div>` :
@ -286,48 +302,41 @@ async function voiceView() {
<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:8px">
<button onclick="toggleAutosync(${!auto.enabled})">${auto.enabled?"Désarmer":"Armer"}</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="auto_del" ${auto.delete_source?"checked":""}> supprimer le WAV device après</label>
</div>
<div class="muted" style="font-size:12px;margin-top:6px">À l'armement, toute tablette qui se (re)connecte est enrôlée automatiquement.
${aStat?`<br>Dernière sync ${serial} : <b>${aStat.state}</b>${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}`:""}</div>`);
</div>${aStat?`<div class="muted" style="font-size:12px;margin-top:6px">Dernière sync ${serial} : <b>${aStat.state}</b>${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}</div>`:""}`);
// 2) voix admin à enrôler
const av = Array.isArray(admin) ? admin : [];
const adminCard = card(`Voix enregistrées — admin (${av.length})`,
admin.error ? `<div class="err">${admin.error}</div>` :
(av.length ? `<table>
<tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th><th>consentement</th></tr>
${av.map(v=>`<tr>
<td><b>${v.name||v.voice_id}</b><div class="serial">${v.voice_id}</div></td>
<td>${scopeBadge(v.scope)}</td>
<td>${v.owner_name||v.owner_profile_id||"—"}</td>
<td>${v.status}${v.deployed_on_device?" ✓":""}</td>
<td class="muted" title="${(v.reference_text||"").replace(/"/g,"&quot;")}">${(v.reference_text||"—").slice(0,40)}${(v.reference_text||"").length>40?"…":""}</td>
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix enregistrée côté admin</span>`) +
`<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:10px">
<button id="syncbtn" onclick="syncAdmin('${serial}')">Synchroniser (enrôler + déployer)</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label>
<span id="syncmsg" class="muted"></span>
</div>`, "full");
let cards = catCard + autoCard;
// 3) voix déployées (verrou)
const dv = Array.isArray(deployed) ? deployed : [];
const depCard = card(`Voix déployées sur la tablette (${dv.filter(v=>v.deployed_on_device).length})`,
deployed.error ? `<div class="err">${deployed.error}</div>` :
(dv.length ? `<table>
<tr><th>voix</th><th>statut</th><th>verrou</th><th>profils</th><th></th></tr>
${dv.map(v=>`<tr>
<td>${v.voice_id}</td>
<td>${v.deployed_on_device?dot(true)+"déployée":v.status}</td>
<td>${v.locked_profile_id?`🔒 ${v.locked_profile_id}`:"<span class='muted'>généraliste</span>"}</td>
<td class="muted">${(v.used_by_profiles||[]).join(", ")||"—"}</td>
<td>${v.locked_profile_id
? `<button onclick="unlockVoice('${serial}','${v.voice_id}')">déverrouiller</button>`
: `<button onclick="lockVoice('${serial}','${v.voice_id}')">verrouiller…</button>`}</td>
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix</span>`), "full");
if (!serial) {
cards += card("Par tablette", `<span class="muted">Sélectionne une tablette (colonne de gauche), puis reclique 🎙 Voix : tu verras ce qui y est chargé et les voix chargeables dessus.</span>`, "full");
} else {
// 3) voix admin à enrôler sur cette tablette
const av = Array.isArray(admin) ? admin : [];
const adminCard = card(`Voix admin à enrôler — ${serial} (${av.length})`,
admin.error ? `<div class="err">${admin.error}</div>` :
(av.length ? `<table><tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th></tr>
${av.map(v=>`<tr><td><b>${v.name||v.voice_id}</b></td><td>${scopeBadge(v.scope)}</td><td>${v.owner_name||v.owner_profile_id||"—"}</td><td>${v.status}</td></tr>`).join("")}
</table>` : `<span class="muted">aucune voix admin en attente</span>`) +
`<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:10px">
<button id="syncbtn" onclick="syncAdmin('${serial}')">Synchroniser (enrôler + déployer)</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label>
<span id="syncmsg" class="muted"></span></div>`, "full");
d.innerHTML = `<div class="grid">${autoCard}${adminCard}${depCard}</div>`;
// 4) tablette : voix chargées + chargeables
const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || [];
const tabCard = card(`Tablette ${serial} — chargées (${loaded.length}) · chargeables (${loadable.filter(v=>v.deployable).length})`,
tablet.error ? `<div class="err">${tablet.error}</div>` :
`<table><tr><th>voix</th><th>portée</th><th>état</th><th></th></tr>
${loaded.map(v=>`<tr><td>${dot(true)}${v.name||v.voice_id}${v.in_catalog===false?" <span class='muted'>(hors catalogue)</span>":""}</td>
<td>${v.scope?scopeBadge(v.scope):"—"}</td><td>chargée</td>
<td>${v.in_catalog!==false?`<button onclick="undeployVoice('${serial}','${v.voice_id}')">retirer</button>`:""}</td></tr>`).join("")}
${loadable.map(v=>`<tr><td class="muted">${v.name||v.voice_id}</td><td>${scopeBadge(v.scope)}</td>
<td class="muted">${v.deployable?"chargeable":(v.reason||"—")}</td>
<td>${v.deployable?`<button onclick="deployVoice('${serial}','${v.voice_id}')">charger</button>`:""}</td></tr>`).join("")}
</table>`, "full");
cards += adminCard + tabCard;
}
d.innerHTML = `<div class="grid">${cards}</div>`;
}
async function toggleAutosync(on) {
@ -344,22 +353,39 @@ async function syncAdmin(serial) {
try {
const rep = await api(`/voices/${serial}/admin/sync`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({delete_source:del})});
const ok = rep.filter(r=>r.ok && r.deployed).length;
msg.textContent = `${ok}/${rep.length} voix déployée(s).`;
msg.textContent = `${rep.filter(r=>r.ok && r.deployed).length}/${rep.length} voix déployée(s).`;
await voiceView();
} catch (e) { msg.textContent = "erreur : " + e.message; btn.disabled = false; }
}
async function lockVoice(serial, vid) {
async function deployVoice(serial, vid) {
const m = document.createElement("div"); m.className = "muted";
m.textContent = `déploiement de ${vid} sur ${serial}… (1ʳᵉ fois : enrôlement ~1 min)`;
$("detail").prepend(m);
try {
const r = await api(`/voices/${serial}/deploy`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({voice_id:vid})});
if (!r.deployed) alert("non chargée : " + (r.reason || ""));
await voiceView();
} catch (e) { alert("erreur : " + e.message); }
}
async function undeployVoice(serial, vid) {
await api(`/voices/${serial}/undeploy`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({voice_id:vid})});
voiceView();
}
async function lockVoice(vid) {
const pid = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`);
if (!pid) return;
await api(`/voices/${serial}/${vid}/lock`, {method:"POST",
await api(`/voices/catalog/${vid}/lock`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({profile_id:pid})});
voiceView();
}
async function unlockVoice(serial, vid) {
await api(`/voices/${serial}/${vid}/unlock`, {method:"POST"});
async function unlockVoice(vid) {
await api(`/voices/catalog/${vid}/unlock`, {method:"POST"});
voiceView();
}

View File

@ -1,20 +1,28 @@
"""Tests du sous-système voix : dérivation chemins, archive store, orchestration.
"""Tests du sous-système voix — modèle GLOBAL (catalogue flotte + déploiement).
Le moteur lourd (OmniVoice) n'est PAS testé ici (validé live) — on injecte un faux
bridge et un faux adb pour prouver la COMPOSITION (workflow) sans le modèle.
Le moteur lourd (OmniVoice) n'est PAS testé ici (validé live) — faux bridge + faux adb
pour prouver la COMPOSITION (catalogue, déploiement scope-conscient, ingestion).
"""
import json as _json
import os as _os
import tempfile
import nacl.pwhash
from kazeia_central.adb import Adb
from kazeia_central.adb.client import AdbDevice
from kazeia_central.store import Store
from kazeia_central.voice import admin_ingest
from kazeia_central.voice import orchestrator as vo
from kazeia_central.voice import transfer
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN
NOW = 1_700_000_000_000
_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 _store(tmp_path):
@ -23,208 +31,10 @@ def _store(tmp_path):
return s
# ---- dérivation des chemins device ---------------------------------------
def test_omnivoice_dir_marker():
wp = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav"
assert transfer.omnivoice_dir_for(wp) == "/data/local/tmp/kazeia/models/omnivoice/voices"
def test_omnivoice_dir_fallback_prod():
wp = "/sdcard/Android/data/com.kazeia/files/kazeia/voix/voix/amir.wav"
assert transfer.omnivoice_dir_for(wp) == \
"/sdcard/Android/data/com.kazeia/files/kazeia/models/omnivoice/voices"
# ---- archive voix dans le store ------------------------------------------
def test_voice_wav_archive_roundtrip_and_encrypted(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"FAKEWAVDATA-amir", source_wav_path="/dev/amir.wav", now=NOW)
assert s.voice_wav_bytes("D1", "amir") == b"FAKEWAVDATA-amir"
# chiffré au repos : le blob brut ne contient pas le contenu
enc = (tmp_path / "voices" / "D1" / "amir.wav.enc").read_bytes()
assert b"FAKEWAVDATA" not in enc
rec = s.voice_record("D1", "amir")
assert rec["has_wav"] and rec["wav_sha256"] and rec["archived_at"] == NOW
def test_voice_transcription_encrypted(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW)
s.set_voice_transcription("D1", "amir", "texte parlé confidentiel", "fr", now=NOW)
assert s.voice_record("D1", "amir")["transcription"] == "texte parlé confidentiel"
raw = (tmp_path / "c.db").read_bytes()
assert b"texte parl" not in raw # PII jamais en clair en base
def test_voice_status_marks(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW)
s.mark_voice_enrolled("D1", "amir", 242000, now=NOW)
s.mark_voice_deployed("D1", "amir", now=NOW)
r = s.voice_record("D1", "amir")
assert r["ovsp_bytes"] == 242000 and r["enrolled_at"] and r["deployed_at"]
# ---- orchestration (faux adb + faux bridge) ------------------------------
class _VoiceAdb(Adb):
def __init__(self, voices, deployed=(), profiles=()):
super().__init__()
self._voices = voices # [(id, wav_path, wav_exists)]
self._deployed = list(deployed)
self._profiles = list(profiles) # [(profile_id, voice_id)]
self.pushed = []
self.deleted = []
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": i, "wav_path": w, "wav_exists": 1} for i, w, _ in self._voices]
if path == "profiles":
return [{"id": pid, "voice_id": vid} for pid, vid in self._profiles]
return []
def shell(self, command, *, serial=None, timeout=None):
if command.startswith("ls "):
return "\n".join(f"{d}.ovsp" for d in self._deployed)
if "rm -f" in command:
self.deleted.append(command.split("rm -f ")[1].split(" &&")[0])
return "OK"
return ""
def pull(self, remote, local, *, serial=None):
open(local, "wb").write(b"WAV:" + remote.encode())
def push(self, local, remote, *, serial=None):
self.pushed.append((open(local, "rb").read(), remote))
class _FakeBridge:
def transcribe(self, wav, model=None):
return {"text": "bonjour ceci est ma voix", "language": "fr", "model": "small"}
def enroll(self, wav, text, out):
open(out, "wb").write(b"OVSP" + text.encode()[:5])
return {"bytes": 242000, "t_ref": 364, "ref_seconds": 14.6, "text_bytes": len(text)}
_WP = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav"
def test_list_voices_status(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1), ("damien",
"/data/local/tmp/kazeia/models/../voix/voix/damien.wav", 1)],
deployed=["damien"])
out = {v["voice_id"]: v for v in vo.list_voices(adb, s, "D1")}
assert out["damien"]["status"] == "deployed"
assert out["amir"]["status"] == "pending"
def test_prepare_archives_and_transcribes(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)])
res = vo.prepare(adb, s, _FakeBridge(), "D1", "amir", now=NOW)
assert res["transcription"].startswith("bonjour")
# WAV archivé (chiffré) + transcription posée
assert s.voice_wav_bytes("D1", "amir") == b"WAV:" + _WP.encode()
assert s.voice_record("D1", "amir")["transcription"].startswith("bonjour")
def test_enroll_deploy_pushes_and_optional_delete(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)])
# sans suppression
r = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix",
delete_source=False, now=NOW)
assert r["ovsp_bytes"] == 242000 and r["wav_deleted"] is False
assert adb.pushed and adb.pushed[0][1] == \
"/data/local/tmp/kazeia/models/omnivoice/voices/amir.ovsp"
assert s.voice_record("D1", "amir")["deployed_at"] == NOW
assert adb.deleted == []
# avec suppression
r2 = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix",
delete_source=True, now=NOW)
assert r2["wav_deleted"] is True and adb.deleted == [_WP]
assert s.voice_record("D1", "amir")["wav_deleted_at"] == NOW
# ---- verrou voix ↔ profil (assignation exclusive + généralistes) ---------
def test_lock_unlock_voice(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW)
s.lock_voice("D1", "amir", "patient_07", now=NOW)
assert s.voice_record("D1", "amir")["locked_profile_id"] == "patient_07"
s.unlock_voice("D1", "amir", now=NOW)
assert s.voice_record("D1", "amir")["locked_profile_id"] is None
def test_list_voices_shows_lock_and_users(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)], profiles=[("p1", "amir"), ("p2", "amir")])
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=_WP, now=NOW)
s.lock_voice("D1", "amir", "p1", now=NOW)
v = vo.list_voices(adb, s, "D1")[0]
assert v["locked_profile_id"] == "p1"
assert set(v["used_by_profiles"]) == {"p1", "p2"}
def test_sync_skips_voice_locked_to_absent_profile(tmp_path):
s = _store(tmp_path)
# voix verrouillée sur un profil ABSENT de la tablette → non déployée
adb = _VoiceAdb([("amir", _WP, 1)], profiles=[("p_present", "x")])
s.archive_voice_wav("D1", "amir", b"WAV", source_wav_path=_WP, now=NOW)
s.lock_voice("D1", "amir", "p_absent", now=NOW)
rep = vo.sync_device(adb, s, _FakeBridge(), "D1", delete_source=False, now=NOW)
assert rep[0]["ok"] is False and rep[0]["skipped"] == "verrou_profil_absent"
assert adb.pushed == [] # rien poussé
def test_sync_deploys_general_voice(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)]) # généraliste (non verrouillée)
rep = vo.sync_device(adb, s, _FakeBridge(), "D1", delete_source=False, now=NOW)
assert rep[0]["ok"] is True and adb.pushed
assert s.voice_record("D1", "amir")["deployed_at"] == NOW
# ---- déclencheur auto -----------------------------------------------------
def test_autosync_blocked_when_store_locked(tmp_path):
from kazeia_central.store import Store
from kazeia_central.voice.autosync import VoiceAutoSync
locked_store = Store(tmp_path / "locked.db", kdf_ops=_OPS, kdf_mem=_MEM) # PAS unlock
a = VoiceAutoSync(_VoiceAdb([("amir", _WP, 1)]), locked_store, _FakeBridge())
st = a.sync_now("D1")
assert st["state"] == "store_verrouille"
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(_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}",
"reference_text": f"Consentement de {vid} — SECRET-{vid}",
"source_device": "tablet-admin-recording", "scope": scope}
if owner:
m["owner_profile_id"] = owner
@ -232,8 +42,9 @@ def _manifest(vid, scope, owner=None):
return m
class _AdminAdb(Adb):
def __init__(self, manifests, profiles=(), deployed=()):
class _Adb(Adb):
"""Faux adb : manifestes admin, profils, dossier omnivoice (déployés), push/rm."""
def __init__(self, manifests=(), profiles=(), deployed=()):
super().__init__()
self._manifests = {m["id"]: m for m in manifests}
self._profiles = list(profiles)
@ -254,7 +65,7 @@ class _AdminAdb(Adb):
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)
if command.startswith("ls "):
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])
@ -272,76 +83,159 @@ class _AdminAdb(Adb):
self.pushed.append((open(local, "rb").read(), remote))
class _BridgeASR:
class _Bridge:
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"}
open(out, "wb").write(b"OVSP-bytes")
return {"bytes": 9, "t_ref": 62, "ref_text": "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
# ---- chemins -------------------------------------------------------------
def test_omnivoice_dir_marker():
assert transfer.omnivoice_dir_for("/data/local/tmp/kazeia/models/../voix/voix/a.wav") \
== "/data/local/tmp/kazeia/models/omnivoice/voices"
def test_set_voice_manifest_consent_encrypted(tmp_path):
# ---- store : catalogue global + déploiements -----------------------------
def test_store_global_catalog_and_deployments(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"
s.archive_voice_wav("marie_1", b"WAV", origin_serial="DA", source_wav_path="/x", name="Marie", now=NOW)
s.store_voice_ovsp("marie_1", b"OVSP", now=NOW)
s.lock_voice("marie_1", "p1", now=NOW)
assert s.voice_wav_bytes("marie_1") == b"WAV" and s.voice_ovsp_bytes("marie_1") == b"OVSP"
s.add_deployment("marie_1", "DA", now=NOW)
s.add_deployment("marie_1", "DB", now=NOW)
rec = s.voice_record("marie_1")
assert rec["origin_serial"] == "DA" and rec["locked_profile_id"] == "p1"
assert rec["scope"] == "exclusive" and sorted(rec["deployed_serials"]) == ["DA", "DB"]
assert s.voices_on("DB") == {"marie_1"}
s.remove_deployment("marie_1", "DB")
assert s.deployments_of("marie_1") == ["DA"]
def test_store_ovsp_encrypted_at_rest(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("v", b"x", origin_serial="DA", source_wav_path=None, now=NOW)
s.set_voice_manifest("v", scope="exclusive", owner_name="P", consent_text="SECRET-consent", now=NOW)
raw = (tmp_path / "c.db").read_bytes()
assert b"SECRET-marie" not in raw # consentement (PII) chiffré au repos
assert b"SECRET-consent" not in raw # consentement chiffré
def test_ingest_pending_archives_no_deploy(tmp_path):
# ---- politique de déployabilité ------------------------------------------
def test_deployable_policy():
assert vo._deployable({"scope": "global"}, set()) == (True, None)
assert vo._deployable({"scope": "pending"}, {"p1"}) == (False, "pending")
assert vo._deployable({"scope": "exclusive", "locked_profile_id": "p1"}, {"p1"}) == (True, None)
ok, reason = vo._deployable({"scope": "exclusive", "locked_profile_id": "p1"}, {"p2"})
assert ok is False and reason == "verrou_profil_absent"
# ---- déploiement par tablette --------------------------------------------
def test_deploy_voice_pushes_and_records(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"
s.archive_voice_wav("paul", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW)
s.store_voice_ovsp("paul", b"OVSP", now=NOW)
s.set_voice_manifest("paul", scope="global", owner_name=None, consent_text=None, now=NOW)
adb = _Adb()
r = vo.deploy_voice(adb, s, _Bridge(), "D1", "paul", now=NOW)
assert r["deployed"] and adb.pushed[0][1] == _os.path.join(_OVDIR, "paul.ovsp")
assert "D1" in s.deployments_of("paul")
def test_ingest_global_deploys(tmp_path):
def test_deploy_voice_enrolls_if_no_ovsp(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
s.archive_voice_wav("paul", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW)
s.set_voice_manifest("paul", scope="global", owner_name=None, consent_text=None, now=NOW)
adb = _Adb()
r = vo.deploy_voice(adb, s, _Bridge(), "D1", "paul", now=NOW) # pas d'ovsp → enrôle
assert r["deployed"] and s.voice_ovsp_bytes("paul") == b"OVSP-bytes"
def test_ingest_exclusive_locks_and_deploys(tmp_path):
def test_deploy_voice_refuses_exclusive_absent_owner(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)
s.archive_voice_wav("marie", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW)
s.store_voice_ovsp("marie", b"OVSP", now=NOW)
s.lock_voice("marie", "p_absent", now=NOW)
adb = _Adb(profiles=["p1"])
r = vo.deploy_voice(adb, s, _Bridge(), "D1", "marie", 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):
def test_undeploy_voice(tmp_path):
s = _store(tmp_path)
adb = _AdminAdb([_manifest("paul", "global")])
rep = vo.sync_admin(adb, s, _BridgeASR(), "D1", delete_source=True, now=NOW)
s.archive_voice_wav("paul", b"x", origin_serial="D1", source_wav_path=None, now=NOW)
s.add_deployment("paul", "D1", now=NOW)
adb = _Adb()
vo.undeploy_voice(adb, s, "D1", "paul", now=NOW)
assert s.deployments_of("paul") == []
assert any("paul.ovsp" in r for r in adb.removed)
def test_list_for_tablet_split(tmp_path):
s = _store(tmp_path)
# paul déployé sur D1 ; marie exclusive p2 (absente) ; léa globale non déployée
for vid, scope in (("paul", "global"), ("lea", "global")):
s.archive_voice_wav(vid, b"x", origin_serial="D1", source_wav_path=None, now=NOW)
s.set_voice_manifest(vid, scope=scope, owner_name=None, consent_text=None, now=NOW)
s.archive_voice_wav("marie", b"x", origin_serial="D1", source_wav_path=None, now=NOW)
s.lock_voice("marie", "p2", now=NOW)
adb = _Adb(profiles=["p1"], deployed=["paul"])
view = vo.list_for_tablet(adb, s, "D1")
dep = {v["voice_id"] for v in view["deployed"]}
able = {v["voice_id"]: v for v in view["deployable"]}
assert "paul" in dep
assert able["lea"]["deployable"] is True
assert able["marie"]["deployable"] is False and able["marie"]["reason"] == "verrou_profil_absent"
# ---- ingestion admin → catalogue global ----------------------------------
def test_ingest_global_archives_and_deploys(tmp_path):
s = _store(tmp_path)
adb = _Adb([_manifest("paul", "global")])
man = admin_ingest.list_manifests(adb, "D1")[0]
r = vo.ingest_voice(adb, s, _Bridge(), "D1", man, delete_source=False, now=NOW)
assert r["deployed"] and r["scope"] == "global"
rec = s.voice_record("paul")
assert rec["origin_serial"] == "D1" and "D1" in rec["deployed_serials"]
def test_ingest_pending_no_deploy(tmp_path):
s = _store(tmp_path)
adb = _Adb([_manifest("anon", "pending")])
man = admin_ingest.list_manifests(adb, "D1")[0]
r = vo.ingest_voice(adb, s, _Bridge(), "D1", man, delete_source=False, now=NOW)
assert r["deployed"] is False and r["reason"] == "pending"
assert adb.pushed == [] and s.voice_record("anon")["archived_at"] == NOW
def test_ingest_exclusive_locks(tmp_path):
s = _store(tmp_path)
adb = _Adb([_manifest("marie", "exclusive", owner="p1")], profiles=["p1"])
man = admin_ingest.list_manifests(adb, "D1")[0]
r = vo.ingest_voice(adb, s, _Bridge(), "D1", man, delete_source=False, now=NOW)
assert r["deployed"] and s.voice_record("marie")["locked_profile_id"] == "p1"
def test_sync_admin_delete_cleans(tmp_path):
s = _store(tmp_path)
adb = _Adb([_manifest("paul", "global")])
rep = vo.sync_admin(adb, s, _Bridge(), "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)
# ---- déclencheur auto -----------------------------------------------------
def test_autosync_blocked_when_locked(tmp_path):
from kazeia_central.voice.autosync import VoiceAutoSync
locked = Store(tmp_path / "l.db", kdf_ops=_OPS, kdf_mem=_MEM) # pas unlock
a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), locked, _Bridge())
assert a.sync_now("D1")["state"] == "store_verrouille"
def test_autosync_enabled_ingests(tmp_path):
from kazeia_central.voice.autosync import VoiceAutoSync
s = _store(tmp_path)
a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), s, _Bridge())
a.set_enabled(True, delete_source=False)
st = a.sync_now("D1")
assert st["state"] == "termine" and st["enrolled"] == 1