feat(bte_proxy): Path A adapter — translate panel recipe-shape to BTE canonical
Some checks failed
plugin-tests / test (push) Failing after 2s
Some checks failed
plugin-tests / test (push) Failing after 2s
Panel's BTE Command Center posts {brandId(slug), recipeSlug, items[],
variantsPerScenario} which the current BTE backend doesn't accept (PRD §5.8
recipe-driven fan-out not shipped). Adapter intercepts POST
/api/command/requestPhotoshoot, resolves brand slug -> Guid via /api/query/brandDtos
(cached in-process), expands items × variants into per-shot entries, inlines the
StopgapFluxWorkflow FLUX.2 graph as workflowJson (LocalFluxImageProvider
rejects empty), parses mode from recipeLabel for rubricMode (polished|ugc;
photoreal/artistic fall back to polished per BTE validator). All other proxied
paths pass through unchanged. Stdlib-only.
Verified: real POST through adapter -> BTE returns 200 with saga-start envelope
(per-shot DB failure is BTE-internal, out of scope — adapter cleared schema).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
69e575ca59
commit
c6d94462c4
@ -26,9 +26,9 @@
|
|||||||
| `routes/adwright.py:68` | `api.logger` | `log = api.logger("svrnty.routes.adwright")` |
|
| `routes/adwright.py:68` | `api.logger` | `log = api.logger("svrnty.routes.adwright")` |
|
||||||
| `routes/adwright.py:69` | `api.register_route` | `api.register_route(` |
|
| `routes/adwright.py:69` | `api.register_route` | `api.register_route(` |
|
||||||
| `routes/adwright.py:71` | `api.register_route` | `api.register_route(` |
|
| `routes/adwright.py:71` | `api.register_route` | `api.register_route(` |
|
||||||
| `routes/bte_proxy.py:40` | `api.logger` | `log = api.logger("svrnty.routes.bte_proxy")` |
|
| `routes/bte_proxy.py:89` | `api.logger` | `log = api.logger("svrnty.routes.bte_proxy")` |
|
||||||
| `routes/bte_proxy.py:41` | `api.register_route` | `api.register_route("/api/bte/proxy", "GET", _handle_proxy)` |
|
| `routes/bte_proxy.py:90` | `api.register_route` | `api.register_route("/api/bte/proxy", "GET", _handle_proxy)` |
|
||||||
| `routes/bte_proxy.py:42` | `api.register_route` | `api.register_route("/api/bte/proxy", "POST", _handle_proxy)` |
|
| `routes/bte_proxy.py:91` | `api.register_route` | `api.register_route("/api/bte/proxy", "POST", _handle_proxy)` |
|
||||||
| `routes/transcribe.py:37` | `api.logger` | `log = api.logger("svrnty.routes.transcribe")` |
|
| `routes/transcribe.py:37` | `api.logger` | `log = api.logger("svrnty.routes.transcribe")` |
|
||||||
| `routes/transcribe.py:38` | `api.register_route` | `api.register_route("/api/transcribe", "POST", _handle_transcribe)` |
|
| `routes/transcribe.py:38` | `api.register_route` | `api.register_route("/api/transcribe", "POST", _handle_transcribe)` |
|
||||||
| `routes/transcribe.py:39` | `api.register_audio_attachment_processor` | `api.register_audio_attachment_processor(_transcribe_audio_attachments)` |
|
| `routes/transcribe.py:39` | `api.register_audio_attachment_processor` | `api.register_audio_attachment_processor(_transcribe_audio_attachments)` |
|
||||||
@ -53,9 +53,9 @@ _None. Plugin uses only the public API._ ✓
|
|||||||
|
|
||||||
| File | Line | URL |
|
| File | Line | URL |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `static/bte.js` | 322 | `/api/command/requestPhotoshoot` |
|
| `static/bte.js` | 329 | `/api/command/requestPhotoshoot` |
|
||||||
| `static/bte.js` | 361 | `/api/query/assetGrid` |
|
| `static/bte.js` | 368 | `/api/query/assetGrid` |
|
||||||
| `static/bte.js` | 475 | `/api/command/rateAsset` |
|
| `static/bte.js` | 482 | `/api/command/rateAsset` |
|
||||||
| `static/adwright.js` | 484 | `/api/adwright/provision-creds` |
|
| `static/adwright.js` | 484 | `/api/adwright/provision-creds` |
|
||||||
| `static/umbrella.js` | 41 | `/api/umbrella` |
|
| `static/umbrella.js` | 41 | `/api/umbrella` |
|
||||||
| `static/app.js` | 165 | `/api/vault/status` |
|
| `static/app.js` | 165 | `/api/vault/status` |
|
||||||
|
|||||||
@ -5,6 +5,13 @@ 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
|
free of webui-origin allowances, and lets us whitelist exactly which BTE
|
||||||
endpoints the panel can reach.
|
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):
|
Configuration (read at call time, never persisted):
|
||||||
BTE_BASE_URL BTE REST base (default http://localhost:6001)
|
BTE_BASE_URL BTE REST base (default http://localhost:6001)
|
||||||
BTE_TENANT_ID default X-Tenant-Id forwarded to BTE (Plan B tenant uuid)
|
BTE_TENANT_ID default X-Tenant-Id forwarded to BTE (Plan B tenant uuid)
|
||||||
@ -14,6 +21,7 @@ Stdlib only — no urllib3, no requests.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@ -22,6 +30,47 @@ import urllib.request
|
|||||||
_DEFAULT_BTE_BASE = "http://localhost:6001"
|
_DEFAULT_BTE_BASE = "http://localhost:6001"
|
||||||
_DEFAULT_TENANT = "00000000-0000-0000-0000-000000000001" # Plan B tenant
|
_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
|
# Whitelist of allowed BTE paths. Static prefixes match exact path; pattern
|
||||||
# entries match a literal `/api/assets/<id>/<suffix>` form. Keep tight — the
|
# entries match a literal `/api/assets/<id>/<suffix>` form. Keep tight — the
|
||||||
# proxy is a privilege amplifier, only the panel's needs go here.
|
# proxy is a privilege amplifier, only the panel's needs go here.
|
||||||
@ -73,6 +122,21 @@ def _handle_proxy(handler, parsed):
|
|||||||
if length > 0:
|
if length > 0:
|
||||||
body = handler.rfile.read(length)
|
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 = urllib.request.Request(target_url, data=body if body else None, method=method)
|
||||||
req.add_header("X-Tenant-Id", tenant)
|
req.add_header("X-Tenant-Id", tenant)
|
||||||
if body:
|
if body:
|
||||||
@ -115,3 +179,176 @@ def _send_json(handler, payload: dict, status: int) -> bool:
|
|||||||
handler.end_headers()
|
handler.end_headers()
|
||||||
handler.wfile.write(body)
|
handler.wfile.write(body)
|
||||||
return True
|
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,
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user