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")
|
||||
|
||||
Reference in New Issue
Block a user