cloud_knowledge.py 7.84 KB
#!/usr/bin/env python3
"""
Sprint 11 — Collective Knowledge Network client.
Uploads structural operation knowledge (not user data) to cloud API.
Pulls verified execution paths from cloud before local discovery.
"""
from __future__ import annotations

import json
import os
import time
import urllib.request
import urllib.error
from pathlib import Path

CLOUD_API = os.environ.get("HERMES_CLOUD_API", "https://api.hermes-agent.ai/v1")
CLOUD_KEY = os.environ.get("HERMES_CLOUD_KEY", "")
LOCAL_CACHE = Path(os.environ.get(
    "CLOUD_CACHE_DIR",
    str(Path(os.environ.get("HOME", "/data/data/com.termux/files/home")) / ".hermes" / "cloud_cache")
))
UPLOAD_LOG = Path(os.environ.get(
    "UPLOAD_LOG",
    str(Path(os.environ.get("HOME", "/data/data/com.termux/files/home")) / ".hermes" / "cloud_uploads.jsonl")
))

# Schema version for knowledge entries
SCHEMA_VERSION = "1.0"


# ──── Knowledge Entry Format (S11-01) ─────────────────────────────────────────

def make_knowledge_entry(
    intent: str,
    app: str,
    app_version: str,
    execution_path: list[dict],
    success_rate: float = 1.0,
    notes: str = "",
) -> dict:
    """
    Create a knowledge entry for upload.
    Only structural knowledge — no user data (content, contacts, messages).
    """
    return {
        "schema_version": SCHEMA_VERSION,
        "intent": intent,
        "app": app,
        "app_version": app_version,
        "execution_path": execution_path,
        "success_rate": success_rate,
        "notes": notes,
        "validated_by": 1,
        "ts": int(time.time()),
        "device_type": "pixel_8_pro",
        "android_version": "14",
    }


# ──── Upload client (S11-02) ──────────────────────────────────────────────────

def upload_knowledge(entry: dict) -> dict:
    """Upload a knowledge entry to cloud API."""
    if not CLOUD_KEY:
        return {"error": "HERMES_CLOUD_KEY not set", "skipped": True}

    LOCAL_CACHE.mkdir(parents=True, exist_ok=True)

    # Log locally regardless of upload success
    _log_entry = {**entry, "upload_ts": int(time.time())}
    with open(UPLOAD_LOG, "a", encoding="utf-8") as f:
        f.write(json.dumps(_log_entry, ensure_ascii=False) + "\n")

    # Upload to cloud
    try:
        payload = json.dumps(entry).encode()
        req = urllib.request.Request(
            f"{CLOUD_API}/knowledge",
            data=payload,
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {CLOUD_KEY}",
                "X-Schema-Version": SCHEMA_VERSION,
            },
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        return {"error": f"HTTP {e.code}", "body": e.read().decode()[:200]}
    except Exception as e:
        # Store locally for retry
        pending_path = LOCAL_CACHE / f"pending_{int(time.time())}.json"
        pending_path.write_text(json.dumps(entry, ensure_ascii=False), encoding="utf-8")
        return {"error": str(e), "pending": str(pending_path)}


# ──── Pull client (S11-03) ────────────────────────────────────────────────────

def pull_knowledge(app: str, intent: str, app_version: str = "") -> list[dict]:
    """
    Pull verified knowledge from cloud for a given app/intent.
    Returns list of execution_path entries sorted by success_rate descending.
    Falls back to local cache if cloud is unavailable.
    """
    LOCAL_CACHE.mkdir(parents=True, exist_ok=True)
    cache_key = f"{app}_{intent}_{app_version}".replace("/", "_")
    cache_file = LOCAL_CACHE / f"{cache_key}.json"

    # Try cloud first
    if CLOUD_KEY:
        try:
            params = f"app={app}&intent={intent}"
            if app_version:
                params += f"&app_version={app_version}"
            req = urllib.request.Request(
                f"{CLOUD_API}/knowledge?{params}",
                headers={"Authorization": f"Bearer {CLOUD_KEY}"},
            )
            with urllib.request.urlopen(req, timeout=5) as resp:
                data = json.loads(resp.read().decode())
                entries = data.get("entries", [])
                # Cache locally
                cache_file.write_text(
                    json.dumps(entries, ensure_ascii=False), encoding="utf-8")
                return entries
        except Exception:
            pass

    # Fallback to local cache
    if cache_file.exists():
        try:
            return json.loads(cache_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    return []


def get_best_execution_path(app: str, intent: str, app_version: str = "") -> list[dict] | None:
    """Return the highest success_rate execution path from cloud/cache, or None."""
    entries = pull_knowledge(app, intent, app_version)
    if not entries:
        return None
    entries.sort(key=lambda e: e.get("success_rate", 0), reverse=True)
    return entries[0].get("execution_path")


# ──── Auto-upload after successful operations ──────────────────────────────────

def record_success(app: str, intent: str, method: str, app_version: str = "") -> dict:
    """Record a successful operation execution to cloud (async-safe)."""
    entry = make_knowledge_entry(
        intent=intent,
        app=app,
        app_version=app_version,
        execution_path=[{"method": method, "step": 1, "result": "success"}],
        success_rate=1.0,
    )
    return upload_knowledge(entry)


def retry_pending_uploads() -> dict:
    """Retry any locally-cached pending uploads."""
    if not CLOUD_KEY:
        return {"skipped": True}
    LOCAL_CACHE.mkdir(parents=True, exist_ok=True)
    pending = list(LOCAL_CACHE.glob("pending_*.json"))
    uploaded = 0
    failed = 0
    for p in pending:
        try:
            entry = json.loads(p.read_text(encoding="utf-8"))
            result = upload_knowledge(entry)
            if "error" not in result or result.get("skipped"):
                p.unlink()
                uploaded += 1
            else:
                failed += 1
        except Exception:
            failed += 1
    return {"uploaded": uploaded, "failed": failed, "pending_remaining": failed}


if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1 and sys.argv[1] == "test":
        # Test with sample knowledge entry (no real upload without API key)
        entry = make_knowledge_entry(
            intent="send_message",
            app="wechat",
            app_version="8.0.47",
            execution_path=[
                {"method": "accessibility", "step": 1, "action": "am_start", "target": "LauncherUI"},
                {"method": "accessibility", "step": 2, "action": "input_text", "content": "<msg>"},
                {"method": "accessibility", "step": 3, "action": "keyevent", "key": "ENTER"},
            ],
            success_rate=0.95,
        )
        print("Knowledge entry:")
        print(json.dumps(entry, ensure_ascii=False, indent=2))

        # Test upload (will fail without API key)
        result = upload_knowledge(entry)
        print("\nUpload result:", json.dumps(result, ensure_ascii=False))

        # Test pull
        best = get_best_execution_path("wechat", "send_message")
        print("\nBest execution path from cloud:", best)
    else:
        print("Usage: python3 cloud_knowledge.py test")
        print(f"CLOUD_API: {CLOUD_API}")
        print(f"CLOUD_KEY: {'set' if CLOUD_KEY else 'NOT SET'}")
        pending = list(LOCAL_CACHE.glob("pending_*.json")) if LOCAL_CACHE.exists() else []
        print(f"Pending uploads: {len(pending)}")