Kazeia-central/kazeia_central/api/app.py

126 lines
4.4 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
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from starlette.responses import Response
from ..adb import Adb, AdbError
from ..provider import ProviderClient
_WEB_DIR = Path(__file__).resolve().parent.parent / "web"
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) -> FastAPI:
adb = adb or Adb()
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 IndexError as e: # endpoint a renvoyé 0 ligne
raise HTTPException(status_code=404, detail="aucune donnée") from e
@app.get("/api/health")
def health():
return {"ok": True, "version": app.version}
@app.get("/api/devices")
def devices():
return [d.__dict__ for d in _guard(adb.devices)]
@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))]
# 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()