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