refactor: architecture modulaire core/features + routers par domaine (phase 1)

Refonte évolutive (pas de page blanche) : on garde le code validé hardware, on impose
la structure.
- core/ : adb, provider, store (socle validé) + context.py (Ctx injecté + guard
  centralisé exceptions→HTTP) + operator_router (santé/unlock/audit).
- features/ : voices/ (ex-voice/, sous-système complet) + fleet/ (service + router) ;
  chaque feature expose make_router(ctx). Ajouter un domaine = un dossier + l'enregistrer.
- app.py slim : compose les routers (_FEATURES). Fini le monolithe api/app.py.
- imports migrés, __main__ et tests adaptés. VoiceWorkerError.http_status=503 (découplage).
- 42/42 ; validé live (santé, devices, voices/catalog).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-30 16:57:26 +02:00
parent 2894771bf4
commit 2731b37b20
35 changed files with 685 additions and 343 deletions

View File

@ -0,0 +1,270 @@
# Migration du module voix : CosyVoice (`.cvps`) → OmniVoice (`.ovsp`)
**Date** : 23/06/2026 · **Auteur** : Richard & Claude (côté app/engine) · **Pour** : dev Kazeia-central
**Objet** : adapter `kazeia_central/voice/` pour préparer les voix du **nouveau TTS OmniVoice**
(l'app patiente a retiré CosyVoice le 22/06/2026, vc17+). Les voix sont désormais des
clone-prompts **`.ovsp`**, plus des `.cvps`.
> **L'architecture du module ne change pas.** Bridge worker en venv encodeur, orchestrator
> (pull → archive chiffré → transcription → enrôlement → push → suppression WAV device),
> verrou d'exclusivité (`locked_profile_id`), autosync : **tout reste valable**. Seuls
> changent **l'encodeur** (`enroll.py`), **la transcription** (`transcribe.py`), **les
> chemins/suffixes** (`transfer.py`), **le venv** (`bridge.py`) et la **glue** (`orchestrator.py`).
---
## 0. Faits vérifiés sur device/PC (ne pas re-découvrir)
- **Encodeur OmniVoice** = `OmniVoice.create_voice_clone_prompt(ref_audio=<wav>, ref_text=None|str)`
→ renvoie un `vcp` avec `vcp.ref_audio_tokens` (tensor `[8, T_ref]` int) et `vcp.ref_text` (str).
Si `ref_text=None`**ASR automatique** (Whisper interne `transformers`), `vcp.ref_text` rempli.
- **venv encodeur = `ov_venv`** (`/opt/Kazeia/ov_venv`). Contient : torch, librosa, soundfile,
transformers, omnivoice. **PAS openai-whisper** → la transcription (relecture opérateur) doit
passer par l'ASR interne d'OmniVoice, **pas `import whisper`**.
- **Cap de la réf à ~16 s** (≠ CosyVoice qui voulait 22 s). OmniVoice paie la longueur de réf
au **runtime** : `T_ref ∝ durée`, et `c_len = T_ref + cible` choisit le *bucket* de synthèse.
Réf ~16 s → `T_ref ~390`**bucket 640** (rapide, niveau damien validé 3,5 s). Réf 22 s →
`T_ref ~550` → bucket 768/1024 (plus lent) + risque de saturer le plus grand bucket.
- **`prepare_wav` partagé** entre transcription et enrôlement (déterministe) → le texte relu
correspond exactement à l'audio enrôlé. (sinon mismatch texte/audio = mauvais clone-prompt.)
- Validé : `make_ovsp` clip 15 s → `T_ref=364`, `.ovsp` 11,7 Ko ; auto-ASR sur clip 16 s tronqué OK.
- **Référence d'implémentation** : `/opt/Kazeia-engine/dist/omnivoice/scripts/make_ovsp.py`.
## 1. Format `.ovsp` (binaire, lu par le moteur tablette `nativeLoadVoice`)
```
int32 magic = 0x4F565350 ('OVSP', little-endian)
int32 T_ref (frames audio de réf, par codebook)
int32 text_len (octets UTF-8 du ref_text)
int32 ref_audio_tokens[8 * T_ref] (row-major [8, T_ref])
char ref_text[text_len] (UTF-8)
```
⚠ Le moteur ne tolère aucune dérive de ce format. Plus de conteneur GGUF (c'était CosyVoice).
## 2. Déploiement sur device
- Dossier cible : **`<MODELS_DIR>/omnivoice/voices/<voice_id>.ovsp`** (était `<MODELS_DIR>/cosyvoice/<id>.cvps`).
- `MODELS_DIR` reste dérivé du `wav_path` du provider `/voices` (même logique qu'avant ;
marqueur `_WAV_MARKER = "/../voix/voix/"` inchangé).
- Le provider `/voices` de l'app patiente a **déjà** été migré : il inventorie `omnivoice/voices/*.ovsp`
et la suppression de voix retire le `.ovsp`. La colonne d'état « ready » = `.ovsp` présent.
## 3. Changements fichier par fichier
### 3.1 `voice/enroll.py`**réécriture complète** (encodeur OmniVoice)
Remplacer intégralement par :
```python
"""Enrôlement vocal OmniVoice : WAV de référence → clone-prompt `.ovsp`.
⚠️ Sous l'env encodeur `ov_venv` (torch + omnivoice + transformers + librosa/soundfile),
invoqué par le worker subprocess (pas en-process API py3.14).
Réimplémente /opt/Kazeia-engine/dist/omnivoice/scripts/make_ovsp.py.
Format .ovsp : int32 'OVSP' | int32 T_ref | int32 text_len | int32 tokens[8*T_ref] | char ref_text[].
"""
from __future__ import annotations
import logging, os, struct, sys
import numpy as np
for _noisy in ("numba", "matplotlib", "transformers", "omnivoice", "huggingface_hub"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
OV_MODEL = os.environ.get("OV_MODEL", "k2-fsa/OmniVoice")
OVSP_MAGIC = 0x4F565350
# Cap ~16 s : T_ref ~390 -> bucket 640 (rapide). Au-delà = bucket 768/1024 (lent) +
# risque de saturer le plus grand bucket. prepare_wav partagé avec la transcription.
OV_TARGET_S = float(os.environ.get("OV_TARGET_S", "16.0"))
_PREP_SR = 24000
_HARD_MAX_S = 20.0
_MIN_WARN_S = 6.0
_model_singleton = None
class EnrollmentError(RuntimeError):
pass
def prepare_wav(in_path: str, out_path: str, target_s: float = OV_TARGET_S) -> dict:
import librosa, soundfile as sf
y, _ = librosa.load(in_path, sr=_PREP_SR, mono=True)
orig_s = len(y) / _PREP_SR
yt, _ = librosa.effects.trim(y, top_db=30)
if yt.size == 0:
yt = y
yt = yt[: int(min(target_s, _HARD_MAX_S) * _PREP_SR)]
final_s = len(yt) / _PREP_SR
sf.write(out_path, yt, _PREP_SR, subtype="PCM_16")
return {"orig_s": round(orig_s, 1), "final_s": round(final_s, 1), "too_short": final_s < _MIN_WARN_S}
def load_model():
global _model_singleton
if _model_singleton is not None:
return _model_singleton
try:
import torch
from omnivoice.models.omnivoice import OmniVoice
except Exception as e:
raise EnrollmentError(f"import OmniVoice échoué ({e}). Exécuter sous ov_venv.") from e
_model_singleton = OmniVoice.from_pretrained(OV_MODEL, torch_dtype=torch.float32).to("cpu").eval()
return _model_singleton
def _clone_prompt(prep_wav: str, ref_text):
import torch
m = load_model()
with torch.no_grad():
return m.create_voice_clone_prompt(ref_audio=prep_wav, ref_text=ref_text or None)
def transcribe_ref(wav_path: str) -> dict:
"""ASR du segment préparé (16 s) via OmniVoice, pour relecture opérateur."""
import tempfile
if not os.path.isfile(wav_path):
raise EnrollmentError(f"WAV introuvable: {wav_path}")
with tempfile.TemporaryDirectory() as td:
prep = os.path.join(td, "prep.wav")
prep_info = prepare_wav(wav_path, prep)
vcp = _clone_prompt(prep, None)
return {"text": (getattr(vcp, "ref_text", "") or "").strip(),
"language": None, "model": "omnivoice-asr", "prep": prep_info}
def enroll(wav_path: str, text: str, out_path: str) -> dict:
import tempfile
if not os.path.isfile(wav_path):
raise EnrollmentError(f"WAV introuvable: {wav_path}")
text = (text or "").strip()
with tempfile.TemporaryDirectory() as td:
prep = os.path.join(td, "prep.wav")
prep_info = prepare_wav(wav_path, prep)
vcp = _clone_prompt(prep, text if text else None)
toks = vcp.ref_audio_tokens.cpu().numpy().astype(np.int32) # [8, T_ref]
C, T = toks.shape
if C != 8:
raise EnrollmentError(f"attendu 8 codebooks, eu {C}")
ref_text = (getattr(vcp, "ref_text", "") or text or "").strip()
txt = ref_text.encode("utf-8")
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
with open(out_path, "wb") as f:
f.write(struct.pack("<i", OVSP_MAGIC))
f.write(struct.pack("<i", T))
f.write(struct.pack("<i", len(txt)))
f.write(toks.tobytes())
f.write(txt)
return {"out_path": out_path, "bytes": os.path.getsize(out_path), "t_ref": int(T),
"ref_seconds": round(T / 25.0, 1), "text_bytes": len(txt),
"ref_text": ref_text, "prep": prep_info}
if __name__ == "__main__":
import json
ref, ref_txt, out = sys.argv[1], sys.argv[2], sys.argv[3]
text = "" if ref_txt == "-" else (open(ref_txt).read() if ref_txt.endswith(".txt") and os.path.isfile(ref_txt) else ref_txt)
print(json.dumps(enroll(ref, text, out), ensure_ascii=False))
```
### 3.2 `voice/transcribe.py` — ASR via OmniVoice (plus d'openai-whisper)
`ov_venv` n'a pas openai-whisper. Remplacer le corps par une délégation à
`enroll.transcribe_ref` (qui transcrit le **segment préparé 16 s** via l'ASR OmniVoice) :
```python
"""Transcription du segment de référence (16 s) via l'ASR interne d'OmniVoice — ov_venv.
Le texte du .ovsp doit correspondre à l'audio réellement enrôlé ; prepare_wav étant
partagé/déterministe, le segment transcrit == le segment enrôlé."""
from __future__ import annotations
from .enroll import transcribe_ref
def transcribe(wav_path: str, model: str | None = None) -> dict:
return transcribe_ref(wav_path) # {text, language, model, prep}
```
### 3.3 `voice/transfer.py` — chemins/suffixe
- Renommer `cosyvoice_dir_for``omnivoice_dir_for`, retourner `posixpath.join(models, "omnivoice", "voices")`
(et le fallback `…/kazeia/models/omnivoice/voices`).
- Renommer `deployed_cvps``deployed_ovsp`, filtrer `.ovsp` (`line[:-5]` reste valable : `.ovsp` = 5 car).
- Renommer `push_cvps``push_ovsp`, `remote = …/<voice_id>.ovsp`.
- `pull_wav` / `delete_device_wav` inchangés.
### 3.4 `voice/bridge.py` — venv encodeur
- `CV_PYTHON``OV_PYTHON = os.environ.get("OV_PYTHON", "/opt/Kazeia/ov_venv/bin/python")`.
- Adapter le message d'erreur (« provisionner ov_venv »). Le worker (`worker.py`) est **générique**
(dispatch `ping/transcribe/enroll/shutdown`) → **inchangé**.
### 3.5 `voice/orchestrator.py` — glue
- `transfer.cosyvoice_dir_for``transfer.omnivoice_dir_for` (var locale `cosy`/`ov`).
- `transfer.deployed_cvps``transfer.deployed_ovsp` ; `transfer.push_cvps``transfer.push_ovsp`.
- Remplacer les littéraux `.cvps``.ovsp` (chemins temporaires) et la clé de retour
`cvps_bytes``ovsp_bytes` (et l'audit `detail={"ovsp_bytes": …}`).
- **Le verrou d'exclusivité (`locked_profile_id` / `verrou_profil_absent`) et l'archivage
WAV chiffré : INCHANGÉS** — la sémantique « ne pas déployer une voix exclusive sur une
tablette sans le profil propriétaire » reste exactement la même.
### 3.6 `voice/__init__.py`, docstrings, `docs/`
- `__init__.py` : docstring « enrôlement OmniVoice WAV → .ovsp ».
- Mettre à jour `docs/VOICE_ENROLLMENT_SPEC.md` et `docs/VOICE_DEPLOYMENT_SPEC.md` :
`.cvps`→`.ovsp`, dossier `omnivoice/voices`, cap 16 s, ASR OmniVoice, format binaire OVSP.
- `tests/test_voice.py` : adapter les attentes (magic `OVSP`, suffixe `.ovsp`, `omnivoice_dir_for`,
`t_ref`/`ovsp_bytes` au lieu des shapes GGUF/`cvps_bytes`).
## 4. Provisioning attendu (env)
- `ov_venv` opérationnel (`/opt/Kazeia/ov_venv`) avec le modèle `k2-fsa/OmniVoice` en cache HF
(déjà le cas sur le PC de dev). Variables : `OV_PYTHON`, `OV_MODEL`, `OV_TARGET_S` (défaut 16).
- Plus besoin de `CV_REPO`/`CV_TEACHER`/`cosyvoice-repo`/teacher 0.5B.
## 5. Validation suggérée
1. `ov_venv/bin/python -m kazeia_central.voice.enroll <wav> - /tmp/x.ovsp` (ASR auto) →
vérifier magic `OVSP`, `t_ref` ~300400 pour ~15 s, `ref_text` cohérent.
2. Flux complet sur une tablette : `prepare` (transcription relue) → `enroll_deploy`
`.ovsp` présent dans `…/omnivoice/voices/`, voix « ready » dans l'admin, lecture OK.
3. Exclusivité : une voix `locked_profile_id` non présent sur la tablette → `verrou_profil_absent`.
## 6. Notes
- Le **consentement légal** complet (~22 s, texte intégral) reste dans le manifeste de
l'admin ; le `.ovsp` ne porte que les 16 s + leur transcription (clone-prompt). C'est voulu.
- Côté app, le défaut de débit voix est réglable (curseur admin) — sans impact sur l'enrôlement.
- Binaires/catalogue OmniVoice déjà publiés sur box (`catalog v13`, composant `tts_omnivoice`).
---
## 7. ⚠ FIX OBLIGATOIRE — mot parasite en début de chaque phrase (bleed de fin de réf)
**Symptôme observé en prod** (voix « Richard » enrôlée via la lecture de consentement,
2026-06-23) : un **mot étrange au début de CHAQUE phrase** synthétisée.
**Cause (diagnostiquée, preuve à l'appui)** : le cap dur à 16 s coupe la réf **en plein
mot**. Ex. la phrase « …le chant joyeux des oiseaux, **près d'un** feu… » est coupée à 16 s ;
l'auto-ASR transcrit le bout partiel en « **présenter** » → `ref_text` se termine par ce
mot tronqué. OmniVoice **recrache ce dernier mot de la réf au début de chaque génération**.
Vérifié : synthèse de « Le chat dort sur le canapé. » → ASR = « **Présenter** le chat dort… ».
**Correctif (validé device — le mot parasite disparaît)** : ne JAMAIS terminer la réf sur
un mot/segment tronqué.
1. **Couper l'audio sur une frontière de silence** (jamais à `target_s` brut). Garder les
segments non-silencieux jusqu'à ~`target_s`, finir à la fin du dernier segment complet :
```python
import librosa, soundfile as sf
y,_ = librosa.load(in_path, sr=24000, mono=True)
y,_ = librosa.effects.trim(y, top_db=30)
end = 0
for s,e in librosa.effects.split(y, top_db=30):
if e/24000 <= target_s: end = e
else: break
if end == 0: end = min(len(y), int(target_s*24000))
sf.write(out_path, y[:end], 24000, subtype="PCM_16") # finit en silence, mots entiers
```
2. **Tronquer `ref_text` à la dernière phrase complète** (anti-bleed, ceinture+bretelles) :
```python
import re
ref = (vcp.ref_text or "").strip()
last = list(re.finditer(r'[.!?]', ref))
if last: ref = ref[:last[-1].end()].strip() # finit sur . ! ?
```
(L'audio finit en silence → le léger surplus audio vs texte = silence, sans mot recraché.)
→ À intégrer dans `voice/enroll.py` (`prepare_wav` pour le point 1, `enroll` pour le point 2).
Validé : `ref_text` « …le chant joyeux des oiseaux. » (au lieu de « …des oiseaux présenter. »)
→ plus de mot parasite. **Toutes les voix déjà enrôlées via l'ancien cap doivent être
ré-enrôlées** (la voix Richard de test a été re-patchée à la main en attendant).

View File

@ -31,7 +31,7 @@ def main() -> None:
elif exposed: elif exposed:
print(f"Kazeia-central sur {host}:{port} — auth Basic active.", flush=True) print(f"Kazeia-central sur {host}:{port} — auth Basic active.", flush=True)
uvicorn.run("kazeia_central.api.app:app", host=host, port=port) uvicorn.run("kazeia_central.app:app", host=host, port=port)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,3 +0,0 @@
from .app import create_app
__all__ = ["create_app"]

View File

@ -1,308 +0,0 @@
"""API FastAPI de Kazeia-central (étapes 0-1 : lecture de flotte).
Sert l'UI web locale et expose la lecture des endpoints provider existants par
tablette. Les écritures (config/presets/profils/RAG) et l'export conversations
seront ajoutés quand les méthodes call() base64-JSON seront livrées côté app
patiente (docs/PROVIDER_RPC_SPEC.md).
Lancer : `uvicorn kazeia_central.api.app:app --reload`
"""
from __future__ import annotations
import base64
import os
import secrets
import time
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.responses import Response
from ..adb import Adb, AdbError
from ..fleet import device_overview, fan_out
from ..provider import ProviderClient
from ..store import BadPassword, Store, StoreLocked
from ..voice import orchestrator as vo
from ..voice.autosync import VoiceAutoSync
from ..voice.bridge import VoiceBridge, VoiceWorkerError
_WEB_DIR = Path(__file__).resolve().parent.parent / "web"
# Base chiffrée locale. Défaut sous data/ (ignoré par .gitignore — jamais commité).
_STORE_PATH = Path(os.environ.get("KAZEIA_STORE", Path.cwd() / "data" / "central.db"))
# Identité opérateur pour la piste d'audit (= user d'auth réseau si défini).
_OPERATOR = os.environ.get("KAZEIA_USER") or "local"
def _now_ms() -> int:
return int(time.time() * 1000)
class _Password(BaseModel):
password: str
class _Label(BaseModel):
label: str
class _Enroll(BaseModel):
transcription: str
delete_source: bool = False # suppression du WAV device = opt-in explicite
class _Sync(BaseModel):
delete_source: bool = False
class _AutoSync(BaseModel):
enabled: bool
delete_source: bool | None = None
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
sécuriser une exposition réseau (0.0.0.0) sans elle, l'app reste ouverte."""
user = os.environ.get("KAZEIA_USER")
pwd = os.environ.get("KAZEIA_PASS")
if not (user and pwd):
return
@app.middleware("http")
async def _basic_auth(request, call_next):
hdr = request.headers.get("authorization", "")
ok = False
if hdr.startswith("Basic "):
try:
u, _, p = base64.b64decode(hdr[6:]).decode("utf-8").partition(":")
ok = secrets.compare_digest(u, user) and secrets.compare_digest(p, pwd)
except Exception:
ok = False
if not ok:
return Response("Authentification requise", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="Kazeia-central"'})
return await call_next(request)
def create_app(adb: Adb | None = None, store: Store | None = None,
bridge: VoiceBridge | None = None, *, start_autosync: bool = True) -> FastAPI:
adb = adb or Adb()
if store is None:
_STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
store = Store(_STORE_PATH)
bridge = bridge or VoiceBridge()
autosync = VoiceAutoSync(adb, store, bridge)
if start_autosync:
autosync.start() # désarmé par défaut : ne fait que suivre les connexions
app = FastAPI(title="Kazeia-central", version="0.0.1")
_install_basic_auth(app)
def client(serial: str) -> ProviderClient:
return ProviderClient(adb, serial)
def _guard(fn):
try:
return fn()
except AdbError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
except StoreLocked as e:
raise HTTPException(status_code=423, detail=str(e)) from e
except VoiceWorkerError as e:
raise HTTPException(status_code=503, detail=f"encodeur voix: {e}") from e
except IndexError as e: # endpoint a renvoyé 0 ligne
raise HTTPException(status_code=404, detail="aucune donnée") from e
# ---- store opérateur (déverrouillage, labels patients, audit) ---------
@app.get("/api/lock-status")
def lock_status():
return {"initialized": store.is_initialized, "unlocked": store.is_unlocked}
@app.post("/api/unlock")
def unlock(body: _Password):
first = not store.is_initialized
try:
store.unlock(body.password)
except BadPassword as e:
raise HTTPException(status_code=401, detail="mot de passe incorrect") from e
store.audit("store_init" if first else "store_unlock",
actor=_OPERATOR, now=_now_ms())
return {"unlocked": True, "initialized_now": first}
@app.post("/api/lock")
def lock():
store.lock()
return {"unlocked": False}
@app.get("/api/audit")
def audit(limit: int = 200):
return _guard(lambda: store.audit_log(limit))
@app.get("/api/health")
def health():
return {"ok": True, "version": app.version}
@app.get("/api/devices")
def devices():
devs = [d.__dict__ for d in _guard(adb.devices)]
# Enrichit avec le label patient si le store est déverrouillé.
if store.is_unlocked:
labels = store.device_labels()
for d in devs:
d["label"] = labels.get(d["serial"])
return devs
@app.put("/api/devices/{serial}/label")
def set_label(serial: str, body: _Label):
_guard(lambda: store.set_device_label(serial, body.label, now=_now_ms()))
# NB : ne PAS mettre le label (PII) dans audit.detail (colonne en clair).
store.audit("set_label", actor=_OPERATOR, target=serial, now=_now_ms())
return {"serial": serial, "label": body.label}
@app.get("/api/devices/{serial}/state")
def state(serial: str):
return _guard(lambda: client(serial).state()).model_dump()
@app.get("/api/devices/{serial}/turns")
def turns(serial: str):
return [t.model_dump() for t in _guard(lambda: client(serial).turns())]
@app.get("/api/devices/{serial}/crashes")
def crashes(serial: str):
return [c.model_dump() for c in _guard(lambda: client(serial).crashes())]
@app.get("/api/devices/{serial}/models")
def models(serial: str):
return [x.model_dump() for x in _guard(lambda: client(serial).models())]
@app.get("/api/devices/{serial}/voices")
def voices(serial: str):
return [v.model_dump() for v in _guard(lambda: client(serial).voices())]
@app.get("/api/devices/{serial}/rag/status")
def rag_status(serial: str):
return _guard(lambda: client(serial).rag_status()).model_dump()
@app.get("/api/devices/{serial}/rag/index")
def rag_index(serial: str):
return [d.model_dump() for d in _guard(lambda: client(serial).rag_index())]
@app.get("/api/devices/{serial}/updates")
def updates(serial: str):
return _guard(lambda: client(serial).updates()).model_dump()
@app.get("/api/devices/{serial}/profiles")
def profiles(serial: str):
return [p.model_dump() for p in _guard(lambda: client(serial).profiles())]
@app.get("/api/devices/{serial}/conversations")
def sessions(serial: str, profile_id: str | None = None):
return [s.model_dump() for s in _guard(lambda: client(serial).sessions(profile_id))]
# ---- flotte (fan-out parc, rapport par device §9) ---------------------
@app.get("/api/fleet/overview")
def fleet_overview():
report = fan_out(adb, lambda s: device_overview(adb, s))
labels = store.device_labels() if store.is_unlocked else {}
for r in report:
r["label"] = labels.get(r["serial"])
return report
@app.post("/api/fleet/update-check")
def fleet_update_check():
return fan_out(adb, lambda s: client(s).update_check())
@app.post("/api/fleet/update-install")
def fleet_update_install():
report = fan_out(adb, lambda s: client(s).update_install())
store.audit("fleet_update_install", actor=_OPERATOR,
detail={"devices": [r["serial"] for r in report if r["ok"]]},
now=_now_ms())
return report
# ---- voix (enrôlement OmniVoice : WAV device → .ovsp → flotte) --------
@app.get("/api/voices/health")
def voices_health():
return {"encoder_available": bridge.available(), "ov_python": bridge.ov_python}
# ⚠️ 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()
@app.post("/api/voices/autosync")
def autosync_set(body: _AutoSync):
autosync.set_enabled(body.enabled, delete_source=body.delete_source)
store.audit("autosync_set", actor=_OPERATOR,
detail={"enabled": body.enabled, "delete_source": autosync.delete_source},
now=_now_ms())
return autosync.status()
# ---- 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}
@app.post("/api/voices/catalog/{voice_id}/reenroll")
def voices_reenroll(voice_id: str):
# régénère le .ovsp (fix §7) depuis le WAV archivé + re-pousse sur ses tablettes
return _guard(lambda: vo.reenroll_voice(adb, store, bridge, voice_id,
now=_now_ms(), actor=_OPERATOR))
# ---- 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))
@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}/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}/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 "/".
if _WEB_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(_WEB_DIR), html=True), name="web")
return app
app = create_app()

87
kazeia_central/app.py Normal file
View File

@ -0,0 +1,87 @@
"""Composition de l'application Kazeia-central (FastAPI).
`create_app` câble le contexte (adb, store chiffré, bridge voix, autosync) et **compose
les routers de features** (operator, fleet, voices). Ajouter un domaine = créer
`features/<x>/router.py` exposant `make_router(ctx)` et l'enregistrer ci-dessous.
Lancer : `python -m kazeia_central` (host/port via env) ou `uvicorn kazeia_central.app:app`.
"""
from __future__ import annotations
import base64
import os
import secrets
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.responses import Response
from .core.adb import Adb
from .core.context import VERSION, Ctx
from .core.operator_router import make_router as operator_router
from .core.store import Store
from .features.fleet.router import make_router as fleet_router
from .features.voices.autosync import VoiceAutoSync
from .features.voices.bridge import VoiceBridge
from .features.voices.router import make_router as voices_router
_WEB_DIR = Path(__file__).resolve().parent / "web"
_STORE_PATH = Path(os.environ.get("KAZEIA_STORE", Path.cwd() / "data" / "central.db"))
_OPERATOR = os.environ.get("KAZEIA_USER") or "local"
# Routers de features à composer (ordre libre ; les littéraux vs /{serial} sont gérés
# DANS chaque router). Ajouter une feature = ajouter sa fabrique ici.
_FEATURES = (operator_router, fleet_router, voices_router)
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 pour une exposition
réseau (0.0.0.0). Sans elle, l'app reste ouverte."""
user, pwd = os.environ.get("KAZEIA_USER"), os.environ.get("KAZEIA_PASS")
if not (user and pwd):
return
@app.middleware("http")
async def _basic_auth(request, call_next):
hdr = request.headers.get("authorization", "")
ok = False
if hdr.startswith("Basic "):
try:
u, _, p = base64.b64decode(hdr[6:]).decode("utf-8").partition(":")
ok = secrets.compare_digest(u, user) and secrets.compare_digest(p, pwd)
except Exception:
ok = False
if not ok:
return Response("Authentification requise", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="Kazeia-central"'})
return await call_next(request)
def create_app(adb: Adb | None = None, store: Store | None = None,
bridge: VoiceBridge | None = None, *, start_autosync: bool = True) -> FastAPI:
adb = adb or Adb()
if store is None:
_STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
store = Store(_STORE_PATH)
bridge = bridge or VoiceBridge()
autosync = VoiceAutoSync(adb, store, bridge)
if start_autosync:
autosync.start() # désarmé par défaut : ne fait que suivre les connexions
app = FastAPI(title="Kazeia-central", version=VERSION)
_install_basic_auth(app)
ctx = Ctx(adb=adb, store=store, bridge=bridge, autosync=autosync, operator=_OPERATOR)
for make_router in _FEATURES:
app.include_router(make_router(ctx))
# UI web servie à la racine — APRÈS les routes /api (le mount "/" capte le reste).
if _WEB_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(_WEB_DIR), html=True), name="web")
return app
app = create_app()

View File

View File

@ -0,0 +1,57 @@
"""Contexte applicatif injecté dans les routers de features.
Patron évolutif : chaque feature expose `make_router(ctx) -> APIRouter` et reçoit ce
contexte (adb, store, bridge, autosync, identité opérateur) + les helpers transverses
(`guard` = mapping exceptionsHTTP, `now_ms`, `client`). Ajouter une feature = créer son
router et l'enregistrer dans `app.create_app` — sans toucher au reste.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Callable
from fastapi import HTTPException
from .adb import Adb, AdbError
from .provider import ProviderClient
from .store import Store, StoreLocked
VERSION = "0.1.0"
@dataclass
class Ctx:
adb: Adb
store: Store
bridge: Any # VoiceBridge (feature voices) — typé Any pour découpler core
autosync: Any # VoiceAutoSync
operator: str
@staticmethod
def now_ms() -> int:
return int(time.time() * 1000)
def client(self, serial: str) -> ProviderClient:
return ProviderClient(self.adb, serial)
def guard(self, fn: Callable):
"""Exécute `fn`, mappe les exceptions de domaine en HTTP. Les erreurs de
feature peuvent porter un attribut `http_status` (ex. VoiceWorkerError=503)
pour rester découplées du core."""
try:
return fn()
except HTTPException:
raise
except AdbError as e:
raise HTTPException(502, str(e)) from e
except StoreLocked as e:
raise HTTPException(423, str(e)) from e
except IndexError as e: # endpoint a renvoyé 0 ligne
raise HTTPException(404, "aucune donnée") from e
except Exception as e:
code = getattr(type(e), "http_status", None)
if code:
raise HTTPException(code, str(e)) from e
raise

View File

@ -0,0 +1,47 @@
"""Router opérateur : santé, déverrouillage du store, audit."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .context import VERSION, Ctx
from .store import BadPassword
class _Password(BaseModel):
password: str
def make_router(ctx: Ctx) -> APIRouter:
r = APIRouter(prefix="/api", tags=["operator"])
@r.get("/health")
def health():
return {"ok": True, "version": VERSION}
@r.get("/lock-status")
def lock_status():
return {"initialized": ctx.store.is_initialized, "unlocked": ctx.store.is_unlocked}
@r.post("/unlock")
def unlock(body: _Password):
first = not ctx.store.is_initialized
try:
ctx.store.unlock(body.password)
except BadPassword as e:
raise HTTPException(401, "mot de passe incorrect") from e
ctx.store.audit("store_init" if first else "store_unlock",
actor=ctx.operator, now=ctx.now_ms())
return {"unlocked": True, "initialized_now": first}
@r.post("/lock")
def lock():
ctx.store.lock()
return {"unlocked": False}
@r.get("/audit")
def audit(limit: int = 200):
return ctx.guard(lambda: ctx.store.audit_log(limit))
return r

View File

View File

@ -0,0 +1,94 @@
"""Router flotte : découverte tablettes, labels patients, télémétrie par device
(lecture provider) et opérations parc (fan-out OTA)."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from ...core.context import Ctx
from .service import device_overview, fan_out
class _Label(BaseModel):
label: str
def make_router(ctx: Ctx) -> APIRouter:
r = APIRouter(prefix="/api", tags=["fleet"])
g, now = ctx.guard, ctx.now_ms
# ---- parc + labels ----------------------------------------------------
@r.get("/devices")
def devices():
devs = [d.__dict__ for d in g(ctx.adb.devices)]
if ctx.store.is_unlocked: # enrichit du label patient
labels = ctx.store.device_labels()
for d in devs:
d["label"] = labels.get(d["serial"])
return devs
@r.put("/devices/{serial}/label")
def set_label(serial: str, body: _Label):
g(lambda: ctx.store.set_device_label(serial, body.label, now=now()))
ctx.store.audit("set_label", actor=ctx.operator, target=serial, now=now()) # pas de PII en clair
return {"serial": serial, "label": body.label}
# ---- télémétrie par tablette (lecture provider) -----------------------
@r.get("/devices/{serial}/state")
def state(serial: str):
return g(lambda: ctx.client(serial).state()).model_dump()
@r.get("/devices/{serial}/turns")
def turns(serial: str):
return [t.model_dump() for t in g(lambda: ctx.client(serial).turns())]
@r.get("/devices/{serial}/crashes")
def crashes(serial: str):
return [c.model_dump() for c in g(lambda: ctx.client(serial).crashes())]
@r.get("/devices/{serial}/models")
def models(serial: str):
return [x.model_dump() for x in g(lambda: ctx.client(serial).models())]
@r.get("/devices/{serial}/rag/status")
def rag_status(serial: str):
return g(lambda: ctx.client(serial).rag_status()).model_dump()
@r.get("/devices/{serial}/rag/index")
def rag_index(serial: str):
return [d.model_dump() for d in g(lambda: ctx.client(serial).rag_index())]
@r.get("/devices/{serial}/updates")
def updates(serial: str):
return g(lambda: ctx.client(serial).updates()).model_dump()
@r.get("/devices/{serial}/profiles")
def profiles(serial: str):
return [p.model_dump() for p in g(lambda: ctx.client(serial).profiles())]
@r.get("/devices/{serial}/conversations")
def sessions(serial: str, profile_id: str | None = None):
return [s.model_dump() for s in g(lambda: ctx.client(serial).sessions(profile_id))]
# ---- opérations parc (fan-out, rapport par device §9) -----------------
@r.get("/fleet/overview")
def fleet_overview():
report = fan_out(ctx.adb, lambda s: device_overview(ctx.adb, s))
labels = ctx.store.device_labels() if ctx.store.is_unlocked else {}
for x in report:
x["label"] = labels.get(x["serial"])
return report
@r.post("/fleet/update-check")
def fleet_update_check():
return fan_out(ctx.adb, lambda s: ctx.client(s).update_check())
@r.post("/fleet/update-install")
def fleet_update_install():
report = fan_out(ctx.adb, lambda s: ctx.client(s).update_install())
ctx.store.audit("fleet_update_install", actor=ctx.operator,
detail={"devices": [x["serial"] for x in report if x["ok"]]}, now=now())
return report
return r

View File

@ -13,8 +13,8 @@ from __future__ import annotations
from typing import Any, Callable from typing import Any, Callable
from .adb import Adb from ...core.adb import Adb
from .provider import ProviderClient from ...core.provider import ProviderClient
def fan_out(adb: Adb, fn: Callable[[str], Any], *, def fan_out(adb: Adb, fn: Callable[[str], Any], *,

View File

@ -20,7 +20,7 @@ import posixpath
import tempfile import tempfile
from typing import Any from typing import Any
from ..adb import Adb from ...core.adb import Adb
# getExternalFilesDir("voix") de com.kazeia.admin. /sdcard = /storage/emulated/0. # getExternalFilesDir("voix") de com.kazeia.admin. /sdcard = /storage/emulated/0.
ADMIN_VOIX_DIR = os.environ.get( ADMIN_VOIX_DIR = os.environ.get(

View File

@ -21,7 +21,7 @@ _REPO = Path(__file__).resolve().parents[2] # /opt/Kazeia-central
class VoiceWorkerError(RuntimeError): class VoiceWorkerError(RuntimeError):
pass http_status = 503 # lu par core.context.Ctx.guard (encodeur indispo) sans coupler core↔voices
class VoiceBridge: class VoiceBridge:
@ -45,7 +45,7 @@ class VoiceBridge:
f"(provisionner ov_venv, ou définir OV_PYTHON)") f"(provisionner ov_venv, ou définir OV_PYTHON)")
env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1") env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1")
self._proc = subprocess.Popen( self._proc = subprocess.Popen(
[self.ov_python, "-m", "kazeia_central.voice.worker"], [self.ov_python, "-m", "kazeia_central.features.voices.worker"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, # stderr → console stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, # stderr → console
text=True, env=env, bufsize=1, text=True, env=env, bufsize=1,
) )

View File

@ -18,9 +18,9 @@ import posixpath
import tempfile import tempfile
from typing import Any from typing import Any
from ..adb import Adb from ...core.adb import Adb
from ..provider import ProviderClient from ...core.provider import ProviderClient
from ..store import Store from ...core.store import Store
from . import admin_ingest, transfer from . import admin_ingest, transfer
from .bridge import VoiceBridge from .bridge import VoiceBridge

View File

@ -0,0 +1,98 @@
"""Router voix : catalogue GLOBAL, déploiement par tablette, ingestion admin,
verrou d'exclusivité, ré-enrôlement, déclencheur auto."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from ...core.context import Ctx
from . import orchestrator as vo
class _Sync(BaseModel):
delete_source: bool = False
class _AutoSync(BaseModel):
enabled: bool
delete_source: bool | None = None
class _Lock(BaseModel):
profile_id: str
class _Deploy(BaseModel):
voice_id: str
def make_router(ctx: Ctx) -> APIRouter:
r = APIRouter(prefix="/api/voices", tags=["voices"])
g, now = ctx.guard, ctx.now_ms
@r.get("/health")
def voices_health():
return {"encoder_available": ctx.bridge.available(), "ov_python": ctx.bridge.ov_python}
# ⚠️ Littéraux (autosync, catalog) AVANT /{serial} — ordre de déclaration.
@r.get("/autosync")
def autosync_status():
return ctx.autosync.status()
@r.post("/autosync")
def autosync_set(body: _AutoSync):
ctx.autosync.set_enabled(body.enabled, delete_source=body.delete_source)
ctx.store.audit("autosync_set", actor=ctx.operator,
detail={"enabled": body.enabled, "delete_source": ctx.autosync.delete_source},
now=now())
return ctx.autosync.status()
# ---- catalogue GLOBAL (vue sans tablette) -----------------------------
@r.get("/catalog")
def voices_catalog():
return g(lambda: vo.catalog(ctx.store))
@r.post("/catalog/{voice_id}/lock")
def voices_lock(voice_id: str, body: _Lock):
g(lambda: ctx.store.lock_voice(voice_id, body.profile_id, now=now()))
ctx.store.audit("voice_lock", actor=ctx.operator, target=voice_id,
detail={"profile_id": body.profile_id}, now=now())
return {"voice_id": voice_id, "locked_profile_id": body.profile_id}
@r.post("/catalog/{voice_id}/unlock")
def voices_unlock(voice_id: str):
g(lambda: ctx.store.unlock_voice(voice_id, now=now()))
ctx.store.audit("voice_unlock", actor=ctx.operator, target=voice_id, now=now())
return {"voice_id": voice_id, "locked_profile_id": None}
@r.post("/catalog/{voice_id}/reenroll")
def voices_reenroll(voice_id: str):
return g(lambda: vo.reenroll_voice(ctx.adb, ctx.store, ctx.bridge, voice_id,
now=now(), actor=ctx.operator))
# ---- vue par tablette : chargées + chargeables ------------------------
@r.get("/{serial}")
def voices_for_tablet(serial: str):
return g(lambda: vo.list_for_tablet(ctx.adb, ctx.store, serial))
@r.get("/{serial}/admin")
def voices_admin_list(serial: str):
return g(lambda: vo.list_admin_voices(ctx.adb, ctx.store, serial))
@r.post("/{serial}/admin/sync")
def voices_admin_sync(serial: str, body: _Sync):
return g(lambda: vo.sync_admin(ctx.adb, ctx.store, ctx.bridge, serial,
delete_source=body.delete_source, now=now(), actor=ctx.operator))
@r.post("/{serial}/deploy")
def voices_deploy(serial: str, body: _Deploy):
return g(lambda: vo.deploy_voice(ctx.adb, ctx.store, ctx.bridge, serial,
body.voice_id, now=now(), actor=ctx.operator))
@r.post("/{serial}/undeploy")
def voices_undeploy(serial: str, body: _Deploy):
return g(lambda: vo.undeploy_voice(ctx.adb, ctx.store, serial, body.voice_id,
now=now(), actor=ctx.operator))
return r

View File

@ -13,7 +13,7 @@ from __future__ import annotations
import posixpath import posixpath
from ..adb import Adb, AdbError from ...core.adb import Adb, AdbError
# Le provider expose VOICE_WAV_DIR = "${MODELS_DIR}/../voix/voix" → ce marqueur # Le provider expose VOICE_WAV_DIR = "${MODELS_DIR}/../voix/voix" → ce marqueur
# permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier voix OmniVoice cible. # permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier voix OmniVoice cible.

View File

@ -1,7 +1,7 @@
"""Worker d'enrôlement vocal — process persistant sous ov_venv. """Worker d'enrôlement vocal — process persistant sous ov_venv.
Lancé par `voice/bridge.py` (côté API py3.14) via : Lancé par `voice/bridge.py` (côté API py3.14) via :
ov_venv/bin/python -m kazeia_central.voice.worker ov_venv/bin/python -m kazeia_central.features.voices.worker
Garde le modèle OmniVoice (enrôlement + ASR de réf) **chaud**, et sert une file de Garde le modèle OmniVoice (enrôlement + ASR de réf) **chaud**, et sert une file de
jobs via un protocole JSON-lines sur stdin/stdout. jobs via un protocole JSON-lines sur stdin/stdout.

View File

@ -1,8 +1,8 @@
import subprocess import subprocess
from kazeia_central.adb import parse_content_query from kazeia_central.core.adb import parse_content_query
from kazeia_central.adb.client import Adb, AdbError, parse_call_bundle from kazeia_central.core.adb.client import Adb, AdbError, parse_call_bundle
from kazeia_central.provider import models as m from kazeia_central.core.provider import models as m
def test_parse_rows(): def test_parse_rows():

View File

@ -3,10 +3,10 @@
import nacl.pwhash import nacl.pwhash
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from kazeia_central.adb import Adb from kazeia_central.core.adb import Adb
from kazeia_central.adb.client import AdbDevice from kazeia_central.core.adb.client import AdbDevice
from kazeia_central.api.app import create_app from kazeia_central.app import create_app
from kazeia_central.store import Store from kazeia_central.core.store import Store
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN

View File

@ -3,11 +3,11 @@
import nacl.pwhash import nacl.pwhash
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from kazeia_central.adb import Adb, AdbError from kazeia_central.core.adb import Adb, AdbError
from kazeia_central.adb.client import AdbDevice from kazeia_central.core.adb.client import AdbDevice
from kazeia_central.api.app import create_app from kazeia_central.app import create_app
from kazeia_central.fleet import device_overview, fan_out from kazeia_central.features.fleet.service import device_overview, fan_out
from kazeia_central.store import Store from kazeia_central.core.store import Store
class _ReadyAdb(Adb): class _ReadyAdb(Adb):

View File

@ -2,7 +2,7 @@
import nacl.pwhash import nacl.pwhash
from kazeia_central.store import BadPassword, Store, StoreLocked from kazeia_central.core.store import BadPassword, Store, StoreLocked
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN

View File

@ -10,12 +10,12 @@ import tempfile
import nacl.pwhash import nacl.pwhash
from kazeia_central.adb import Adb from kazeia_central.core.adb import Adb
from kazeia_central.adb.client import AdbDevice from kazeia_central.core.adb.client import AdbDevice
from kazeia_central.store import Store from kazeia_central.core.store import Store
from kazeia_central.voice import admin_ingest from kazeia_central.features.voices import admin_ingest
from kazeia_central.voice import orchestrator as vo from kazeia_central.features.voices import orchestrator as vo
from kazeia_central.voice import transfer from kazeia_central.features.voices import transfer
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN
@ -249,14 +249,14 @@ def test_sync_admin_delete_cleans(tmp_path):
# ---- déclencheur auto ----------------------------------------------------- # ---- déclencheur auto -----------------------------------------------------
def test_autosync_blocked_when_locked(tmp_path): def test_autosync_blocked_when_locked(tmp_path):
from kazeia_central.voice.autosync import VoiceAutoSync from kazeia_central.features.voices.autosync import VoiceAutoSync
locked = Store(tmp_path / "l.db", kdf_ops=_OPS, kdf_mem=_MEM) # pas unlock locked = Store(tmp_path / "l.db", kdf_ops=_OPS, kdf_mem=_MEM) # pas unlock
a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), locked, _Bridge()) a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), locked, _Bridge())
assert a.sync_now("D1")["state"] == "store_verrouille" assert a.sync_now("D1")["state"] == "store_verrouille"
def test_autosync_enabled_ingests(tmp_path): def test_autosync_enabled_ingests(tmp_path):
from kazeia_central.voice.autosync import VoiceAutoSync from kazeia_central.features.voices.autosync import VoiceAutoSync
s = _store(tmp_path) s = _store(tmp_path)
a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), s, _Bridge()) a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), s, _Bridge())
a.set_enabled(True, delete_source=False) a.set_enabled(True, delete_source=False)