Kazeia-central/kazeia_central/core/provider/client.py

160 lines
6.3 KiB
Python

"""Client typé du provider Kazeia, par tablette.
Enveloppe `Adb.content_query` et expose des accès typés (pydantic) aux endpoints
EXISTANTS du provider. Les écritures et la lecture du texte riche (config/prompts,
texte des conversations) dépendent des méthodes call() base64-JSON décrites dans
docs/PROVIDER_RPC_SPEC.md — non livrées côté app patiente → exposées ici comme
NotImplemented explicites pour cadrer l'intégration future.
"""
from __future__ import annotations
import base64
import json
from typing import Any
from ..adb import Adb, AdbError
from . import models as m
class RpcError(RuntimeError):
"""Échec logique d'une méthode call() base64-JSON (Bundle ok=false)."""
def __init__(self, code: str, method: str) -> None:
super().__init__(f"{method}: {code}")
self.code = code
self.http_status = 404 if code == "not_found" else 502 # lu par Ctx.guard
class ProviderClient:
def __init__(self, adb: Adb, serial: str) -> None:
self.adb = adb
self.serial = serial
def _q(self, path: str, **kw) -> list[dict]:
return self.adb.content_query(path, serial=self.serial, **kw)
def _one(self, path: str, **kw) -> dict:
"""Endpoint mono-ligne : réveille le provider si besoin (§3.5) et lève
une erreur explicite plutôt qu'un IndexError si toujours vide."""
rows = self._q(path, expect_rows=True, **kw)
if not rows:
raise AdbError(f"{path}: provider sans réponse (endormi ?) après réveil")
return rows[0]
# ---- lecture (endpoints actuels, valeurs simples) ----------------------
def state(self) -> m.StateRow:
return m.StateRow(**self._one("state"))
def turns(self) -> list[m.TurnRow]:
return [m.TurnRow(**r) for r in self._q("turns")]
def crashes(self) -> list[m.CrashRow]:
return [m.CrashRow(**r) for r in self._q("crashes")]
def models(self) -> list[m.ModelRow]:
return [m.ModelRow(**r) for r in self._q("models")]
def voices(self) -> list[m.VoiceRow]:
return [m.VoiceRow(**r) for r in self._q("voices")]
def rag_status(self) -> m.RagStatus:
return m.RagStatus(**self._one("rag_status"))
def rag_index(self) -> list[m.RagDocRow]:
return [m.RagDocRow(**r) for r in self._q("rag")]
def rag_query(self, text: str) -> list[m.RagHit]:
# text peut contenir des espaces → fragile via query ; OK pour un test simple.
return [m.RagHit(**r) for r in self._q("rag_query", where=text)]
def updates(self) -> m.UpdateStatus:
return m.UpdateStatus(**self._one("updates"))
def dist_config(self) -> m.DistConfig:
return m.DistConfig(**self._one("dist_config"))
def sessions(self, profile_id: str | None = None) -> list[m.SessionRow]:
path = f"conversations/profile/{profile_id}" if profile_id else "conversations"
return [m.SessionRow(**r) for r in self._q(path)]
def profiles(self) -> list[m.ProfileRow]:
return [m.ProfileRow(**r) for r in self._q("profiles")]
def active_profile_id(self) -> str | None:
rows = self._q("profiles/active", expect_rows=True)
return rows[0].get("active_profile_id") if rows else None
# ---- MAJ OTA (call() déjà disponibles côté app) ------------------------
def update_check(self) -> dict:
return self.adb.content_call("update_check", serial=self.serial)
def update_install(self) -> dict:
return self.adb.content_call("update_install", serial=self.serial)
# ---- RPC base64-JSON (livré app patiente vc19, provider_schema=2) -------
# Contourne la corruption de `content query`/`--bind` sur le texte riche : arg ET
# retour en base64(JSON). Bundle de retour : ok, error, result_b64 | path/count.
def rpc(self, method: str, payload: Any | None = None,
extras: dict[str, str] | None = None) -> Any:
b = self.adb.content_call(method, arg_json=payload, extras=extras, serial=self.serial)
if not b:
raise AdbError(f"call {method}: aucune réponse (provider endormi ?)")
if str(b.get("ok")).lower() != "true":
raise RpcError(b.get("error", "échec"), method)
if "result_b64" in b:
return json.loads(base64.b64decode(b["result_b64"]).decode("utf-8"))
return b # ok-seul, ou {path, count} (ex. conversations_export)
def capabilities(self) -> dict:
return self.rpc("capabilities")
def supports(self, method: str) -> bool:
try:
return method in (self.capabilities().get("supported_calls") or [])
except Exception:
return False
# config / presets
def cfg_dump(self) -> dict:
return self.rpc("cfg_dump_json")
def cfg_apply(self, patch: dict) -> dict:
return self.rpc("cfg_apply_json", patch)
def presets_dump(self) -> list:
return self.rpc("presets_dump_json")
def presets_apply(self, presets: list) -> dict:
return self.rpc("presets_apply_json", {"presets": presets})
# profils (fiche/langue/prompt riches)
def profile_dump(self, profile_id: str) -> dict:
return self.rpc("profile_dump_json", {"id": profile_id})
def profile_upsert(self, profile: dict) -> dict:
return self.rpc("profile_upsert_json", profile)
# RAG (authoring texte riche)
def rag_dump(self, source: str) -> dict:
return self.rpc("rag_dump_json", {"source": source})
def rag_upsert(self, source: str, text: str) -> dict:
return self.rpc("rag_upsert_json", {"source": source, "text": text})
def rag_sync(self) -> dict:
return self.rpc("rag_sync")
# conversations (file-drop NDJSON — pull puis export_purge, PLAINTEXT MVP)
def conversations_export(self, *, profile_id: str | None = None,
session_id: str | None = None, since: int | None = None,
until: int | None = None) -> dict:
f = {k: v for k, v in (("profile_id", profile_id), ("session_id", session_id),
("since", since), ("until", until)) if v is not None}
return self.rpc("conversations_export", f)
def export_purge(self, path: str | None = None) -> dict:
return self.rpc("export_purge", {"path": path} if path else {})
def voices_reload(self) -> dict:
return self.rpc("voices_reload")