Kazeia-central/kazeia_central/features/technical/voices/orchestrator.py

266 lines
13 KiB
Python

"""Orchestration vocale — catalogue GLOBAL + déploiement par tablette.
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 ré-enrôler.
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
import os
import posixpath
import tempfile
from typing import Any
from kazeia_central.core.adb import Adb
from kazeia_central.core.provider import ProviderClient
from kazeia_central.core.store import Store
from . import admin_ingest, transfer
from .bridge import VoiceBridge
# 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 _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 (`<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
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 (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 reenroll_voice(adb: Adb, store: Store, bridge: VoiceBridge, voice_id: str, *,
now: int, actor: str | None = None) -> dict[str, Any]:
"""Régénère le `.ovsp` depuis le WAV archivé (ex. après le fix §7) et le re-pousse
sur toutes les tablettes où la voix est déployée. `deploy_voice` réutilise le `.ovsp`
stocké — donc sans ré-enrôlement, un artefact pollué reste pollué."""
if store.voice_wav_bytes(voice_id) is None:
raise ValueError(f"WAV non archivé pour {voice_id} — ré-enrôlement impossible "
f"(ingérer d'abord depuis l'admin)")
info = _enroll_into_store(store, bridge, voice_id, now=now) # écrase ovsp + transcription
store.audit("voice_reenroll", actor=actor, target=voice_id, now=now)
ovsp_bytes = store.voice_ovsp_bytes(voice_id)
redeployed = []
for serial in store.deployments_of(voice_id):
try:
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)
transfer.push_ovsp(adb, serial, local, ov, voice_id)
store.add_deployment(voice_id, serial, now=now)
redeployed.append(serial)
except Exception as e:
redeployed.append(f"{serial}:erreur ({e})")
return {"voice_id": voice_id, "reenrolled": True, "ovsp_bytes": info["bytes"],
"ref_text": info.get("ref_text"), "redeployed": redeployed}
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"
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 le catalogue global."""
manifests = admin_ingest.list_manifests(adb, serial)
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:
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 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")
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(vid, data, origin_serial=serial, name=manifest.get("name"),
source_wav_path=manifest["wav_device_path"], now=now)
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(vid, owner, now=now)
store.audit("voice_admin_ingest", actor=actor, target=f"{serial}/{vid}",
detail={"scope": scope, "owner": owner}, now=now)
if scope == "pending":
return {"voice_id": vid, "scope": scope, "deployed": False, "reason": "pending"}
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(vid, now=now)
store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{vid}", now=now)
out["wav_deleted"] = True
return out
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