From 8d90c49e544b35d2d26e4b2190506ec694747286 Mon Sep 17 00:00:00 2001 From: alf Date: Wed, 1 Jul 2026 18:01:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(provider):=20client=20RPC=20base64-JSON=20?= =?UTF-8?q?(d=C3=A9bloqu=C3=A9=20=E2=80=94=20app=20patiente=20vc19=20livr?= =?UTF-8?q?=C3=A9e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le blocage est levé : PROVIDER_RPC_SPEC implémenté+validé côté app (vc19, schema 2). - ProviderClient.rpc() : content_call base64 → décode result_b64 (JSON) | {path,count} ; RpcError(code) sur ok=false (http_status 404 not_found sinon 502, lu par Ctx.guard). - Méthodes typées : capabilities/supports, cfg_dump/apply, presets_dump/apply, profile_dump/upsert, rag_dump/upsert, rag_sync, conversations_export, export_purge, voices_reload. - Validé LIVE sur vc19 : capabilities (16 méthodes), cfg_dump (prompt 718c avec ':' intact — ce qui casse en content query). Débloque config/presets, push profils (fiche+langue), authoring RAG, export conversations. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/PROVIDER_RPC_SPEC.md | 27 ++++++++ kazeia_central/core/context.py | 2 +- kazeia_central/core/provider/__init__.py | 4 +- kazeia_central/core/provider/client.py | 88 +++++++++++++++++++++--- 4 files changed, 107 insertions(+), 14 deletions(-) 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")