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>}'
|
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
|
||||||
)
|
)
|
||||||
CALL2_SEED = (
|
CALL2_SEED = (
|
||||||
"Write the long product description for the slab from the supplied photos and listing "
|
"Write WooCommerce listing copy for this slab from the photos and listing facts below.\n"
|
||||||
"facts. The name and short description are already built, so do not repeat them. Use the "
|
"You write BOTH the short description and the long description.\n"
|
||||||
"brand voice and geo context given. The dimensions in the fact block are exact: use those "
|
"\n"
|
||||||
"strings and invent no numbers. Write prose a premium lumber buyer would read."
|
"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}
|
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
|
||||||
_THRESHOLD = 0.7
|
_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)'}",
|
f"features: {', '.join(_feat_names(conn, feat_ids)) or '(none)'}",
|
||||||
]
|
]
|
||||||
title = (slab.get("title") or "").strip()
|
title = (slab.get("title") or "").strip()
|
||||||
short = (slab.get("short_description") or "").strip()
|
|
||||||
if title:
|
if title:
|
||||||
lines.append(f"title: {title}")
|
lines.append(f"title: {title}")
|
||||||
if short:
|
# short_description is Call 2 output — do not feed a prior value back into the prompt.
|
||||||
lines.append(f"short_description: {short}")
|
|
||||||
return "\n".join(lines)
|
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")
|
@router.post("/slabs/{slab_id}/inference/call2")
|
||||||
async def call2(slab_id: str) -> dict:
|
async def call2(slab_id: str) -> dict:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -686,13 +754,22 @@ async def call2(slab_id: str) -> dict:
|
|||||||
_log_call(
|
_log_call(
|
||||||
conn, slab_id, 2, assembled["prompt_revision_id"], model, messages, raw, latency, photo_meta,
|
conn, slab_id, 2, assembled["prompt_revision_id"], model, messages, raw, latency, photo_meta,
|
||||||
)
|
)
|
||||||
# Store as description, mark content_source=llm.
|
short, description = parse_call2_response(raw)
|
||||||
conn.execute(
|
if short:
|
||||||
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
conn.execute(
|
||||||
(raw.strip(), _now(), slab_id),
|
"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()
|
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:
|
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(" ", "")
|
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():
|
def test_taxonomy_lists_call1_closed_shape():
|
||||||
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
|
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
|
||||||
import tempfile, os, shutil
|
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
|
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
|
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("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.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
from backend.inference import InferenceClient
|
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")
|
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"] == "<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["description"] == "<p>A fine walnut slab.</p>"
|
||||||
assert row["content_source"] == "llm"
|
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):
|
def test_call1_disabled_even_when_inference_off(env):
|
||||||
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
|
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
|
||||||
client, conn = env
|
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 "Brand voice: Warm and precise." in text
|
||||||
assert "Geo: Pacific Northwest mill." in text
|
assert "Geo: Pacific Northwest mill." in text
|
||||||
assert CALL2_SEED.split("\n")[0] 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
|
# No raw base64 in View-prompt shape
|
||||||
assert "photos" in assembled
|
assert "photos" in assembled
|
||||||
assert assembled["photos"][0]["id"] == "p9"
|
assert assembled["photos"][0]["id"] == "p9"
|
||||||
|
|||||||
@@ -96,9 +96,14 @@ No provider thread id is used, and Call 1's raw prompt and result are not sent (
|
|||||||
fact block (`METADATA-65`).
|
fact block (`METADATA-65`).
|
||||||
- The dimension-overlay image is never sent (`METADATA-95`).
|
- The dimension-overlay image is never sent (`METADATA-95`).
|
||||||
|
|
||||||
**Output.** The long `description` prose. The server assembles `name` and `short_description`
|
**Output.** Call 2 writes **both** `short_description` (plain text, ~50 words) and
|
||||||
(`METADATA-55`, `METADATA-80`). The primary wood category selects the specialized prompt
|
`description` (HTML). The seed carries short/long writing structure (benefit→diff→trust short;
|
||||||
(`METADATA-85`). The owner can edit the result (`REVIEW-40`).
|
Hook→Benefits→Specs→Trust long) and requires a JSON object
|
||||||
|
`{"short_description":"...","description":"..."}`. Brand voice and geo stay Settings-injected
|
||||||
|
at assemble time — not hardcoded in the seed. The server still assembles `name`
|
||||||
|
deterministically (`METADATA-55`, `METADATA-80`); short may also be owner-edited or rebuilt via
|
||||||
|
Create short desc. The primary wood category selects the specialized prompt (`METADATA-85`).
|
||||||
|
The owner can edit the result (`REVIEW-40`).
|
||||||
|
|
||||||
## 5. Fallback and templates
|
## 5. Fallback and templates
|
||||||
|
|
||||||
@@ -161,10 +166,40 @@ Return one JSON object with exactly these keys:
|
|||||||
|
|
||||||
Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos.
|
Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos.
|
||||||
|
|
||||||
**`call2` seed.** Write the long product description for the slab from the supplied photos and
|
**`call2` seed.** Shipped text in `backend/inference.py` (`CALL2_SEED`), mirrored here for review.
|
||||||
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
|
Write WooCommerce listing copy for this slab from the photos and listing facts below.
|
||||||
and invent no numbers. Write prose a premium lumber buyer would read.
|
You write BOTH the short description and the long description.
|
||||||
|
|
||||||
|
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,
|
||||||
|
moisture, or features.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
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>"}`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## 7. Prompt management
|
## 7. Prompt management
|
||||||
|
|
||||||
|
|||||||
@@ -364,6 +364,8 @@ export interface Call1Result {
|
|||||||
export interface Call2Result {
|
export interface Call2Result {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Present when Call 2 returned a parsed short_description. */
|
||||||
|
short_description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */
|
/** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
let contentEnabled = false;
|
let contentEnabled = false;
|
||||||
let reachable = false;
|
let reachable = false;
|
||||||
let description: string | null = null;
|
let description: string | null = null;
|
||||||
|
let shortDescription: string | null = null;
|
||||||
let error = '';
|
let error = '';
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
@@ -29,10 +30,14 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
description = null;
|
description = null;
|
||||||
|
shortDescription = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { method: 'POST' });
|
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { method: 'POST' });
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (res.ok) description = body.description;
|
if (res.ok) {
|
||||||
|
description = body.description;
|
||||||
|
shortDescription = body.short_description ?? null;
|
||||||
|
}
|
||||||
else error = body.error?.message || `call2 failed (${res.status})`;
|
else error = body.error?.message || `call2 failed (${res.status})`;
|
||||||
} catch {
|
} catch {
|
||||||
error = 'network error';
|
error = 'network error';
|
||||||
@@ -61,7 +66,8 @@
|
|||||||
<button on:click={generate} disabled={busy}>{busy ? 'Working…' : 'Generate description (Call 2)'}</button>
|
<button on:click={generate} disabled={busy}>{busy ? 'Working…' : 'Generate description (Call 2)'}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if error}<p class="err">{error}</p>{/if}
|
{#if error}<p class="err">{error}</p>{/if}
|
||||||
{#if description}<p class="desc">{description}</p>{/if}
|
{#if shortDescription}<p class="desc"><strong>Short:</strong> {shortDescription}</p>{/if}
|
||||||
|
{#if description}<p class="desc"><strong>Long:</strong> {description}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<button class="mini" on:click={checkReadiness}>Recheck readiness</button>
|
<button class="mini" on:click={checkReadiness}>Recheck readiness</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -136,19 +136,18 @@
|
|||||||
let reviewError = '';
|
let reviewError = '';
|
||||||
// #89: explicit "Create" actions. `createBusy` covers the deterministic
|
// #89: explicit "Create" actions. `createBusy` covers the deterministic
|
||||||
// title/short-desc generation; `canCreateTitle` is its gate (species already
|
// title/short-desc generation; `canCreateTitle` is its gate (species already
|
||||||
// on the slab from Measure + ≥1 wood category). `canCreateDesc` gates the AI
|
// on the slab from Measure + ≥1 wood category). `canCreateDesc` gates Call 2
|
||||||
// long-description on title+short-desc being populated AND the content LLM
|
// (writes short + long) on title + taxonomy + content LLM — not short/desc/price.
|
||||||
// being enabled.
|
|
||||||
let createBusy = false;
|
let createBusy = false;
|
||||||
let createError = '';
|
let createError = '';
|
||||||
// Prefer slab.species_id; fall back to Capture handoff if createSlab omitted it.
|
// Prefer slab.species_id; fall back to Capture handoff if createSlab omitted it.
|
||||||
$: resolvedSpeciesId = effectiveSpeciesId(slab?.species_id, capture?.speciesId ?? null);
|
$: resolvedSpeciesId = effectiveSpeciesId(slab?.species_id, capture?.speciesId ?? null);
|
||||||
$: canCreateTitle = canCreateTitleFromSpecies(resolvedSpeciesId, woodCatIds.length);
|
$: canCreateTitle = canCreateTitleFromSpecies(resolvedSpeciesId, woodCatIds.length);
|
||||||
// Create-desc gate: recompute on every field change (no blur required). Description and price not required.
|
// Create-desc gate: recompute on every field change (no blur required).
|
||||||
|
// Short/description are Call 2 outputs; price is publish-only.
|
||||||
$: createDescMissing = missingCreateDescLabels({
|
$: createDescMissing = missingCreateDescLabels({
|
||||||
contentLlmEnabled: canGenerateContent,
|
contentLlmEnabled: canGenerateContent,
|
||||||
title,
|
title,
|
||||||
shortDescription,
|
|
||||||
speciesId: resolvedSpeciesId,
|
speciesId: resolvedSpeciesId,
|
||||||
woodCategoryIds: woodCatIds,
|
woodCategoryIds: woodCatIds,
|
||||||
primaryCatId,
|
primaryCatId,
|
||||||
@@ -541,10 +540,12 @@
|
|||||||
try {
|
try {
|
||||||
const r = await inferContent(slabId);
|
const r = await inferContent(slabId);
|
||||||
description = r.description;
|
description = r.description;
|
||||||
|
if (r.short_description) shortDescription = r.short_description;
|
||||||
contentSource = 'llm';
|
contentSource = 'llm';
|
||||||
// commitReview() no longer sends description (see #89), so persist it explicitly.
|
// Persist Call 2 short + long (commitReview does not send description — see #89).
|
||||||
const myToken = ++commitToken;
|
const myToken = ++commitToken;
|
||||||
const fresh = await updateSlab(slabId, {
|
const fresh = await updateSlab(slabId, {
|
||||||
|
short_description: shortDescription || null,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
content_source: contentSource || null,
|
content_source: contentSource || null,
|
||||||
client_rev: slab.server_rev,
|
client_rev: slab.server_rev,
|
||||||
@@ -859,7 +860,7 @@
|
|||||||
data-testid="create-desc"
|
data-testid="create-desc"
|
||||||
on:click={generateContent}
|
on:click={generateContent}
|
||||||
disabled={!canCreateDesc || genBusy}
|
disabled={!canCreateDesc || genBusy}
|
||||||
title={canCreateDesc ? 'Generate long description (Call 2)' : `Missing: ${createDescMissing.join(', ')}`}
|
title={canCreateDesc ? 'Generate short + long description (Call 2)' : `Missing: ${createDescMissing.join(', ')}`}
|
||||||
>
|
>
|
||||||
{genBusy ? 'Writing…' : 'Create desc'}
|
{genBusy ? 'Writing…' : 'Create desc'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
const full = {
|
const full = {
|
||||||
contentLlmEnabled: true,
|
contentLlmEnabled: true,
|
||||||
title: 'Figured Walnut Live Edge Slab',
|
title: 'Figured Walnut Live Edge Slab',
|
||||||
shortDescription: 'Figured Walnut Live Edge Slab, 9ft …',
|
|
||||||
speciesId: 11,
|
speciesId: 11,
|
||||||
woodCategoryIds: [21],
|
woodCategoryIds: [21],
|
||||||
primaryCatId: 21,
|
primaryCatId: 21,
|
||||||
@@ -19,7 +18,7 @@ const full = {
|
|||||||
moistureTermId: '326',
|
moistureTermId: '326',
|
||||||
};
|
};
|
||||||
|
|
||||||
test('Create desc gate: full set passes; does not require description', () => {
|
test('Create desc gate: full set passes; does not require short or description', () => {
|
||||||
assert.equal(canCreateDesc(full), true);
|
assert.equal(canCreateDesc(full), true);
|
||||||
assert.deepEqual(missingCreateDesc(full), []);
|
assert.deepEqual(missingCreateDesc(full), []);
|
||||||
});
|
});
|
||||||
@@ -30,12 +29,11 @@ test('Create desc gate: empty price still passes when other fields full', () =>
|
|||||||
assert.deepEqual(missingCreateDesc(full), []);
|
assert.deepEqual(missingCreateDesc(full), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Create desc gate: lists each missing field; no description or price key', () => {
|
test('Create desc gate: lists each missing field; no short/description/price key', () => {
|
||||||
const miss = missingCreateDesc({
|
const miss = missingCreateDesc({
|
||||||
...full,
|
...full,
|
||||||
contentLlmEnabled: false,
|
contentLlmEnabled: false,
|
||||||
title: '',
|
title: '',
|
||||||
shortDescription: ' ',
|
|
||||||
speciesId: null,
|
speciesId: null,
|
||||||
woodCategoryIds: [],
|
woodCategoryIds: [],
|
||||||
primaryCatId: '',
|
primaryCatId: '',
|
||||||
@@ -47,7 +45,6 @@ test('Create desc gate: lists each missing field; no description or price key',
|
|||||||
assert.deepEqual(miss, [
|
assert.deepEqual(miss, [
|
||||||
'content_llm',
|
'content_llm',
|
||||||
'title',
|
'title',
|
||||||
'short_description',
|
|
||||||
'species',
|
'species',
|
||||||
'wood_categories',
|
'wood_categories',
|
||||||
'primary',
|
'primary',
|
||||||
@@ -56,6 +53,7 @@ test('Create desc gate: lists each missing field; no description or price key',
|
|||||||
'grade',
|
'grade',
|
||||||
'moisture',
|
'moisture',
|
||||||
]);
|
]);
|
||||||
|
assert.equal(miss.includes('short_description'), false);
|
||||||
assert.equal(miss.includes('description'), false);
|
assert.equal(miss.includes('description'), false);
|
||||||
assert.equal(miss.includes('price'), false);
|
assert.equal(miss.includes('price'), false);
|
||||||
const labels = missingCreateDescLabels({
|
const labels = missingCreateDescLabels({
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Create-desc gate (Call 2): disable Create desc unless these are present.
|
* Create-desc gate (Call 2): disable Create desc unless these are present.
|
||||||
* Does NOT require description OR price (price stays publish-only).
|
* Does NOT require short_description, description, or price — Call 2 writes
|
||||||
|
* short + long; price stays publish-only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type CreateDescInput = {
|
export type CreateDescInput = {
|
||||||
contentLlmEnabled: boolean;
|
contentLlmEnabled: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
shortDescription: string;
|
|
||||||
speciesId: number | null | undefined;
|
speciesId: number | null | undefined;
|
||||||
woodCategoryIds: number[];
|
woodCategoryIds: number[];
|
||||||
primaryCatId: number | '' | null | undefined;
|
primaryCatId: number | '' | null | undefined;
|
||||||
@@ -20,7 +20,6 @@ export type CreateDescInput = {
|
|||||||
export const CREATE_DESC_LABELS: Record<string, string> = {
|
export const CREATE_DESC_LABELS: Record<string, string> = {
|
||||||
content_llm: 'content LLM on',
|
content_llm: 'content LLM on',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
short_description: 'short description',
|
|
||||||
species: 'species',
|
species: 'species',
|
||||||
wood_categories: '≥1 wood category',
|
wood_categories: '≥1 wood category',
|
||||||
primary: 'primary wood category',
|
primary: 'primary wood category',
|
||||||
@@ -41,7 +40,6 @@ export function missingCreateDesc(input: CreateDescInput): string[] {
|
|||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!input.contentLlmEnabled) missing.push('content_llm');
|
if (!input.contentLlmEnabled) missing.push('content_llm');
|
||||||
if (!String(input.title ?? '').trim()) missing.push('title');
|
if (!String(input.title ?? '').trim()) missing.push('title');
|
||||||
if (!String(input.shortDescription ?? '').trim()) missing.push('short_description');
|
|
||||||
if (input.speciesId == null || !Number.isFinite(Number(input.speciesId))) missing.push('species');
|
if (input.speciesId == null || !Number.isFinite(Number(input.speciesId))) missing.push('species');
|
||||||
if (!input.woodCategoryIds?.length) missing.push('wood_categories');
|
if (!input.woodCategoryIds?.length) missing.push('wood_categories');
|
||||||
if (!presentId(input.primaryCatId)) missing.push('primary');
|
if (!presentId(input.primaryCatId)) missing.push('primary');
|
||||||
|
|||||||
Reference in New Issue
Block a user