160 lines
6.3 KiB
Python
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")
|