diff --git a/docs/PROVIDER_RPC_SPEC.md b/docs/PROVIDER_RPC_SPEC.md index d9bc69b..ce30655 100644 --- a/docs/PROVIDER_RPC_SPEC.md +++ b/docs/PROVIDER_RPC_SPEC.md @@ -14,6 +14,33 @@ --- +## ✅ LIVRÉ — app patiente `com.kazeia` vc19 / 0.2.7 (2026-07-01) + +Toutes les méthodes de la checklist §9 sont implémentées et **validées sur device** +(round-trip texte riche avec `:` + sauts de ligne intact via base64, export file-drop +pullable + purge). Implémentation : commit `a420d00` (`KazeiaTelemetryProvider.call()` ++ `ConfigStore.exportJson()/applyPatchJson()`). + +**Écarts assumés à connaître côté Kazeia-central :** +1. **`conversations_export` = PLAINTEXT MVP.** Le chiffrement hybride libsodium (§4.3) + est **différé** (pas de dépendance JNA/lazysodium ajoutée pour ce jalon). Le fichier + `.ndjson` est écrit **en clair** dans `Android/data/com.kazeia/files/exports/`. + → **Kazeia-central DOIT `adb pull` puis appeler `export_purge` immédiatement.** + Garde-fous en place : purge auto des exports > 24 h à l'`onCreate`, et + `export_purge` refuse tout chemin hors du dossier `exports/`. Le param `recipient` + est accepté mais **ignoré** tant que le chiffrement n'est pas livré (à planifier si + la PII doit transiter par un stockage non-fiable). +2. **`rag_sync` renvoie `error=not_found` si le pipeline n'est pas chargé** + (`RagHolder.instance` nul — ex. tablette fraîche, `KazeiaService` jamais démarré). + À appeler après que l'app patiente a tourné au moins une fois. +3. **Gate token (§6) = phase 1 (désactivé).** `token_required=false` dans + `capabilities`. La plomberie `checkToken()` est en place (comparaison temps constant + sur les méthodes mutantes + export) ; il reste à provisionner le secret côté + Kazeia-central puis à activer (lecture `EncryptedSharedPreferences`). +4. `provider_schema = 2`. + +--- + ## 0. Pourquoi (le problème à régler) La sortie texte d'`adb shell content` est cassée pour le texte riche, **dans les deux diff --git a/kazeia_central/core/context.py b/kazeia_central/core/context.py index 00c3927..d9ba772 100644 --- a/kazeia_central/core/context.py +++ b/kazeia_central/core/context.py @@ -51,7 +51,7 @@ class Ctx: except IndexError as e: # endpoint a renvoyé 0 ligne raise HTTPException(404, "aucune donnée") from e except Exception as e: - code = getattr(type(e), "http_status", None) + code = getattr(e, "http_status", None) # classe (VoiceWorkerError) ou instance (RpcError) if code: raise HTTPException(code, str(e)) from e raise diff --git a/kazeia_central/core/provider/__init__.py b/kazeia_central/core/provider/__init__.py index 78b18e5..47d4e03 100644 --- a/kazeia_central/core/provider/__init__.py +++ b/kazeia_central/core/provider/__init__.py @@ -1,4 +1,4 @@ -from .client import ProviderClient +from .client import ProviderClient, RpcError from . import models -__all__ = ["ProviderClient", "models"] +__all__ = ["ProviderClient", "RpcError", "models"] diff --git a/kazeia_central/core/provider/client.py b/kazeia_central/core/provider/client.py index 21cc93f..d452760 100644 --- a/kazeia_central/core/provider/client.py +++ b/kazeia_central/core/provider/client.py @@ -9,10 +9,22 @@ 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 @@ -79,15 +91,69 @@ class ProviderClient: def update_install(self) -> dict: return self.adb.content_call("update_install", serial=self.serial) - # ---- bloqué tant que PROVIDER_RPC_SPEC n'est pas livré côté app --------- - def dump_config(self): - raise NotImplementedError( - "Nécessite la méthode call() `cfg_dump_json` (PROVIDER_RPC_SPEC §2.1) " - "— non livrée côté app patiente." - ) + # ---- 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 session_turns(self, session_id: str): - raise NotImplementedError( - "Le texte des tours contient des sauts de ligne → `content query` casse. " - "Passer par `conversations_export` (PROVIDER_RPC_SPEC §4) une fois livré." - ) + 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")