303 lines
12 KiB
Python
303 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
|
|
|
|
|
|
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" est
|
|
# capté 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()
|
|
|
|
@app.get("/api/voices/{serial}")
|
|
def voices_list(serial: str):
|
|
return _guard(lambda: vo.list_voices(adb, store, serial))
|
|
|
|
# Voix enregistrées via l'admin (manifestes record-time : scope/owner/consentement).
|
|
# Déclarées avant /{serial}/{voice_id}/... — "admin" ne doit pas être pris pour un id.
|
|
@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}/{voice_id}/prepare")
|
|
def voices_prepare(serial: str, voice_id: str):
|
|
# pull + archive chiffré + transcription (ASR OmniVoice, à relire avant enrôlement)
|
|
return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id,
|
|
now=_now_ms(), actor=_OPERATOR))
|
|
|
|
@app.post("/api/voices/{serial}/{voice_id}/enroll")
|
|
def voices_enroll(serial: str, voice_id: str, body: _Enroll):
|
|
return _guard(lambda: vo.enroll_deploy(
|
|
adb, store, bridge, serial, voice_id, body.transcription,
|
|
delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR))
|
|
|
|
@app.post("/api/voices/{serial}/{voice_id}/lock")
|
|
def voices_lock(serial: str, voice_id: str, body: _Lock):
|
|
_guard(lambda: store.lock_voice(serial, voice_id, body.profile_id, now=_now_ms()))
|
|
store.audit("voice_lock", actor=_OPERATOR, target=f"{serial}/{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/{serial}/{voice_id}/unlock")
|
|
def voices_unlock(serial: str, voice_id: str):
|
|
_guard(lambda: store.unlock_voice(serial, voice_id, now=_now_ms()))
|
|
store.audit("voice_unlock", actor=_OPERATOR, target=f"{serial}/{voice_id}", now=_now_ms())
|
|
return {"voice_id": voice_id, "locked_profile_id": None}
|
|
|
|
@app.post("/api/voices/{serial}/sync")
|
|
def voices_sync(serial: str, body: _Sync):
|
|
return _guard(lambda: vo.sync_device(
|
|
adb, store, bridge, serial, delete_source=body.delete_source,
|
|
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()
|