feat(call2): short/long desc structure in seed (not brand voice)

Call 2 seed now requires benefit-first short + four-part long HTML, returned
as JSON. POST parses and persists both fields; Create-desc gate no longer
requires short (Call 2 writes it). Brand voice/geo stay Settings-injected.
This commit is contained in:
grokbot_ty
2026-09-26 10:35:53 -07:00
parent 076eb17db6
commit c25e3f4927
8 changed files with 242 additions and 41 deletions
+90 -13
View File
@@ -106,10 +106,34 @@ CALL1_SEED = (
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
)
CALL2_SEED = (
"Write the long product description for the slab from the supplied photos and listing "
"facts. The name and short description are already built, so do not repeat them. Use the "
"brand voice and geo context given. The dimensions in the fact block are exact: use those "
"strings and invent no numbers. Write prose a premium lumber buyer would read."
"Write WooCommerce listing copy for this slab from the photos and listing facts below.\n"
"You write BOTH the short description and the long description.\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, "
"moisture, or features.\n"
"- 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"
"\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"
"\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"
"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>"}'
)
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
_THRESHOLD = 0.7
@@ -532,11 +556,9 @@ def build_declared_attributes(conn, slab: dict) -> str:
f"features: {', '.join(_feat_names(conn, feat_ids)) or '(none)'}",
]
title = (slab.get("title") or "").strip()
short = (slab.get("short_description") or "").strip()
if title:
lines.append(f"title: {title}")
if short:
lines.append(f"short_description: {short}")
# short_description is Call 2 output — do not feed a prior value back into the prompt.
return "\n".join(lines)
@@ -659,6 +681,52 @@ async def call2_prompt(slab_id: str) -> dict:
}
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).
"""
text = (raw or "").strip()
if not text:
return None, ""
# Strip common markdown fences around JSON.
body = text
if body.startswith("```"):
lines = body.split("\n")
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
body = "\n".join(lines).strip()
try:
obj = json.loads(body)
except (ValueError, TypeError):
# Try to find a JSON object embedded in prose.
start = body.find("{")
end = body.rfind("}")
if start >= 0 and end > start:
try:
obj = json.loads(body[start : end + 1])
except (ValueError, TypeError):
return None, text
else:
return None, text
if not isinstance(obj, dict):
return None, text
short = obj.get("short_description")
desc = obj.get("description")
if isinstance(short, str):
short = short.strip() 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
@router.post("/slabs/{slab_id}/inference/call2")
async def call2(slab_id: str) -> dict:
conn = get_connection()
@@ -686,13 +754,22 @@ async def call2(slab_id: str) -> dict:
_log_call(
conn, slab_id, 2, assembled["prompt_revision_id"], model, messages, raw, latency, photo_meta,
)
# Store as description, mark content_source=llm.
conn.execute(
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
(raw.strip(), _now(), slab_id),
)
short, description = parse_call2_response(raw)
if short:
conn.execute(
"UPDATE slabs SET short_description=?, description=?, content_source='llm', updated_at=? WHERE id=?",
(short, description, _now(), slab_id),
)
else:
conn.execute(
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
(description, _now(), slab_id),
)
conn.commit()
return {"ok": True, "description": raw.strip()}
out: dict[str, Any] = {"ok": True, "description": description}
if short:
out["short_description"] = short
return out
def server_description(slab: dict) -> str: