feat(provider): client RPC base64-JSON (débloqué — app patiente vc19 livrée)

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) <noreply@anthropic.com>
This commit is contained in:
alf 2026-07-01 18:01:24 +02:00
parent bf02f6ca5d
commit 8d90c49e54
4 changed files with 107 additions and 14 deletions

View File

@ -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

View File

@ -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

View File

@ -1,4 +1,4 @@
from .client import ProviderClient
from .client import ProviderClient, RpcError
from . import models
__all__ = ["ProviderClient", "models"]
__all__ = ["ProviderClient", "RpcError", "models"]

View File

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