From 88278a55e85a3bb7f73bbaafc0a94ccfd68a8e41 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 10:52:53 -0700 Subject: [PATCH] fix(call2): no metadata dump in long description Long desc is Hook + Benefits (+ brief trust close) only; forbid Full specs / Attribute list / product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK. Defensively strip trailing Category:/Length:/Board Feet: labeled dumps in parse. --- backend/inference.py | 123 +++++++++++++++++++++++++++++--- backend/tests/test_inference.py | 75 +++++++++++++++++++ docs/contracts/PROMPTS.md | 20 ++++-- 3 files changed, 200 insertions(+), 18 deletions(-) diff --git a/backend/inference.py b/backend/inference.py index 2ba4f7b..927d969 100644 --- a/backend/inference.py +++ b/backend/inference.py @@ -128,6 +128,11 @@ CALL2_SEED = ( "- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.\n" "- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece " "suits (tabletop, bar, etc.) without ad-copy fluff.\n" + "- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED " + "ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description " + "(no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no \"Full specs\" " + "section, no \"Maple Slab\" attribute-header block). You may weave 1–2 key traits into " + "prose or benefit bullets naturally.\n" "\n" "SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):\n" "1. Lead with a practical outcome for a maker (what they can build / how the stock helps), " @@ -136,13 +141,13 @@ CALL2_SEED = ( "3. Close with a trust signal or concrete reason to choose this piece.\n" "\n" "LONG DESCRIPTION (plain text only — newlines and \"- \" bullets; no HTML, no markdown " - "markup). Four parts, in order:\n" + "markup). Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute " + "list / product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK:\n" "1. Hook — one sentence on what the piece suits and its standout usable trait.\n" - "2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting spec.\n" - "3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK " - "(dims, species, moisture, edge, figure, grade, etc.).\n" - "4. Trust close — return/warranty/brand; use Brand voice when present; otherwise a short " - "neutral close with no invented policies.\n" + "2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail. " + "Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.\n" + "3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present; " + "otherwise a short neutral close with no invented policies.\n" "\n" "Return ONE JSON object only (no prose outside JSON) with exactly these keys:\n" '{"short_description": "", "description": ""}' @@ -693,6 +698,95 @@ async def call2_prompt(slab_id: str) -> dict: } +# Labels that mark a Woo/taxonomy/dimension metadata dump when used as "- Label: …". +_ATTR_DUMP_LABELS = ( + "Category", + "Primary", + "Secondary", + "Species", + "Figure", + "Edge", + "Grade", + "Moisture", + "Features", + "Feature", + "Title", + "Length", + "Thickness", + "Width", + "Avg Width", + "Average Width", + "Width Min", + "Width Max", + "Min Width", + "Max Width", + "Board Feet", + "Bdft", + "BF", + "Dimensions", + "Dims", +) +_ATTR_DUMP_LABEL_RE = re.compile( + r"(?i)^\s*-\s*(?:" + + "|".join(re.escape(l) for l in _ATTR_DUMP_LABELS) + + r")\s*:", +) +_ATTR_DUMP_HEADER_RE = re.compile( + r"(?i)^\s*(?:Full\s+specs?|Specifications?|Attributes?|Attribute\s+list|" + r"[A-Za-z][A-Za-z0-9 /&-]{0,40}\s+Slab)\s*$", +) + + +def _strip_attribute_dump(value: str) -> str: + """Remove a labeled Category:/Length:/Board Feet: metadata dump from long desc. + + Specs live in taxonomy/dims on the product; a trailing (or mid-body) block that + re-lists them as "- Label: value" lines drifts from Woo fields. Keeps Hook + + Benefits (+ trust close) prose around the dump. + """ + if not value: + return "" + lines = value.split("\n") + n = len(lines) + is_attr = [_ATTR_DUMP_LABEL_RE.match(ln) is not None for ln in lines] + + # Find contiguous runs of ≥3 attribute bullets (optionally preceded by a header). + runs: list[tuple[int, int]] = [] + i = 0 + while i < n: + if not is_attr[i]: + i += 1 + continue + start = i + while i < n and is_attr[i]: + i += 1 + end = i # exclusive + if end - start >= 3: + # Absorb a single preceding header / "Maple Slab" title line. + hdr = start - 1 + if hdr >= 0 and lines[hdr].strip() and _ATTR_DUMP_HEADER_RE.match(lines[hdr]): + # Do not swallow a benefits bullet that happens to match poorly. + if not is_attr[hdr]: + start = hdr + # Absorb blank lines immediately before the block. + while start > 0 and not lines[start - 1].strip(): + start -= 1 + runs.append((start, end)) + + if not runs: + return value.strip() + + # Prefer the last run (typical trailing dump); drop all qualifying runs. + keep = [True] * n + for start, end in runs: + for j in range(start, end): + keep[j] = False + out_lines = [ln for j, ln in enumerate(lines) if keep[j]] + text = "\n".join(out_lines) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + def _html_to_plain(value: str) -> str: """Defensive: strip HTML/markup so Review fields never show raw tags. @@ -717,11 +811,18 @@ def _html_to_plain(value: str) -> str: return t.strip() +def _plain_long_desc(value: str) -> str: + """HTML→plain then drop labeled attribute/metadata dumps from long description.""" + return _strip_attribute_dump(_html_to_plain(value)) + + def parse_call2_response(raw: str) -> tuple[str | None, str]: """Split Call 2 model output into (short_description, description). Preferred shape is a JSON object with short_description + description. Both fields are forced to plain text (HTML stripped) before return. + Long description also drops a trailing/mid labeled attribute dump + (Category:/Length:/Board Feet: …) so specs stay on Woo fields. If JSON is missing or incomplete, treat the whole body as description (stripped) and leave short_description None (caller keeps any prior short). """ @@ -747,11 +848,11 @@ def parse_call2_response(raw: str) -> tuple[str | None, str]: try: obj = json.loads(body[start : end + 1]) except (ValueError, TypeError): - return None, _html_to_plain(text) + return None, _plain_long_desc(text) else: - return None, _html_to_plain(text) + return None, _plain_long_desc(text) if not isinstance(obj, dict): - return None, _html_to_plain(text) + return None, _plain_long_desc(text) short = obj.get("short_description") desc = obj.get("description") if isinstance(short, str): @@ -759,9 +860,9 @@ def parse_call2_response(raw: str) -> tuple[str | None, str]: else: short = None if isinstance(desc, str) and desc.strip(): - return short, _html_to_plain(desc) + return short, _plain_long_desc(desc) # JSON without a usable description — fall back to raw (stripped). - return short, _html_to_plain(text) + return short, _plain_long_desc(text) @router.post("/slabs/{slab_id}/inference/call2") diff --git a/backend/tests/test_inference.py b/backend/tests/test_inference.py index 07c6f1b..f1f2cd7 100644 --- a/backend/tests/test_inference.py +++ b/backend/tests/test_inference.py @@ -101,6 +101,16 @@ def test_call2_seed_has_short_long_structure(env): assert '"description"' in CALL2_SEED assert "" not in CALL2_SEED assert "plain text long description" in CALL2_SEED + # No Full-specs / attribute metadata dump in long desc (specs live on Woo fields). + assert "Hook + Benefits only" in CALL2_SEED + assert "Do NOT restate DECLARED" in CALL2_SEED + assert 'no "Full specs"' in CALL2_SEED or "Do NOT add a Full specs" in CALL2_SEED + assert "Category:/Figure:/Edge:" in CALL2_SEED + assert "1–2 key traits" in CALL2_SEED + assert "labeled catalog" in CALL2_SEED or "labeled metadata list" in CALL2_SEED + # Must not instruct a Full-specs numbered part (old 4-part structure). + assert "3. Full specs" not in CALL2_SEED + assert "Four parts, in order" not in CALL2_SEED def test_taxonomy_lists_call1_closed_shape(): @@ -296,6 +306,71 @@ def test_parse_call2_response_json_and_fenced(): assert _html_to_plain(" Kiln-dried walnut for a bar top. ") == "Kiln-dried walnut for a bar top." +def test_strip_attribute_dump_trailing_and_header(): + """Defensive: drop labeled Category:/Length: dumps from long description.""" + from backend.inference import _strip_attribute_dump, parse_call2_response + import json as _json + + hook_benefits = ( + "Kiln-dried maple suited to a compact bar top.\n" + "- Stable stock: kiln dried, ready to mill.\n" + "- Live edge on one side for a natural apron.\n" + "- Character grade with usable burl figure." + ) + dump = ( + "Maple Slab\n" + "- Category: Live edge wood\n" + "- Figure: Burl, Curl, Figured\n" + "- Edge: Live Edge 1 side\n" + "- Grade: Character\n" + "- Moisture: Kiln dried\n" + "- Length: 45.0in\n" + "- Board Feet: 12.4" + ) + trust = "Ships from the mill; ask about returns in Brand voice." + full = f"{hook_benefits}\n\n{dump}\n\n{trust}" + cleaned = _strip_attribute_dump(full) + assert "Category:" not in cleaned + assert "Board Feet:" not in cleaned + assert "Maple Slab" not in cleaned + assert "Kiln-dried maple" in cleaned + assert "Live edge on one side" in cleaned + assert "Ships from the mill" in cleaned + + # Mid-body Full specs header also stripped; benefits bullets without Label: kept. + mid = ( + "Hook line.\n" + "- Good for a bench seat.\n" + "\n" + "Full specs\n" + "- Species: Maple\n" + "- Length: 45.0in\n" + "- Thickness: 2.0in\n" + "\n" + "Neutral trust close." + ) + mid_clean = _strip_attribute_dump(mid) + assert "Full specs" not in mid_clean + assert "Species:" not in mid_clean + assert "Good for a bench seat" in mid_clean + assert "Neutral trust close" in mid_clean + + # Fewer than 3 attr lines: leave alone (not a dump). + short_list = "Hook.\n- Length: 45.0in\n- Thickness: 2in\nTrust." + assert _strip_attribute_dump(short_list) == short_list.strip() + + # parse_call2_response applies strip on description (not short). + payload = _json.dumps({ + "short_description": "Short with - Length: 1in - Width: 2in - Thickness: 3in ok", + "description": full, + }) + short, desc = parse_call2_response(payload) + assert short and "Length: 1in" in short # short untouched by attr-dump strip + assert "Category:" not in desc + assert "Board Feet:" not in desc + assert "Kiln-dried maple" in desc + + def test_call1_disabled_even_when_inference_off(env): """Call 1 hard-disable wins over inference_enabled=false (still 410, not 409).""" client, conn = env diff --git a/docs/contracts/PROMPTS.md b/docs/contracts/PROMPTS.md index 6abf4bb..2d3906b 100644 --- a/docs/contracts/PROMPTS.md +++ b/docs/contracts/PROMPTS.md @@ -188,6 +188,11 @@ Rules that always apply: - The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title. - Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece suits (tabletop, bar, etc.) without ad-copy fluff. +- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED + ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description + (no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no "Full specs" + section, no "Maple Slab" attribute-header block). You may weave 1–2 key traits into + prose or benefit bullets naturally. SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only): 1. Lead with a practical outcome for a maker (what they can build / how the stock helps), @@ -196,20 +201,21 @@ SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only): 3. Close with a trust signal or concrete reason to choose this piece. LONG DESCRIPTION (plain text only — newlines and `- ` bullets; no HTML, no markdown markup). -Four parts, in order: +Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute list / +product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK: 1. Hook — one sentence on what the piece suits and its standout usable trait. -2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting spec. -3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK (dims, species, - moisture, edge, figure, grade, etc.). -4. Trust close — return/warranty/brand; use Brand voice when present; otherwise a short - neutral close with no invented policies. +2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail. + Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog. +3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present; + otherwise a short neutral close with no invented policies. Return ONE JSON object only (no prose outside JSON) with exactly these keys: `{"short_description": "", "description": ""}` Runtime appends DECLARED ATTRIBUTES (title + taxonomy; not a prior short), DIMENSION FACT BLOCK, and `Brand voice:` / `Geo:` from Settings (`SETTINGS-30`). The POST handler parses the -JSON, strips any residual HTML to plain text, and persists both fields. +JSON, strips any residual HTML to plain text, drops a labeled Category:/Length:/Board Feet: +attribute dump if the model still emits one, and persists both fields. ## 7. Prompt management