From c6d94462c4fce77425fe30a8bb621c8ddc762a76 Mon Sep 17 00:00:00 2001 From: Svrnty Date: Sun, 24 May 2026 13:34:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(bte=5Fproxy):=20Path=20A=20adapter=20?= =?UTF-8?q?=E2=80=94=20translate=20panel=20recipe-shape=20to=20BTE=20canon?= =?UTF-8?q?ical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CONNECTION-MAP.md | 12 +-- routes/bte_proxy.py | 237 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 6 deletions(-) diff --git a/CONNECTION-MAP.md b/CONNECTION-MAP.md index 6f811f2..4bb434f 100644 --- a/CONNECTION-MAP.md +++ b/CONNECTION-MAP.md @@ -26,9 +26,9 @@ | `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: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:41` | `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:89` | `api.logger` | `log = api.logger("svrnty.routes.bte_proxy")` | +| `routes/bte_proxy.py:90` | `api.register_route` | `api.register_route("/api/bte/proxy", "GET", _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: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)` | @@ -53,9 +53,9 @@ _None. Plugin uses only the public API._ ✓ | File | Line | URL | |---|---|---| -| `static/bte.js` | 322 | `/api/command/requestPhotoshoot` | -| `static/bte.js` | 361 | `/api/query/assetGrid` | -| `static/bte.js` | 475 | `/api/command/rateAsset` | +| `static/bte.js` | 329 | `/api/command/requestPhotoshoot` | +| `static/bte.js` | 368 | `/api/query/assetGrid` | +| `static/bte.js` | 482 | `/api/command/rateAsset` | | `static/adwright.js` | 484 | `/api/adwright/provision-creds` | | `static/umbrella.js` | 41 | `/api/umbrella` | | `static/app.js` | 165 | `/api/vault/status` | diff --git a/routes/bte_proxy.py b/routes/bte_proxy.py index 0948106..ee1dc5d 100644 --- a/routes/bte_proxy.py +++ b/routes/bte_proxy.py @@ -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 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) @@ -14,6 +21,7 @@ Stdlib only — no urllib3, no requests. """ import json import os +import random import re import urllib.error import urllib.parse @@ -22,6 +30,47 @@ 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//` form. Keep tight — the # proxy is a privilege amplifier, only the panel's needs go here. @@ -73,6 +122,21 @@ def _handle_proxy(handler, parsed): 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: @@ -115,3 +179,176 @@ def _send_json(handler, payload: dict, status: int) -> bool: 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 = '____'. 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, + }