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")
+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):
"""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
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 "SHORT 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 "Brand voice and Geography sections when provided" in CALL2_SEED
assert "Whidbey" not in CALL2_SEED
assert "Pacific Northwest" not in CALL2_SEED
assert '"short_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():
@@ -196,7 +204,7 @@ def test_call1_hard_disabled_410(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
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute(
@@ -210,25 +218,29 @@ def test_call2_stores_short_and_description(env, monkeypatch):
import json as _json
payload = _json.dumps({
"short_description": "Turn a blank into a showpiece table — kiln-dried walnut with live edge character. Ready to finish.",
"description": "<p><strong>Showpiece ready.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>",
"short_description": "Kiln-dried walnut live-edge stock for a dining table — clear midspan, usable width. Ready to finish.",
"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)
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
body = r.json()
assert "Showpiece ready" in body["description"] or "kiln" in body["description"]
assert body["short_description"].startswith("Turn a blank")
assert "Dining table stock" in body["description"]
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(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone()
assert row["short_description"].startswith("Turn a blank")
assert "<p>" in row["description"]
assert row["short_description"].startswith("Kiln-dried walnut")
assert "<p>" not in row["description"]
assert "- Stable:" in row["description"] or "Stable:" in row["description"]
assert row["content_source"] == "llm"
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
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
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>")
r = client.post("/api/v1/slabs/s1/inference/call2")
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()
row = conn.execute(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone()
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"
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
short, desc = parse_call2_response(
_json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "})
)
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```"
short2, desc2 = parse_call2_response(fenced)
assert short2 == "S"
assert desc2 == "<p>D</p>"
assert desc2 == "D"
short3, desc3 = parse_call2_response("<p>only long</p>")
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):
+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.
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.
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:
- 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,
@@ -178,28 +186,30 @@ Rules that always apply:
- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay
neutral. Never invent return, warranty, or shipping policies.
- 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):
1. Lead with the outcome or problem solved (benefit), not material-first.
2. Add the key differentiator or most important spec.
3. Close with a trust signal or reason to act.
Do NOT lead with a species + material list.
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),
not lifestyle hype and not a bare species list.
2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).
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
bullets, `<strong>` for benefit leads; no markdown). Four parts, in order:
1. Hook — one sentence restating the core benefit.
2. Benefits — 3–5 bullets; each benefit-first, then the supporting spec.
LONG DESCRIPTION (plain text only — newlines and `- ` bullets; no HTML, no markdown markup).
Four parts, in order:
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.
3. Full specs — clean list from DECLARED ATTRIBUTES + DIMENSION FACT BLOCK (dims, species,
moisture, edge, figure, grade, etc.).
4. Trust close — return/warranty/brand; use Brand voice when present; otherwise a short
neutral close with no invented policies.
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
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