svrnty-hermes-webui-plugin/routes/adwright.py
Svrnty 01825190a3 feat(adwright panel): wire GetCycleMetrics into Overview KPIs
Frontend now drives Impressions / CTR / Spend from real per-variant
metrics (cycle_metric) rather than hardcoded zeros + fake +12% / -0.3%
deltas. Adds state.data.metricsByCycle keyed by cycle id; populated
via _fetchCycleMetrics which hits the panel-update endpoint directly
(no CMO chat dispatch needed — the route calls gRPC live per request).

After every cycles load (refresh-cycles or list-cycles ingest), fans
out one metrics fetch per cycle. _deriveKpis flattens all metrics
arrays, sums impressions/clicks/spend, computes real CTR from
clicks/impressions. Spend bar now reflects $spend / $budget_total
from real values instead of falling back to the $6,000 default floor.

Plugin route _real_payload_for gains a new branch:
adwright_get_cycle_metrics?cycle_id=N → core.get_cycle_metrics_tool(N).

Fake deltas dropped — real deltas need a previous-window snapshot
the backend doesn't supply yet.

Verified live: metricsByCycle.1 populated with backend's [{variant_id:1,
impressions:0, ...}] record. All metric values still zero in this
sandbox because no ads have dispatched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:57:10 -04:00

295 lines
12 KiB
Python

