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