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 base64
import hashlib import hashlib
import html
import re
import io import io
import json import json
import time import time
@@ -106,9 +108,17 @@ CALL1_SEED = (
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}' '"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
) )
CALL2_SEED = ( 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" "You write BOTH the short description and the long description.\n"
"\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" "Rules that always apply:\n"
"- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.\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, " "- 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 " "- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay "
"neutral. Never invent return, warranty, or shipping policies.\n" "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" "- 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" "\n"
"SHORT DESCRIPTION (~50 words — most important on-page copy; plain text, no HTML):\n" "SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):\n"
"1. Lead with the outcome or problem solved (benefit), not material-first.\n" "1. Lead with a practical outcome for a maker (what they can build / how the stock helps), "
"2. Add the key differentiator or most important spec.\n" "not lifestyle hype and not a bare species list.\n"
"3. Close with a trust signal or reason to act.\n" "2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).\n"
"Do NOT lead with a species + material list.\n" "3. Close with a trust signal or concrete reason to choose this piece.\n"
"\n" "\n"
"LONG DESCRIPTION (HTML for the WooCommerce visual editor — short paragraphs, " "LONG DESCRIPTION (plain text only — newlines and \"- \" bullets; no HTML, no markdown "
"<ul>/<li> for bullets, <strong> for benefit leads; no markdown). Four parts, in order:\n" "markup). Four parts, in order:\n"
"1. Hook — one sentence restating the core benefit.\n" "1. Hook — one sentence on what the piece suits and its standout usable trait.\n"
"2. Benefits — 3–5 bullets; each benefit-first, then the supporting spec.\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 " "3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK "
"(dims, species, moisture, edge, figure, grade, etc.).\n" "(dims, species, moisture, edge, figure, grade, etc.).\n"
"4. Trust close — return/warranty/brand; use Brand voice when present; otherwise a short " "4. Trust close — return/warranty/brand; use Brand voice when present; 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": "<HTML long description>"}' '{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}'
) )
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED} _SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
_THRESHOLD = 0.7 _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]: 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.
If JSON is missing or incomplete, treat the whole body as description HTML Both fields are forced to plain text (HTML stripped) before return.
and leave short_description None (caller keeps any prior short). 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() text = (raw or "").strip()
if not text: if not text:
@@ -710,21 +747,21 @@ 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, text return None, _html_to_plain(text)
else: else:
return None, text return None, _html_to_plain(text)
if not isinstance(obj, dict): if not isinstance(obj, dict):
return None, text return None, _html_to_plain(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):
short = short.strip() or None short = _html_to_plain(short) or None
else: else:
short = None short = None
if isinstance(desc, str) and desc.strip(): if isinstance(desc, str) and desc.strip():
return short, desc.strip() return short, _html_to_plain(desc)
# JSON without a usable description — fall back to raw. # JSON without a usable description — fall back to raw (stripped).
return short, text return short, _html_to_plain(text)
@router.post("/slabs/{slab_id}/inference/call2") @router.post("/slabs/{slab_id}/inference/call2")
+41 -16
View File
@@ -78,7 +78,7 @@ def test_call1_seed_is_locked_expert_prompt(env):
def test_call2_seed_has_short_long_structure(env): def test_call2_seed_has_short_long_structure(env):
"""Call 2 seed carries short+long structure and JSON dual output (not brand voice).""" """Call 2 seed: plain-text short+long, woodworker tone, JSON dual output (not brand voice)."""
client, _conn = env client, _conn = env
from backend.inference import CALL2_SEED from backend.inference import CALL2_SEED
@@ -86,13 +86,21 @@ def test_call2_seed_has_short_long_structure(env):
assert body["active_content"] == CALL2_SEED assert body["active_content"] == CALL2_SEED
assert "SHORT DESCRIPTION" in CALL2_SEED assert "SHORT DESCRIPTION" in CALL2_SEED
assert "LONG DESCRIPTION" in CALL2_SEED assert "LONG DESCRIPTION" in CALL2_SEED
assert "Lead with the outcome" in CALL2_SEED assert "PLAIN TEXT ONLY" in CALL2_SEED
assert "plain text only" in CALL2_SEED.lower()
assert "No HTML tags" in CALL2_SEED
assert "woodworkers choosing stock" in CALL2_SEED
assert "elevate" in CALL2_SEED # banned-phrase guidance
assert "stunning" in CALL2_SEED
assert "Lead with a practical outcome" in CALL2_SEED
assert "Hook" in CALL2_SEED assert "Hook" in CALL2_SEED
assert "Brand voice and Geography sections when provided" in CALL2_SEED assert "Brand voice and Geography sections when provided" in CALL2_SEED
assert "Whidbey" not in CALL2_SEED assert "Whidbey" not in CALL2_SEED
assert "Pacific Northwest" not in CALL2_SEED assert "Pacific Northwest" not in CALL2_SEED
assert '"short_description"' in CALL2_SEED assert '"short_description"' in CALL2_SEED
assert '"description"' in CALL2_SEED assert '"description"' in CALL2_SEED
assert "<HTML long description>" not in CALL2_SEED
assert "plain text long description" in CALL2_SEED
def test_taxonomy_lists_call1_closed_shape(): def test_taxonomy_lists_call1_closed_shape():
@@ -196,7 +204,7 @@ def test_call1_hard_disabled_410(env, monkeypatch):
def test_call2_stores_short_and_description(env, monkeypatch): def test_call2_stores_short_and_description(env, monkeypatch):
"""Call 2 persists both short_description and description from JSON output.""" """Call 2 persists both fields as plain text (HTML from model is stripped)."""
client, conn = env client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)") conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute( conn.execute(
@@ -210,25 +218,29 @@ def test_call2_stores_short_and_description(env, monkeypatch):
import json as _json import json as _json
payload = _json.dumps({ payload = _json.dumps({
"short_description": "Turn a blank into a showpiece table — kiln-dried walnut with live edge character. Ready to finish.", "short_description": "Kiln-dried walnut live-edge stock for a dining table — clear midspan, usable width. Ready to finish.",
"description": "<p><strong>Showpiece ready.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>", "description": "<p><strong>Dining table stock.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>",
}) })
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: payload) monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: payload)
r = client.post("/api/v1/slabs/s1/inference/call2") r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
body = r.json() body = r.json()
assert "Showpiece ready" in body["description"] or "kiln" in body["description"] assert "Dining table stock" in body["description"]
assert body["short_description"].startswith("Turn a blank") assert "kiln dried" in body["description"]
assert "<p>" not in body["description"]
assert "<strong>" not in body["description"]
assert body["short_description"].startswith("Kiln-dried walnut")
row = conn.execute( row = conn.execute(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'" "SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone() ).fetchone()
assert row["short_description"].startswith("Turn a blank") assert row["short_description"].startswith("Kiln-dried walnut")
assert "<p>" in row["description"] assert "<p>" not in row["description"]
assert "- Stable:" in row["description"] or "Stable:" in row["description"]
assert row["content_source"] == "llm" assert row["content_source"] == "llm"
def test_call2_html_fallback_keeps_prior_short(env, monkeypatch): def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
"""Non-JSON Call 2 body becomes description only; prior short is left alone.""" """Non-JSON Call 2 body becomes plain-text description; prior short is left alone."""
client, conn = env client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)") conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute( conn.execute(
@@ -243,32 +255,45 @@ def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>A fine walnut slab.</p>") monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>A fine walnut slab.</p>")
r = client.post("/api/v1/slabs/s1/inference/call2") r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["description"] == "<p>A fine walnut slab.</p>" assert r.json()["description"] == "A fine walnut slab."
assert "short_description" not in r.json() assert "short_description" not in r.json()
row = conn.execute( row = conn.execute(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'" "SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone() ).fetchone()
assert row["short_description"] == "keep me" assert row["short_description"] == "keep me"
assert row["description"] == "<p>A fine walnut slab.</p>" assert row["description"] == "A fine walnut slab."
assert row["content_source"] == "llm" assert row["content_source"] == "llm"
def test_parse_call2_response_json_and_fenced(): def test_parse_call2_response_json_and_fenced():
from backend.inference import parse_call2_response from backend.inference import parse_call2_response, _html_to_plain
import json as _json import json as _json
short, desc = parse_call2_response( short, desc = parse_call2_response(
_json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "}) _json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "})
) )
assert short == "Benefit first." assert short == "Benefit first."
assert desc == "<p>Long</p>" assert desc == "Long"
fenced = "```json\n" + _json.dumps({"short_description": "S", "description": "<p>D</p>"}) + "\n```" fenced = "```json\n" + _json.dumps({"short_description": "S", "description": "<p>D</p>"}) + "\n```"
short2, desc2 = parse_call2_response(fenced) short2, desc2 = parse_call2_response(fenced)
assert short2 == "S" assert short2 == "S"
assert desc2 == "<p>D</p>" assert desc2 == "D"
short3, desc3 = parse_call2_response("<p>only long</p>") short3, desc3 = parse_call2_response("<p>only long</p>")
assert short3 is None assert short3 is None
assert desc3 == "<p>only long</p>" assert desc3 == "only long"
# Defensive strip: lists become "- " bullets; entities unescaped; no tags left.
plain = _html_to_plain(
"<p>Hook.</p><ul><li><strong>Stable:</strong> kiln dried</li>"
"<li>Live edge</li></ul><p>Trust &amp; ship.</p>"
)
assert "<" not in plain
assert "Hook." in plain
assert "- Stable: kiln dried" in plain
assert "- Live edge" in plain
assert "Trust & ship." in plain
# Already-plain text is unchanged aside from trim.
assert _html_to_plain(" Kiln-dried walnut for a bar top. ") == "Kiln-dried walnut for a bar top."
def test_call1_disabled_even_when_inference_off(env): def test_call1_disabled_even_when_inference_off(env):
+22 -12
View File
@@ -168,9 +168,17 @@ Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickne
**`call2` seed.** Shipped text in `backend/inference.py` (`CALL2_SEED`), mirrored here for review. **`call2` seed.** Shipped text in `backend/inference.py` (`CALL2_SEED`), mirrored here for review.
Write WooCommerce listing copy for this slab from the photos and listing facts below. 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).
You write BOTH the short description and the long description. You write BOTH the short description and the long description.
PLAIN TEXT ONLY (short AND long):
- Output plain text only. No HTML tags (`<p>`, `<ul>`, `<li>`, `<strong>`, `<br>`, etc.).
No markdown bold/headers that look like markup (`**bold**`, `#` headers).
- Structure the long description with newlines and `- ` bullets when helpful.
Rules that always apply: Rules that always apply:
- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers. - Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.
- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade, - Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade,
@@ -178,28 +186,30 @@ Rules that always apply:
- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay - Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay
neutral. Never invent return, warranty, or shipping policies. neutral. Never invent return, warranty, or shipping policies.
- 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
suits (tabletop, bar, etc.) without ad-copy fluff.
SHORT DESCRIPTION (~50 words — most important on-page copy; plain text, no HTML): SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):
1. Lead with the outcome or problem solved (benefit), not material-first. 1. Lead with a practical outcome for a maker (what they can build / how the stock helps),
2. Add the key differentiator or most important spec. not lifestyle hype and not a bare species list.
3. Close with a trust signal or reason to act. 2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).
Do NOT lead with a species + material list. 3. Close with a trust signal or concrete reason to choose this piece.
LONG DESCRIPTION (HTML for the WooCommerce visual editor — short paragraphs, `<ul>`/`<li>` for LONG DESCRIPTION (plain text only — newlines and `- ` bullets; no HTML, no markdown markup).
bullets, `<strong>` for benefit leads; no markdown). Four parts, in order: Four parts, in order:
1. Hook — one sentence restating the core benefit. 1. Hook — one sentence on what the piece suits and its standout usable trait.
2. Benefits — 3–5 bullets; each benefit-first, then the supporting spec. 2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting spec.
3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK (dims, species, 3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK (dims, species,
moisture, edge, figure, grade, etc.). moisture, edge, figure, grade, etc.).
4. Trust close — return/warranty/brand; use Brand voice when present; otherwise a short 4. Trust close — return/warranty/brand; use Brand voice when present; 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": "<HTML 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 and persists both fields. JSON, strips any residual HTML to plain text, and persists both fields.
## 7. Prompt management ## 7. Prompt management