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.
This commit is contained in:
grokbot_ty
2026-09-26 10:52:53 -07:00
parent 7e0157e45b
commit 88278a55e8
3 changed files with 200 additions and 18 deletions
+112 -11
View File
@@ -128,6 +128,11 @@ CALL2_SEED = (
"- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.\n" "- 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 " "- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece "
"suits (tabletop, bar, etc.) without ad-copy fluff.\n" "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" "\n"
"SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):\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), " "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" "3. Close with a trust signal or concrete reason to choose this piece.\n"
"\n" "\n"
"LONG DESCRIPTION (plain text only — newlines and \"- \" bullets; no HTML, no markdown " "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" "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" "2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail. "
"3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK " "Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.\n"
"(dims, species, moisture, edge, figure, grade, etc.).\n" "3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present; "
"4. Trust close — return/warranty/brand; use Brand voice when present; otherwise a short " "otherwise a short neutral close with no invented policies.\n"
"neutral close with no invented policies.\n"
"\n" "\n"
"Return ONE JSON object only (no prose outside JSON) with exactly these keys:\n" "Return ONE JSON object only (no prose outside JSON) with exactly these keys:\n"
'{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}' '{"short_description": "<plain text ~50 words>", "description": "<plain text long 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: def _html_to_plain(value: str) -> str:
"""Defensive: strip HTML/markup so Review fields never show raw tags. """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() 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]: def parse_call2_response(raw: str) -> tuple[str | None, str]:
"""Split Call 2 model output into (short_description, description). """Split Call 2 model output into (short_description, description).
Preferred shape is a JSON object with short_description + description. Preferred shape is a JSON object with short_description + description.
Both fields are forced to plain text (HTML stripped) before return. 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 If JSON is missing or incomplete, treat the whole body as description
(stripped) and leave short_description None (caller keeps any prior short). (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: try:
obj = json.loads(body[start : end + 1]) obj = json.loads(body[start : end + 1])
except (ValueError, TypeError): except (ValueError, TypeError):
return None, _html_to_plain(text) return None, _plain_long_desc(text)
else: else:
return None, _html_to_plain(text) return None, _plain_long_desc(text)
if not isinstance(obj, dict): if not isinstance(obj, dict):
return None, _html_to_plain(text) return None, _plain_long_desc(text)
short = obj.get("short_description") short = obj.get("short_description")
desc = obj.get("description") desc = obj.get("description")
if isinstance(short, str): if isinstance(short, str):
@@ -759,9 +860,9 @@ def parse_call2_response(raw: str) -> tuple[str | None, str]:
else: else:
short = None short = None
if isinstance(desc, str) and desc.strip(): 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). # 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") @router.post("/slabs/{slab_id}/inference/call2")
+75
View File
@@ -101,6 +101,16 @@ def test_call2_seed_has_short_long_structure(env):
assert '"description"' in CALL2_SEED assert '"description"' in CALL2_SEED
assert "<HTML long description>" not in CALL2_SEED assert "<HTML long description>" not in CALL2_SEED
assert "plain text long description" 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(): 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." 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): def test_call1_disabled_even_when_inference_off(env):
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409).""" """Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
client, conn = env client, conn = env
+13 -7
View File
@@ -188,6 +188,11 @@ Rules that always apply:
- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title. - 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 - Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece
suits (tabletop, bar, etc.) without ad-copy fluff. 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): 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), 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. 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). 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. 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. 2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail.
3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK (dims, species, Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.
moisture, edge, figure, grade, etc.). 3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present;
4. Trust close — return/warranty/brand; use Brand voice when present; otherwise a short otherwise a short neutral close with no invented policies.
neutral close with no invented policies.
Return ONE JSON object only (no prose outside JSON) with exactly these keys: Return ONE JSON object only (no prose outside JSON) with exactly these keys:
`{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}` `{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}`
Runtime appends DECLARED ATTRIBUTES (title + taxonomy; not a prior short), DIMENSION FACT 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 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 ## 7. Prompt management