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")