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:
grokbot_ty
2026-09-26 10:45:28 -07:00
parent c25e3f4927
commit 7e0157e45b
3 changed files with 120 additions and 48 deletions
+57 -20
View File
@@ -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")