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>
295 lines
12 KiB
Python
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
|