svrnty-hermes-webui-plugin/routes/bte_proxy.py
Svrnty d61f9e8d3f
Some checks failed
plugin-tests / test (push) Failing after 6s
feat(bte panel): wire grid to live /api/query/assetDtos (replaces 404'd assetGrid)
JP question: 'is there any way to see the generated images by the cmo inside the bte panel app'.

The Command Center PRD §5.4 specifies `/api/query/assetGrid` for the grid;
that endpoint isn't implemented yet on BTE. But `/api/query/assetDtos`
WORKS today and returns every brand-scoped asset including the ones CMO
just generated via bte_image_generate (asset ids 664787c4-... + dbe21e15-...).

Changes:
- routes/bte_proxy.py: allowlist /api/query/assetDtos + /api/assets/{id}/image
  (the latter so panel can render real PNG thumbnails — not just /thumb stubs)
- static/bte.js: _refreshGrid now POSTs assetDtos with {pageSize:48, sortBy:createdAt desc}.
  AssetDto rows normalized to panel's existing asset shape (id, thumbUrl,
  lifecycle, scores, prompt, dims). thumbUrl points at the live image bytes
  via the new proxy allowlist entry. _renderGrid stays untouched — same
  shape it expected.

Result: BTE panel grid now shows every Plan B asset including freshly-
generated CMO images. Polling stops when no in-flight renders remain.

When BTE Command Center §5.4 ships the real assetGrid endpoint, swap
the path back — frontend won't need any other change.

Karpathy 4 rules: smallest possible adapter (normalize one shape to another,
no abstraction added), surgical (one new allowlist entry, one function body
rewritten, no other panel logic touched), verified via curl that assetDtos
returns 10 assets including the CMO-generated ones before committing.
2026-05-24 14:15:22 -04:00

356 lines
15 KiB
Python

