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:
+90
-13
@@ -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:
|
||||
|
||||
@@ -77,6 +77,24 @@ def test_call1_seed_is_locked_expert_prompt(env):
|
||||
assert "character\":" not in CALL1_SEED.replace(" ", "")
|
||||
|
||||
|
||||
def test_call2_seed_has_short_long_structure(env):
|
||||
"""Call 2 seed carries short+long structure and JSON dual output (not brand voice)."""
|
||||
client, _conn = env
|
||||
from backend.inference import CALL2_SEED
|
||||
|
||||
body = client.get("/api/v1/prompts/call2").json()
|
||||
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 "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
|
||||
|
||||
|
||||
def test_taxonomy_lists_call1_closed_shape():
|
||||
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
|
||||
import tempfile, os, shutil
|
||||
@@ -177,10 +195,47 @@ def test_call1_hard_disabled_410(env, monkeypatch):
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_stores_description(env, monkeypatch):
|
||||
def test_call2_stores_short_and_description(env, monkeypatch):
|
||||
"""Call 2 persists both short_description and description from JSON output."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','ready',1,1,'t','t')")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','old short','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
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>",
|
||||
})
|
||||
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")
|
||||
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["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."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','keep me','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
@@ -189,11 +244,33 @@ def test_call2_stores_description(env, monkeypatch):
|
||||
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>"
|
||||
row = conn.execute("SELECT description, content_source FROM slabs WHERE id='s1'").fetchone()
|
||||
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["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_parse_call2_response_json_and_fenced():
|
||||
from backend.inference import parse_call2_response
|
||||
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>"
|
||||
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>"
|
||||
short3, desc3 = parse_call2_response("<p>only long</p>")
|
||||
assert short3 is None
|
||||
assert desc3 == "<p>only long</p>"
|
||||
|
||||
|
||||
def test_call1_disabled_even_when_inference_off(env):
|
||||
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
|
||||
client, conn = env
|
||||
@@ -407,6 +484,13 @@ def test_assemble_call2_includes_declared_attrs_and_dims(env):
|
||||
assert "Brand voice: Warm and precise." in text
|
||||
assert "Geo: Pacific Northwest mill." in text
|
||||
assert CALL2_SEED.split("\n")[0] in text
|
||||
assert "SHORT DESCRIPTION" in text
|
||||
assert "LONG DESCRIPTION" in text
|
||||
assert "short_description" in CALL2_SEED
|
||||
assert '"description"' in CALL2_SEED or "\"description\"" in CALL2_SEED
|
||||
# Prior short must not be fed back into the prompt (Call 2 writes it).
|
||||
assert "short_description: short line" not in text
|
||||
assert "Write BOTH" in CALL2_SEED or "BOTH the short" in CALL2_SEED
|
||||
# No raw base64 in View-prompt shape
|
||||
assert "photos" in assembled
|
||||
assert assembled["photos"][0]["id"] == "p9"
|
||||
|
||||
Reference in New Issue
Block a user