"""Adwright tool panel — plugin backend routes.
Per ADWRIGHT-PANEL-PRD §5-§6:
GET /api/adwright/last-panel-update — read channel for the frontend panel
(polled while a /adwright cmd is
pending). v2: imports adwright_core
from the adwright-mcp subdir and
calls Adwright gRPC directly. Falls
back to mock if import fails.
POST /api/adwright/provision-creds — governance exception write path.
Writes Meta + Woo credentials direct
to the credctl vault, bypassing chat
and MCP. NO secret ever logged.
Public API surface used: api.register_route, api.logger.
adwright_core import is a workspace-internal dependency on adwright-mcp/
(same hermes/ workspace; same Python venv at runtime). Documented in
CONNECTION-MAP.md.
"""
import json
import os
import subprocess
import sys
import time
import urllib.parse
# Lazy-import adwright_core from the adwright-mcp subdir. Only loaded when
# the panel first asks for real data. If the import fails (proto version
# mismatch, adwright-mcp not present), routes fall back to mock data.
_ADWRIGHT_MCP_PATH = "/home/svrnty/workspaces/hermes/adwright-mcp"
_adwright_core = None
_adwright_core_err: str | None = None
def _load_adwright_core():
global _adwright_core, _adwright_core_err
if _adwright_core is not None or _adwright_core_err is not None:
return _adwright_core
try:
if _ADWRIGHT_MCP_PATH not in sys.path:
sys.path.insert(0, _ADWRIGHT_MCP_PATH)
import adwright_core # type: ignore
_adwright_core = adwright_core
return _adwright_core
except Exception as e:
_adwright_core_err = f"{type(e).__name__}: {e}"
return None
# Same credctl path as routes/vault_status.py (consistency).
_DEFAULT_CREDCTL = "/home/svrnty/workspaces/cortex/L6-svrnty.core-credentials/credctl"
# Credential keys the panel form is allowed to write. Anything else in the
# POST body is silently dropped — defense in depth against a compromised
# frontend trying to write arbitrary vault keys.
_ALLOWED_CRED_KEYS = frozenset({
"meta-app-id",
"meta-app-secret",
"meta-sandbox-access-token",
"meta-sandbox-ad-account",
"meta-sandbox-page-id",
"woocommerce-ck",
"woocommerce-cs",
})
def register(api):
"""Wire both Adwright plugin routes."""
log = api.logger("svrnty.routes.adwright")
api.register_route(
"/api/adwright/last-panel-update", "GET", _handle_last_panel_update)
api.register_route(
"/api/adwright/provision-creds", "POST", _handle_provision_creds)
log.info("adwright panel routes registered (last-panel-update + provision-creds)")
# ── GET /api/adwright/last-panel-update ─────────────────────────────────────
# v1: until adwright-mcp writes structured payloads to the session DB
# (PRD §5 step [8], table adwright_panel_updates), this returns mocked
# data keyed by ?tool= so the panel can be exercised end-to-end.
# Real wiring lands when adwright-mcp ships in parallel.
_MOCK_CYCLES = [
{"id": 14, "title": "Cycle #14 · May 2026", "status": "running",
"started_at": "2026-05-20", "spend": 4210, "budget": 6000,
"impressions": 1240000, "ctr": 2.1},
{"id": 13, "title": "Cycle #13 · Apr 2026", "status": "complete",
"started_at": "2026-04-15", "spend": 5800, "budget": 6000,
"impressions": 980000, "ctr": 1.8},
{"id": 12, "title": "Cycle #12 · Mar 2026", "status": "complete",
"started_at": "2026-03-10", "spend": 5990, "budget": 6000,
"impressions": 1100000, "ctr": 2.4},
]
_MOCK_SEGMENTS = [
{"id": "seg-1", "name": "Lookalike — buyers 30d", "size": 850000,
"description": "Meta lookalike from WooCommerce buyers, 30-day window"},
{"id": "seg-2", "name": "Cart abandoners", "size": 12400,
"description": "Pixel: AddToCart without Purchase, 14d"},
{"id": "seg-3", "name": "Newsletter subscribers", "size": 4800,
"description": "Mailchimp list sync"},
]
_MOCK_RECIPES = [
{"id": "rec-1", "name": "Prospecting · video"},
{"id": "rec-2", "name": "Retargeting · carousel"},
{"id": "rec-3", "name": "Brand · static"},
]
_MOCK_CONNECTIONS = [
{"id": "meta", "name": "Meta (Facebook + Instagram)", "status": "ok", "ok": True},
{"id": "woocommerce", "name": "WooCommerce", "status": "ok", "ok": True},
]
def _mock_payload_for(tool: str) -> dict:
if tool in ("adwright_list_cycles", "adwright_refresh_cycles"):
return {"cycles": _MOCK_CYCLES}
if tool == "adwright_get_cycle":
# Return cycle #14's expanded form with mock variants.
return {
"id": 14, "title": "Cycle #14 · May 2026", "status": "running",
"variants": [
{"id": "v-1", "name": "video · audience A", "status": "active",
"impressions": 540000},
{"id": "v-2", "name": "video · audience B", "status": "active",
"impressions": 410000},
{"id": "v-3", "name": "carousel · retarget",
"status": "paused", "impressions": 290000},
],
}
if tool == "adwright_list_segments":
return {"segments": _MOCK_SEGMENTS}
if tool == "adwright_list_recipes":
return {"recipes": _MOCK_RECIPES}
if tool == "adwright_get_connections_status":
return {"connections": _MOCK_CONNECTIONS}
return {}
# Real-data path — calls adwright_core directly. adwright_core tool functions
# return JSON strings; we parse + return the dict for the panel.
# Returns None when the tool isn't recognized or adwright_core unavailable.
def _real_payload_for(tool: str, qs: dict) -> dict | None:
core = _load_adwright_core()
if core is None:
return None
try:
if tool == "adwright_list_cycles":
limit = int((qs.get("limit", ["20"])[0] or "20"))
raw = core.list_cycles_tool(limit=limit)
elif tool == "adwright_refresh_cycles":
raw = core.refresh_cycles_tool()
# Also load fresh cycle list for the panel to re-render.
cycles_raw = core.list_cycles_tool(limit=20)
refresh_dict = json.loads(raw)
cycles_dict = json.loads(cycles_raw)
return {**refresh_dict, **cycles_dict}
elif tool == "adwright_get_cycle":
cycle_id = int((qs.get("cycle_id", ["0"])[0] or "0"))
raw = core.get_cycle_tool(cycle_id=cycle_id)
elif tool == "adwright_list_segments":
raw = core.list_segments_tool()
elif tool == "adwright_list_recipes":
limit = int((qs.get("limit", ["10"])[0] or "10"))
raw = core.list_recipes_tool(limit=limit)
elif tool == "adwright_get_connections_status":
raw = core.get_connections_status_tool()
elif tool == "adwright_get_cycle_metrics":
cycle_id = int((qs.get("cycle_id", ["0"])[0] or "0"))
raw = core.get_cycle_metrics_tool(cycle_id=cycle_id)
else:
return None
return json.loads(raw) if isinstance(raw, str) else raw
except Exception:
return None
def _handle_last_panel_update(handler, parsed):
"""GET /api/adwright/last-panel-update?session_id=&since=&tool=
v1 returns a mocked update for the requested tool. Real impl will read
from session DB (table: adwright_panel_updates) once adwright-mcp writes
structured payloads — see PRD §5 step [8] and §9 decision #12.
"""
qs = urllib.parse.parse_qs(parsed.query or "")
tool = (qs.get("tool", [""])[0] or "").strip()
since = qs.get("since", ["0"])[0]
try:
since_ts = int(since)
except (TypeError, ValueError):
since_ts = 0
if not tool:
# No tool specified → nothing pending; return empty (frontend treats
# as "no update").
return _send_json(handler, {"update": None, "mock": True}, 200)
now_ms = int(time.time() * 1000)
if since_ts and (now_ms - since_ts) < 500:
# Spec polls every 2s; if frontend just got an update <500ms ago,
# return empty so we don't double-render.
return _send_json(handler, {"update": None}, 200)
# Real-data first; mock fallback if adwright_core unavailable.
payload = _real_payload_for(tool, qs)
is_mock = False
if payload is None:
payload = _mock_payload_for(tool)
is_mock = True
if not payload:
return _send_json(handler, {"update": None, "mock": is_mock}, 200)
return _send_json(handler, {
"update": {
"ts": now_ms,
"tool": tool,
"payload": payload,
},
"mock": is_mock,
}, 200)
# ── POST /api/adwright/provision-creds ──────────────────────────────────────
def _handle_provision_creds(handler, parsed):
"""POST /api/adwright/provision-creds — direct vault write (PRD §6).
Body: JSON object {<cred-key>: <value>, ...}. Only keys in
_ALLOWED_CRED_KEYS are written; others are silently dropped. Values are
piped to `credctl set <key>` via stdin — never on argv, never in logs.
Returns {ok: bool, written: [keys], error?: str}. The list of WRITTEN
keys is safe to return (key names are public; values are not). Per PRD
§6, secrets NEVER touch CMO, adwright-mcp, hermes-agent, or session DB.
"""
length = int(handler.headers.get("Content-Length", "0") or 0)
if not length:
return _send_json(handler, {"ok": False, "error": "empty body"}, 400)
raw = handler.rfile.read(length)
try:
body = json.loads(raw.decode("utf-8"))
except Exception:
return _send_json(handler, {"ok": False, "error": "invalid JSON"}, 400)
if not isinstance(body, dict):
return _send_json(handler, {"ok": False, "error": "body must be object"}, 400)
credctl = os.environ.get("CREDCTL", _DEFAULT_CREDCTL)
written = []
errors = []
for key, val in body.items():
if key not in _ALLOWED_CRED_KEYS:
continue
if not isinstance(val, str) or not val.strip():
continue
try:
# credctl set <key> reads value from stdin; never on argv.
proc = subprocess.run(
[credctl, "set", key],
input=val,
text=True,
capture_output=True,
timeout=10,
)
if proc.returncode == 0:
written.append(key)
else:
# Never include `val` or proc.stderr in the response —
# stderr may echo the value back.
errors.append(key)
except Exception:
errors.append(key)
if errors and not written:
return _send_json(handler, {
"ok": False,
"written": [],
"error": "credctl write failed for: " + ",".join(errors),
}, 500)
return _send_json(handler, {
"ok": True,
"written": written,
"failed": errors,
}, 200)
# ── helpers ─────────────────────────────────────────────────────────────────
def _send_json(handler, payload: dict, status: int) -> bool:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(body)
return True