"""GET|POST /api/bte/proxy?path=<bte-endpoint> — same-origin proxy to BTE REST.
The browser hits the plugin (same origin as webui :8787), the plugin forwards
to BTE REST (:6001 by default). This avoids cross-origin CORS, keeps BTE
free of webui-origin allowances, and lets us whitelist exactly which BTE
endpoints the panel can reach.
Path A adapter: for POST /api/command/requestPhotoshoot the panel sends a
recipe-shape payload (brand slug + recipeSlug + items[] + variantsPerScenario)
that the current BTE backend doesn't yet accept (PRD §5.8 not shipped). The
adapter translates the panel shape into BTE's canonical shape (brand Guid +
fully-formed shots[]) before forwarding. All other proxied paths pass through
unchanged.
Configuration (read at call time, never persisted):
BTE_BASE_URL BTE REST base (default http://localhost:6001)
BTE_TENANT_ID default X-Tenant-Id forwarded to BTE (Plan B tenant uuid)
Public API surface used: api.register_route, api.logger.
Stdlib only — no urllib3, no requests.
"""
import json
import os
import random
import re
import urllib.error
import urllib.parse
import urllib.request
_DEFAULT_BTE_BASE = "http://localhost:6001"
_DEFAULT_TENANT = "00000000-0000-0000-0000-000000000001" # Plan B tenant
# Path A adapter constants — translation tables for the recipe-driven payload
# the panel sends (per static/bte.js _onGenerate) into the per-shot canonical
# shape BTE's RequestPhotoshootCommand accepts today.
# Mirror of sot/07-BRAND/PLANB-RECIPE-TAXONOMY.md §5 (5 families).
_RECIPE_PROMPTS = {
"hero-shot": "Centered hero product shot of {offering_name}, studio lighting, clean background, photoreal",
"lifestyle-shot": "Lifestyle scene featuring {offering_name} on a wood kitchen counter, golden hour, hands holding",
"photoshoot": "Catalog photoshoot of {offering_name}, multiple angles, neutral lighting",
"recipe-sheet": "Editorial recipe sheet: {offering_name} dish + 3 ingredient flat-lays, branded margin",
"montage-catalog": "Catalog grid montage featuring {offering_name}, unified treatment, top-down view",
}
_MODE_PREFIXES = {
"polished": "Magazine-grade hero, retouched. ",
"ugc": "Hand-held UGC feel, mild grain, off-axis. ",
"photoreal": "Photorealistic, neutral palette, no stylization. ",
"artistic": "Artistic editorial interpretation. ",
}
# POSITIVE-phrased Plan B hard rules — FLUX.2 negatives barely work
# (per bte/CLAUDE.md "Gotchas") so brand constraints ride as positives.
_BRAND_RULES = (
" Plan B brand: real plated dish on ceramic or wood serving surface; "
"QC region cues (wood grain, warm autumn tones); fr-CA context. "
"Strictly no steam plumes, no takeout containers, no branded labels, "
"no plastic packaging."
)
# Per-recipe default dimensions (panel does not send w/h yet).
_RECIPE_DIMS = {
"hero-shot": (1024, 1024),
"photoshoot": (1024, 1024),
"recipe-sheet": (1024, 1024),
"lifestyle-shot": (1024, 1280), # 4:5 portrait
"montage-catalog": (1536, 1024), # 3:2 landscape
}
# BTE RubricMode validator accepts only these two; panel modes "photoreal" /
# "artistic" fall back to "polished".
_RUBRIC_FALLBACK = {"polished": "polished", "ugc": "ugc"}
# Brand slug -> Guid cache, populated on first translation call.
_BRAND_ID_CACHE: dict = {}
# Whitelist of allowed BTE paths. Static prefixes match exact path; pattern
# entries match a literal `/api/assets/<id>/<suffix>` form. Keep tight — the
# proxy is a privilege amplifier, only the panel's needs go here.
_ALLOWED_EXACT = frozenset({
"/api/command/requestPhotoshoot",
"/api/command/rateAsset",
"/api/query/assetGrid", # PRD §5.4 (not yet built on BTE — 404 until ships)
"/api/query/assetDtos", # works today — pre-PRD grid
"/api/query/recipeStats",
})
# Pattern: /api/assets/<uuid-or-id>/(thumb|status|image). image = full bytes via /api/assets/{id}/image.
_ALLOWED_PATTERN = re.compile(r"^/api/assets/[A-Za-z0-9_\-]+/(thumb|status|image)$")
def register(api):
"""Wire the GET + POST /api/bte/proxy routes."""
log = api.logger("svrnty.routes.bte_proxy")
api.register_route("/api/bte/proxy", "GET", _handle_proxy)
api.register_route("/api/bte/proxy", "POST", _handle_proxy)
log.info("bte proxy endpoint registered (GET+POST)")
def _is_allowed(path: str) -> bool:
if path in _ALLOWED_EXACT:
return True
if _ALLOWED_PATTERN.match(path):
return True
return False
def _handle_proxy(handler, parsed):
"""Forward a single request to BTE. Returns BTE response body verbatim."""
qs = urllib.parse.parse_qs(parsed.query or "")
target_path = (qs.get("path") or [""])[0].strip()
if not target_path or not target_path.startswith("/api/"):
return _send_json(handler, {"ok": False, "error": "missing or invalid path"}, 400)
if not _is_allowed(target_path):
return _send_json(handler, {"ok": False, "error": f"path not allowed: {target_path}"}, 403)
base = os.environ.get("BTE_BASE_URL", _DEFAULT_BTE_BASE).rstrip("/")
tenant = (handler.headers.get("X-Tenant-Id")
or os.environ.get("BTE_TENANT_ID", _DEFAULT_TENANT))
target_url = base + target_path
method = handler.command # 'GET' / 'POST'
body = b""
content_type = handler.headers.get("Content-Type", "application/json")
if method in ("POST", "PUT", "PATCH"):
length = int(handler.headers.get("Content-Length", "0") or 0)
if length > 0:
body = handler.rfile.read(length)
# Path A adapter: rewrite the panel-shape payload into BTE-canonical shape
# for requestPhotoshoot only. All other endpoints pass through verbatim.
if method == "POST" and target_path == "/api/command/requestPhotoshoot" and body:
try:
panel_payload = json.loads(body.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as e:
return _send_json(handler, {"ok": False, "error": f"invalid JSON: {e}"}, 400)
if _looks_like_panel_payload(panel_payload):
try:
bte_payload = _translate_panel_to_bte(panel_payload, base, tenant)
except _AdapterError as e:
return _send_json(handler, {"ok": False, "error": str(e)}, e.status)
body = json.dumps(bte_payload).encode("utf-8")
content_type = "application/json"
req = urllib.request.Request(target_url, data=body if body else None, method=method)
req.add_header("X-Tenant-Id", tenant)
if body:
req.add_header("Content-Type", content_type)
auth = handler.headers.get("Authorization")
if auth:
req.add_header("Authorization", auth)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read()
resp_ctype = resp.headers.get("Content-Type", "application/octet-stream")
resp_status = resp.status
except urllib.error.HTTPError as e:
# Forward BTE's own error status + body — panel renders "endpoint coming
# soon" placeholders when it sees 404/501.
resp_body = e.read() or b""
resp_ctype = e.headers.get("Content-Type", "application/json") if e.headers else "application/json"
resp_status = e.code
except urllib.error.URLError as e:
return _send_json(handler, {"ok": False, "error": f"bte unreachable: {e.reason}"}, 502)
except Exception as e:
return _send_json(handler, {"ok": False, "error": f"proxy error: {e}"}, 500)
handler.send_response(resp_status)
handler.send_header("Content-Type", resp_ctype)
handler.send_header("Content-Length", str(len(resp_body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(resp_body)
return True
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
# ── Path A adapter helpers ───────────────────────────────────────────────────
class _AdapterError(Exception):
"""Translation aborted — propagate status + reason back to the caller."""
def __init__(self, message: str, status: int = 502):
super().__init__(message)
self.status = status
def _looks_like_panel_payload(payload) -> bool:
"""Heuristic: panel payloads have 'recipeSlug' + 'items'; canonical BTE
payloads have 'shots'. Routing on field presence lets a future caller post
the canonical shape directly without going through translation.
"""
return (
isinstance(payload, dict)
and "recipeSlug" in payload
and "items" in payload
and "shots" not in payload
)
def _resolve_brand_id(brand_slug: str, base: str, tenant: str) -> str:
"""Slug -> Guid via /api/query/brandDtos. Cached in-process; on cache miss
we hit BTE once. If a slug we've never seen comes in we refresh the whole
cache (BTE returns all brands in a single call, so this is cheap).
"""
if not brand_slug:
raise _AdapterError("brandId (slug) is required", status=400)
# UUID passthrough: panel only ever sends slugs but be permissive — if a
# caller already resolved the Guid, accept it verbatim.
if re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", brand_slug):
return brand_slug
if brand_slug in _BRAND_ID_CACHE:
return _BRAND_ID_CACHE[brand_slug]
_refresh_brand_cache(base, tenant)
if brand_slug not in _BRAND_ID_CACHE:
raise _AdapterError(
f"brand slug '{brand_slug}' not found in BTE — refusing to seed",
status=404,
)
return _BRAND_ID_CACHE[brand_slug]
def _refresh_brand_cache(base: str, tenant: str) -> None:
"""Repopulate _BRAND_ID_CACHE from BTE's brandDtos query. Empty body would
400, so we POST an empty filter object.
"""
url = base.rstrip("/") + "/api/query/brandDtos"
body = b"{}"
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-Tenant-Id", tenant)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raise _AdapterError(
f"brandDtos lookup failed: HTTP {e.code}{e.read()[:200].decode('utf-8', 'replace')}",
status=502,
)
except urllib.error.URLError as e:
raise _AdapterError(f"brandDtos unreachable: {e.reason}", status=502)
data = payload.get("data") if isinstance(payload, dict) else None
if not data:
raise _AdapterError(
"BTE returned no brands — DB empty? cannot seed; stop and ask JP",
status=502,
)
_BRAND_ID_CACHE.clear()
for b in data:
slug = (b.get("name") or "").strip()
bid = (b.get("id") or "").strip()
if slug and bid:
_BRAND_ID_CACHE[slug] = bid
def _build_flux2_workflow_json(prompt: str, width: int, height: int) -> str:
"""Port of Svrnty.Bte.Shared.Recipes.StopgapFluxWorkflow.Build. BTE's
LocalFluxImageProvider rejects empty WorkflowJson with InvalidOperationException,
and the recipe-driven IRecipeAssembler isn't shipped yet (PRD §5.8 pending),
so the adapter inlines the same stopgap graph the C# side uses. Random
seed per call: ComfyUI dedupes identical workflows (execution_cached).
"""
seed = random.randint(1, 2**31 - 1)
graph = {
"5": {"class_type": "EmptySD3LatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["11", 0], "text": prompt}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["11", 0], "text": ""}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["10", 0]}},
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "bte", "images": ["8", 0]}},
"10": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}},
"11": {"class_type": "CLIPLoader", "inputs": {"clip_name": "mistral_3_small_flux2_fp8.safetensors", "type": "flux2"}},
"12": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux2_dev_fp8mixed.safetensors", "weight_dtype": "default"}},
"13": {"class_type": "KSampler", "inputs": {
"model": ["12", 0], "positive": ["26", 0], "negative": ["7", 0],
"latent_image": ["5", 0], "seed": seed, "steps": 20, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "simple", "denoise": 1.0,
}},
"26": {"class_type": "FluxGuidance", "inputs": {"conditioning": ["6", 0], "guidance": 2.5}},
}
return json.dumps(graph)
def _parse_mode_from_label(recipe_label: str, default: str = "polished") -> str:
"""Panel sends recipeLabel = '<family>__<mode>__<media>'. Extract mode."""
parts = (recipe_label or "").split("__")
if len(parts) >= 2 and parts[1] in _MODE_PREFIXES:
return parts[1]
return default
def _translate_panel_to_bte(panel: dict, base: str, tenant: str) -> dict:
"""Recipe-shape -> canonical RequestPhotoshootCommand payload."""
brand_slug = (panel.get("brandId") or "").strip()
brand_guid = _resolve_brand_id(brand_slug, base, tenant)
recipe_slug = (panel.get("recipeSlug") or "").strip()
if recipe_slug not in _RECIPE_PROMPTS:
raise _AdapterError(
f"unknown recipeSlug '{recipe_slug}' (expected one of: {sorted(_RECIPE_PROMPTS)})",
status=400,
)
mode = _parse_mode_from_label(panel.get("recipeLabel") or "")
rubric_mode = _RUBRIC_FALLBACK.get(mode, "polished")
items = panel.get("items") or []
if not isinstance(items, list) or not items:
raise _AdapterError("items[] is required and must be non-empty", status=400)
variants = panel.get("variantsPerScenario") or 1
try:
variants = max(1, min(12, int(variants)))
except (TypeError, ValueError):
variants = 1
width, height = _RECIPE_DIMS.get(recipe_slug, (1024, 1024))
prefix = _MODE_PREFIXES.get(mode, _MODE_PREFIXES["polished"])
template = _RECIPE_PROMPTS[recipe_slug]
shots = []
for item in items:
if not isinstance(item, dict):
continue
# Panel sends offeringName (per static/bte.js line 324); fall back to
# name/offeringId so a future caller doesn't have to send both.
offering_name = (
item.get("offeringName")
or item.get("name")
or str(item.get("offeringId") or "item")
)
prompt = prefix + template.format(offering_name=offering_name) + _BRAND_RULES
for _ in range(variants):
shots.append({
"prompt": prompt,
"workflowJson": _build_flux2_workflow_json(prompt, width, height),
"width": width,
"height": height,
})
if not shots:
raise _AdapterError("no shots produced — items[] had no usable entries", status=400)
return {
"brandId": brand_guid,
"shots": shots,
"autoEvaluate": bool(panel.get("autoEvaluate", False)),
"rubricMode": rubric_mode,
}