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:
parent
2894771bf4
commit
2731b37b20
|
|
@ -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` ~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).
|
||||||
|
|
@ -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__":
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from .app import create_app
|
|
||||||
|
|
||||||
__all__ = ["create_app"]
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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], *,
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue