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:
parent
bf02f6ca5d
commit
8d90c49e54
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from .client import ProviderClient
|
||||
from .client import ProviderClient, RpcError
|
||||
from . import models
|
||||
|
||||
__all__ = ["ProviderClient", "models"]
|
||||
__all__ = ["ProviderClient", "RpcError", "models"]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue