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