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
+87 -10
View File
@@ -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)
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( conn.execute(
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?", "UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
(raw.strip(), _now(), slab_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:
+87 -3
View File
@@ -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"
+42 -7
View File
@@ -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
+2
View File
@@ -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>
+8 -7
View File
@@ -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({
+2 -4
View File
@@ -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');