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:
+112
-11
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user