fix(call2): plain text + woodworker tone
Review short/long must not show HTML tags; seed now requires plain text only (newlines/- bullets) and practical woodworker copy. parse_call2_response strips residual HTML as a safety net. Seed activation unchanged.
This commit is contained in:
+57
-20
@@ -7,6 +7,8 @@ overnight-whole-app: Call 1 POST is hard-disabled (410 call1_disabled). Call 2 s
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
@@ -106,9 +108,17 @@ CALL1_SEED = (
|
||||
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
|
||||
)
|
||||
CALL2_SEED = (
|
||||
"Write WooCommerce listing copy for this slab from the photos and listing facts below.\n"
|
||||
"Write listing copy for this slab for woodworkers choosing stock for a project. "
|
||||
"Audience: makers matching wood to a tabletop, bar, bench, or similar build — practical, "
|
||||
"material/figure/spec-forward. No lifestyle or marketing hype "
|
||||
"(avoid empty phrases like elevate, stunning, dramatic visual contrast).\n"
|
||||
"You write BOTH the short description and the long description.\n"
|
||||
"\n"
|
||||
"PLAIN TEXT ONLY (short AND long):\n"
|
||||
"- Output plain text only. No HTML tags (<p>, <ul>, <li>, <strong>, <br>, etc.). "
|
||||
"No markdown bold/headers that look like markup (**bold**, # headers).\n"
|
||||
"- Structure the long description with newlines and \"- \" bullets when helpful.\n"
|
||||
"\n"
|
||||
"Rules that always apply:\n"
|
||||
"- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.\n"
|
||||
"- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade, "
|
||||
@@ -116,24 +126,26 @@ CALL2_SEED = (
|
||||
"- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay "
|
||||
"neutral. Never invent return, warranty, or shipping policies.\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 "
|
||||
"suits (tabletop, bar, etc.) without ad-copy fluff.\n"
|
||||
"\n"
|
||||
"SHORT DESCRIPTION (~50 words — most important on-page copy; plain text, no HTML):\n"
|
||||
"1. Lead with the outcome or problem solved (benefit), not material-first.\n"
|
||||
"2. Add the key differentiator or most important spec.\n"
|
||||
"3. Close with a trust signal or reason to act.\n"
|
||||
"Do NOT lead with a species + material list.\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), "
|
||||
"not lifestyle hype and not a bare species list.\n"
|
||||
"2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).\n"
|
||||
"3. Close with a trust signal or concrete reason to choose this piece.\n"
|
||||
"\n"
|
||||
"LONG DESCRIPTION (HTML for the WooCommerce visual editor — short paragraphs, "
|
||||
"<ul>/<li> for bullets, <strong> for benefit leads; no markdown). Four parts, in order:\n"
|
||||
"1. Hook — one sentence restating the core benefit.\n"
|
||||
"2. Benefits — 3–5 bullets; each benefit-first, then the supporting spec.\n"
|
||||
"LONG DESCRIPTION (plain text only — newlines and \"- \" bullets; no HTML, no markdown "
|
||||
"markup). Four parts, in order:\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"
|
||||
"\n"
|
||||
"Return ONE JSON object only (no prose outside JSON) with exactly these keys:\n"
|
||||
'{"short_description": "<plain text ~50 words>", "description": "<HTML long description>"}'
|
||||
'{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}'
|
||||
)
|
||||
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
|
||||
_THRESHOLD = 0.7
|
||||
@@ -681,12 +693,37 @@ async def call2_prompt(slab_id: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _html_to_plain(value: str) -> str:
|
||||
"""Defensive: strip HTML/markup so Review fields never show raw tags.
|
||||
|
||||
Converts common block tags to newlines and <li> to "- " bullets, then removes
|
||||
remaining tags and unescapes entities. Already-plain text is returned cleaned.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
t = value
|
||||
# Normalize common break / list / paragraph boundaries before dropping tags.
|
||||
t = re.sub(r"(?is)<br\s*/?>", "\n", t)
|
||||
t = re.sub(r"(?is)</\s*(p|div|h[1-6]|tr|table)\s*>", "\n", t)
|
||||
t = re.sub(r"(?is)<\s*li[^>]*>", "- ", t)
|
||||
t = re.sub(r"(?is)</\s*(ul|ol)\s*>", "\n", t)
|
||||
t = re.sub(r"(?is)<[^>]+>", "", t)
|
||||
t = html.unescape(t)
|
||||
# Drop leftover markdown-ish bold/headers that look like markup in Review.
|
||||
t = re.sub(r"(?m)^#{1,6}\s+", "", t)
|
||||
t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t)
|
||||
t = re.sub(r"[ \t]+\n", "\n", t)
|
||||
t = re.sub(r"\n{3,}", "\n\n", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
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.
|
||||
If JSON is missing or incomplete, treat the whole body as description HTML
|
||||
and leave short_description None (caller keeps any prior short).
|
||||
Both fields are forced to plain text (HTML stripped) before return.
|
||||
If JSON is missing or incomplete, treat the whole body as description
|
||||
(stripped) and leave short_description None (caller keeps any prior short).
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
@@ -710,21 +747,21 @@ def parse_call2_response(raw: str) -> tuple[str | None, str]:
|
||||
try:
|
||||
obj = json.loads(body[start : end + 1])
|
||||
except (ValueError, TypeError):
|
||||
return None, text
|
||||
return None, _html_to_plain(text)
|
||||
else:
|
||||
return None, text
|
||||
return None, _html_to_plain(text)
|
||||
if not isinstance(obj, dict):
|
||||
return None, text
|
||||
return None, _html_to_plain(text)
|
||||
short = obj.get("short_description")
|
||||
desc = obj.get("description")
|
||||
if isinstance(short, str):
|
||||
short = short.strip() or None
|
||||
short = _html_to_plain(short) or None
|
||||
else:
|
||||
short = None
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
return short, desc.strip()
|
||||
# JSON without a usable description — fall back to raw.
|
||||
return short, text
|
||||
return short, _html_to_plain(desc)
|
||||
# JSON without a usable description — fall back to raw (stripped).
|
||||
return short, _html_to_plain(text)
|
||||
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call2")
|
||||
|
||||
Reference in New Issue
Block a user