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"
"- 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": "<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:
"""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")
+75
View File
@@ -101,6 +101,16 @@ def test_call2_seed_has_short_long_structure(env):
assert '"description"' in CALL2_SEED
assert "<HTML long description>" 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