Kazeia-central/kazeia_central/api/app.py

309 lines
12 KiB
Python

"""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()