diff --git a/docs/OMNIVOICE_VOICE_MIGRATION.md b/docs/OMNIVOICE_VOICE_MIGRATION.md new file mode 100644 index 0000000..47fff36 --- /dev/null +++ b/docs/OMNIVOICE_VOICE_MIGRATION.md @@ -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=, 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 : **`/omnivoice/voices/.ovsp`** (était `/cosyvoice/.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(" 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 = …/.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 - /tmp/x.ovsp` (ASR auto) → + vérifier magic `OVSP`, `t_ref` ~300–400 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). diff --git a/kazeia_central/__main__.py b/kazeia_central/__main__.py index f08ea56..979d98d 100644 --- a/kazeia_central/__main__.py +++ b/kazeia_central/__main__.py @@ -31,7 +31,7 @@ def main() -> None: elif exposed: 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__": diff --git a/kazeia_central/api/__init__.py b/kazeia_central/api/__init__.py deleted file mode 100644 index b94a1e8..0000000 --- a/kazeia_central/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .app import create_app - -__all__ = ["create_app"] diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py deleted file mode 100644 index ac33b5d..0000000 --- a/kazeia_central/api/app.py +++ /dev/null @@ -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() diff --git a/kazeia_central/app.py b/kazeia_central/app.py new file mode 100644 index 0000000..c08d5cc --- /dev/null +++ b/kazeia_central/app.py @@ -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//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() diff --git a/kazeia_central/core/__init__.py b/kazeia_central/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kazeia_central/adb/__init__.py b/kazeia_central/core/adb/__init__.py similarity index 100% rename from kazeia_central/adb/__init__.py rename to kazeia_central/core/adb/__init__.py diff --git a/kazeia_central/adb/client.py b/kazeia_central/core/adb/client.py similarity index 100% rename from kazeia_central/adb/client.py rename to kazeia_central/core/adb/client.py diff --git a/kazeia_central/core/context.py b/kazeia_central/core/context.py new file mode 100644 index 0000000..00c3927 --- /dev/null +++ b/kazeia_central/core/context.py @@ -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 exceptions→HTTP, `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 diff --git a/kazeia_central/core/operator_router.py b/kazeia_central/core/operator_router.py new file mode 100644 index 0000000..19e065f --- /dev/null +++ b/kazeia_central/core/operator_router.py @@ -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 diff --git a/kazeia_central/provider/__init__.py b/kazeia_central/core/provider/__init__.py similarity index 100% rename from kazeia_central/provider/__init__.py rename to kazeia_central/core/provider/__init__.py diff --git a/kazeia_central/provider/client.py b/kazeia_central/core/provider/client.py similarity index 100% rename from kazeia_central/provider/client.py rename to kazeia_central/core/provider/client.py diff --git a/kazeia_central/provider/models.py b/kazeia_central/core/provider/models.py similarity index 100% rename from kazeia_central/provider/models.py rename to kazeia_central/core/provider/models.py diff --git a/kazeia_central/store/__init__.py b/kazeia_central/core/store/__init__.py similarity index 100% rename from kazeia_central/store/__init__.py rename to kazeia_central/core/store/__init__.py diff --git a/kazeia_central/store/crypto.py b/kazeia_central/core/store/crypto.py similarity index 100% rename from kazeia_central/store/crypto.py rename to kazeia_central/core/store/crypto.py diff --git a/kazeia_central/store/db.py b/kazeia_central/core/store/db.py similarity index 100% rename from kazeia_central/store/db.py rename to kazeia_central/core/store/db.py diff --git a/kazeia_central/features/__init__.py b/kazeia_central/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kazeia_central/features/fleet/__init__.py b/kazeia_central/features/fleet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kazeia_central/features/fleet/router.py b/kazeia_central/features/fleet/router.py new file mode 100644 index 0000000..242bd9a --- /dev/null +++ b/kazeia_central/features/fleet/router.py @@ -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 diff --git a/kazeia_central/fleet.py b/kazeia_central/features/fleet/service.py similarity index 97% rename from kazeia_central/fleet.py rename to kazeia_central/features/fleet/service.py index 451278e..dad10e9 100644 --- a/kazeia_central/fleet.py +++ b/kazeia_central/features/fleet/service.py @@ -13,8 +13,8 @@ from __future__ import annotations from typing import Any, Callable -from .adb import Adb -from .provider import ProviderClient +from ...core.adb import Adb +from ...core.provider import ProviderClient def fan_out(adb: Adb, fn: Callable[[str], Any], *, diff --git a/kazeia_central/voice/__init__.py b/kazeia_central/features/voices/__init__.py similarity index 100% rename from kazeia_central/voice/__init__.py rename to kazeia_central/features/voices/__init__.py diff --git a/kazeia_central/voice/admin_ingest.py b/kazeia_central/features/voices/admin_ingest.py similarity index 99% rename from kazeia_central/voice/admin_ingest.py rename to kazeia_central/features/voices/admin_ingest.py index cd67294..f1e8e3c 100644 --- a/kazeia_central/voice/admin_ingest.py +++ b/kazeia_central/features/voices/admin_ingest.py @@ -20,7 +20,7 @@ import posixpath import tempfile from typing import Any -from ..adb import Adb +from ...core.adb import Adb # getExternalFilesDir("voix") de com.kazeia.admin. /sdcard = /storage/emulated/0. ADMIN_VOIX_DIR = os.environ.get( diff --git a/kazeia_central/voice/autosync.py b/kazeia_central/features/voices/autosync.py similarity index 100% rename from kazeia_central/voice/autosync.py rename to kazeia_central/features/voices/autosync.py diff --git a/kazeia_central/voice/bridge.py b/kazeia_central/features/voices/bridge.py similarity index 95% rename from kazeia_central/voice/bridge.py rename to kazeia_central/features/voices/bridge.py index 403d46c..291f1f9 100644 --- a/kazeia_central/voice/bridge.py +++ b/kazeia_central/features/voices/bridge.py @@ -21,7 +21,7 @@ _REPO = Path(__file__).resolve().parents[2] # /opt/Kazeia-central class VoiceWorkerError(RuntimeError): - pass + http_status = 503 # lu par core.context.Ctx.guard (encodeur indispo) sans coupler core↔voices class VoiceBridge: @@ -45,7 +45,7 @@ class VoiceBridge: f"(provisionner ov_venv, ou définir OV_PYTHON)") env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1") 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 text=True, env=env, bufsize=1, ) diff --git a/kazeia_central/voice/enroll.py b/kazeia_central/features/voices/enroll.py similarity index 100% rename from kazeia_central/voice/enroll.py rename to kazeia_central/features/voices/enroll.py diff --git a/kazeia_central/voice/orchestrator.py b/kazeia_central/features/voices/orchestrator.py similarity index 99% rename from kazeia_central/voice/orchestrator.py rename to kazeia_central/features/voices/orchestrator.py index e1356e4..7c4b5af 100644 --- a/kazeia_central/voice/orchestrator.py +++ b/kazeia_central/features/voices/orchestrator.py @@ -18,9 +18,9 @@ import posixpath import tempfile from typing import Any -from ..adb import Adb -from ..provider import ProviderClient -from ..store import Store +from ...core.adb import Adb +from ...core.provider import ProviderClient +from ...core.store import Store from . import admin_ingest, transfer from .bridge import VoiceBridge diff --git a/kazeia_central/features/voices/router.py b/kazeia_central/features/voices/router.py new file mode 100644 index 0000000..d3f7c1a --- /dev/null +++ b/kazeia_central/features/voices/router.py @@ -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 diff --git a/kazeia_central/voice/transcribe.py b/kazeia_central/features/voices/transcribe.py similarity index 100% rename from kazeia_central/voice/transcribe.py rename to kazeia_central/features/voices/transcribe.py diff --git a/kazeia_central/voice/transfer.py b/kazeia_central/features/voices/transfer.py similarity index 98% rename from kazeia_central/voice/transfer.py rename to kazeia_central/features/voices/transfer.py index d558854..6a4ed69 100644 --- a/kazeia_central/voice/transfer.py +++ b/kazeia_central/features/voices/transfer.py @@ -13,7 +13,7 @@ from __future__ import annotations 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 # permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier voix OmniVoice cible. diff --git a/kazeia_central/voice/worker.py b/kazeia_central/features/voices/worker.py similarity index 97% rename from kazeia_central/voice/worker.py rename to kazeia_central/features/voices/worker.py index 12fefb4..81707de 100644 --- a/kazeia_central/voice/worker.py +++ b/kazeia_central/features/voices/worker.py @@ -1,7 +1,7 @@ """Worker d'enrôlement vocal — process persistant sous ov_venv. 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 jobs via un protocole JSON-lines sur stdin/stdout. diff --git a/tests/test_adb_parse.py b/tests/test_adb_parse.py index c5932b1..d0d7e66 100644 --- a/tests/test_adb_parse.py +++ b/tests/test_adb_parse.py @@ -1,8 +1,8 @@ import subprocess -from kazeia_central.adb import parse_content_query -from kazeia_central.adb.client import Adb, AdbError, parse_call_bundle -from kazeia_central.provider import models as m +from kazeia_central.core.adb import parse_content_query +from kazeia_central.core.adb.client import Adb, AdbError, parse_call_bundle +from kazeia_central.core.provider import models as m def test_parse_rows(): diff --git a/tests/test_api_store.py b/tests/test_api_store.py index abfb73c..f630de4 100644 --- a/tests/test_api_store.py +++ b/tests/test_api_store.py @@ -3,10 +3,10 @@ import nacl.pwhash from fastapi.testclient import TestClient -from kazeia_central.adb import Adb -from kazeia_central.adb.client import AdbDevice -from kazeia_central.api.app import create_app -from kazeia_central.store import Store +from kazeia_central.core.adb import Adb +from kazeia_central.core.adb.client import AdbDevice +from kazeia_central.app import create_app +from kazeia_central.core.store import Store _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN diff --git a/tests/test_fleet.py b/tests/test_fleet.py index 71fa4d1..615881d 100644 --- a/tests/test_fleet.py +++ b/tests/test_fleet.py @@ -3,11 +3,11 @@ import nacl.pwhash from fastapi.testclient import TestClient -from kazeia_central.adb import Adb, AdbError -from kazeia_central.adb.client import AdbDevice -from kazeia_central.api.app import create_app -from kazeia_central.fleet import device_overview, fan_out -from kazeia_central.store import Store +from kazeia_central.core.adb import Adb, AdbError +from kazeia_central.core.adb.client import AdbDevice +from kazeia_central.app import create_app +from kazeia_central.features.fleet.service import device_overview, fan_out +from kazeia_central.core.store import Store class _ReadyAdb(Adb): diff --git a/tests/test_store.py b/tests/test_store.py index 6e394c6..5675209 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -2,7 +2,7 @@ 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 _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN diff --git a/tests/test_voice.py b/tests/test_voice.py index d3af245..13b3482 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -10,12 +10,12 @@ 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 +from kazeia_central.core.adb import Adb +from kazeia_central.core.adb.client import AdbDevice +from kazeia_central.core.store import Store +from kazeia_central.features.voices import admin_ingest +from kazeia_central.features.voices import orchestrator as vo +from kazeia_central.features.voices import transfer _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN @@ -249,14 +249,14 @@ def test_sync_admin_delete_cleans(tmp_path): # ---- déclencheur auto ----------------------------------------------------- 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 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 + from kazeia_central.features.voices.autosync import VoiceAutoSync s = _store(tmp_path) a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), s, _Bridge()) a.set_enabled(True, delete_source=False)