feat(review): View prompt + Call 2 declared attrs, Create-desc gate
Shared assemble_call2 for GET .../call2/prompt and POST call2 (seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; photo slots without base64). Create desc disabled until content LLM + title/short + species + wood cats/primary + edge + ≥1 figure + grade + moisture + price; show missing. figure_term_ids required for ready. Moisture defaults to Kiln dried (kd). Pin snl / liveedge in wood-cat UI. Ready-on-change via MultiSelect onCommit.
This commit is contained in:
+236
-27
@@ -436,39 +436,244 @@ def _gate(parsed: dict) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.post("/slabs/{slab_id}/inference/call2")
|
def _json_list(val: Any) -> list:
|
||||||
async def call2(slab_id: str) -> dict:
|
if val is None:
|
||||||
conn = get_connection()
|
return []
|
||||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
if isinstance(val, list):
|
||||||
if row is None:
|
return val
|
||||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
if isinstance(val, str):
|
||||||
if not _enabled(conn, "content_llm_enabled"):
|
try:
|
||||||
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
|
parsed = json.loads(val)
|
||||||
base, key, model = _inference_creds(conn)
|
return parsed if isinstance(parsed, list) else []
|
||||||
if not base or not key:
|
except (ValueError, TypeError):
|
||||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
return []
|
||||||
client = InferenceClient(base, key, model)
|
return []
|
||||||
act = _active_prompt(conn, "call2")
|
|
||||||
prompt_text = act[1] if act else CALL2_SEED
|
|
||||||
|
def _term_name(terms: dict, term_id: Any) -> str:
|
||||||
|
if not term_id:
|
||||||
|
return ""
|
||||||
|
t = terms.get(int(term_id) if not isinstance(term_id, int) and str(term_id).isdigit() else term_id) or {}
|
||||||
|
try:
|
||||||
|
from .taxonomy import _decode_name
|
||||||
|
return _decode_name(t.get("name") or "")
|
||||||
|
except Exception:
|
||||||
|
return str(t.get("name") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _cat_name(cats: dict, cat_id: Any) -> str:
|
||||||
|
if not cat_id:
|
||||||
|
return ""
|
||||||
|
c = cats.get(int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id) or {}
|
||||||
|
try:
|
||||||
|
from .taxonomy import _decode_name
|
||||||
|
return _decode_name(c.get("name") or "")
|
||||||
|
except Exception:
|
||||||
|
return str(c.get("name") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_path(cats: dict, cat_id: Any) -> str:
|
||||||
|
"""Parent-category path for a wood-category leaf (METADATA-60)."""
|
||||||
|
if not cat_id:
|
||||||
|
return ""
|
||||||
|
cid = int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id
|
||||||
|
leaf = cats.get(cid) or {}
|
||||||
|
parts: list[str] = []
|
||||||
|
pid = leaf.get("parent_id")
|
||||||
|
seen = set()
|
||||||
|
while pid and pid not in seen:
|
||||||
|
seen.add(pid)
|
||||||
|
parent = cats.get(pid) or {}
|
||||||
|
name = _cat_name(cats, pid)
|
||||||
|
if name:
|
||||||
|
parts.append(name)
|
||||||
|
pid = parent.get("parent_id")
|
||||||
|
parts.reverse()
|
||||||
|
leaf_name = _cat_name(cats, cid)
|
||||||
|
if leaf_name:
|
||||||
|
parts.append(leaf_name)
|
||||||
|
return " > ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _feat_names(conn, feat_ids: list) -> list[str]:
|
||||||
|
if not feat_ids:
|
||||||
|
return []
|
||||||
|
q = ",".join("?" * len(feat_ids))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT id, name FROM woo_tags WHERE id IN ({q})", feat_ids
|
||||||
|
).fetchall()
|
||||||
|
by_id = {r[0]: _feat_display_name(r[1]) for r in rows}
|
||||||
|
return [by_id[i] for i in feat_ids if i in by_id]
|
||||||
|
|
||||||
|
|
||||||
|
def build_declared_attributes(conn, slab: dict) -> str:
|
||||||
|
"""Structured DECLARED ATTRIBUTES from the slab for Call 2 (METADATA-60).
|
||||||
|
|
||||||
|
Names only — no hardcoded Curl≠Curly bridges. Primary = first wood category
|
||||||
|
when not otherwise known; secondary = the rest.
|
||||||
|
"""
|
||||||
|
cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()}
|
||||||
|
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||||
|
wood_ids = _json_list(slab.get("wood_category_ids"))
|
||||||
|
primary_id = wood_ids[0] if wood_ids else None
|
||||||
|
secondary_ids = wood_ids[1:] if len(wood_ids) > 1 else []
|
||||||
|
figure_ids = _json_list(slab.get("figure_term_ids"))
|
||||||
|
feat_ids = _json_list(slab.get("feat_tag_ids"))
|
||||||
|
lines = [
|
||||||
|
"DECLARED ATTRIBUTES (confirmed in review — use these names; do not invent taxonomy):",
|
||||||
|
f"species: {_cat_name(cats, slab.get('species_id')) or '(none)'}",
|
||||||
|
f"primary wood category: {_cat_name(cats, primary_id) or '(none)'}",
|
||||||
|
f"primary path: {_parent_path(cats, primary_id) or '(none)'}",
|
||||||
|
f"secondary wood categories: {', '.join(_cat_name(cats, i) for i in secondary_ids) or '(none)'}",
|
||||||
|
f"edge: {_term_name(terms, slab.get('edge_type_term_id')) or '(none)'}",
|
||||||
|
f"figure: {', '.join(_term_name(terms, i) for i in figure_ids) or '(none)'}",
|
||||||
|
f"grade: {_term_name(terms, slab.get('grade_term_id')) or '(none)'}",
|
||||||
|
f"moisture: {_term_name(terms, slab.get('moisture_term_id')) or '(none)'}",
|
||||||
|
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}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dimension_fact_block(slab: dict) -> str:
|
||||||
|
"""Exact dimension strings — invent no numbers (METADATA-65/90)."""
|
||||||
|
return (
|
||||||
|
"DIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
||||||
|
f"length={slab.get('length_in')}in thickness={slab.get('thickness_in')}in "
|
||||||
|
f"avg_width={slab.get('width_avg_in')}in width_min={slab.get('width_min_in')}in "
|
||||||
|
f"width_max={slab.get('width_max_in')}in bdft={slab.get('bdft')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_call2_text(conn, slab: dict, prompt_text: str | None = None) -> str:
|
||||||
|
"""Assemble the Call 2 text payload: seed + declared attrs + dims + brand/geo.
|
||||||
|
|
||||||
|
Shared by View-prompt GET and POST .../inference/call2 so both show the same text.
|
||||||
|
"""
|
||||||
|
seed = prompt_text
|
||||||
|
if seed is None:
|
||||||
|
act = _active_prompt(conn, "call2")
|
||||||
|
seed = act[1] if act else CALL2_SEED
|
||||||
|
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
||||||
|
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
||||||
|
brand_s = brand[0] if brand and brand[0] else ""
|
||||||
|
geo_s = geo[0] if geo and geo[0] else ""
|
||||||
|
return (
|
||||||
|
f"{seed}\n\n"
|
||||||
|
f"{build_declared_attributes(conn, slab)}\n\n"
|
||||||
|
f"{build_dimension_fact_block(slab)}\n\n"
|
||||||
|
f"Brand voice: {brand_s}\n"
|
||||||
|
f"Geo: {geo_s}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_call2_photo_slots(conn, slab_id: str, *, for_wire: bool = False) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Up to 8 processed PNGs for Call 2.
|
||||||
|
|
||||||
|
Returns (image_parts for the model, photo_slots without base64 for View prompt / log).
|
||||||
|
Slot list: seq, id, bytes, long_edge_px of the *fitted* infer payload.
|
||||||
|
"""
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||||
(slab_id,),
|
(slab_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
image_parts = []
|
image_parts: list[dict] = []
|
||||||
photo_meta: list[dict] = []
|
photo_slots: list[dict] = []
|
||||||
for p in photos:
|
for p in photos:
|
||||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||||
image_parts.append(_image_part_from_processed(blob))
|
fitted = fit_long_edge_png(blob)
|
||||||
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
if for_wire:
|
||||||
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
image_parts.append(_image_part_from_processed(blob))
|
||||||
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
meta = _photo_meta_row(pid, seq, fitted)
|
||||||
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
photo_slots.append({
|
||||||
f"length={row['length_in']}in thickness={row['thickness_in']}in "
|
"seq": meta["seq"],
|
||||||
f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in "
|
"id": meta["photo_id"],
|
||||||
f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}")
|
"bytes": meta["bytes"],
|
||||||
|
"long_edge_px": meta["infer_long_edge_px"],
|
||||||
|
})
|
||||||
|
return image_parts, photo_slots
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_call2(conn, slab_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Full Call 2 assembly used by View prompt and the live POST.
|
||||||
|
|
||||||
|
Returns None if the slab is missing. Does not call the model.
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
slab = dict(row)
|
||||||
|
for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"):
|
||||||
|
if slab.get(f) is not None and isinstance(slab[f], str):
|
||||||
|
try:
|
||||||
|
slab[f] = json.loads(slab[f])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
slab[f] = []
|
||||||
|
act = _active_prompt(conn, "call2")
|
||||||
|
prompt_text = act[1] if act else CALL2_SEED
|
||||||
|
rev_id = act[0] if act else None
|
||||||
|
text = assemble_call2_text(conn, slab, prompt_text)
|
||||||
|
image_parts, photo_slots = assemble_call2_photo_slots(conn, slab_id, for_wire=True)
|
||||||
|
# photo_meta for the inference log (same order as image_parts)
|
||||||
|
photo_meta = [
|
||||||
|
{
|
||||||
|
"photo_id": s["id"],
|
||||||
|
"seq": s["seq"],
|
||||||
|
"bytes": s["bytes"],
|
||||||
|
"infer_long_edge_px": s["long_edge_px"],
|
||||||
|
"label": f"photo seq={s['seq']} id={s['id']}",
|
||||||
|
}
|
||||||
|
for s in photo_slots
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"photos": photo_slots,
|
||||||
|
"image_parts": image_parts,
|
||||||
|
"photo_meta": photo_meta,
|
||||||
|
"prompt_revision_id": rev_id,
|
||||||
|
"slab": slab,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/slabs/{slab_id}/inference/call2/prompt")
|
||||||
|
async def call2_prompt(slab_id: str) -> dict:
|
||||||
|
"""View prompt: exact Call 2 text + photo slot list (no base64, no model call)."""
|
||||||
|
conn = get_connection()
|
||||||
|
assembled = assemble_call2(conn, slab_id)
|
||||||
|
if assembled is None:
|
||||||
|
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||||
|
return {
|
||||||
|
"text": assembled["text"],
|
||||||
|
"photos": assembled["photos"],
|
||||||
|
"prompt_revision_id": assembled["prompt_revision_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/slabs/{slab_id}/inference/call2")
|
||||||
|
async def call2(slab_id: str) -> dict:
|
||||||
|
conn = get_connection()
|
||||||
|
if not _enabled(conn, "content_llm_enabled"):
|
||||||
|
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
|
||||||
|
base, key, model = _inference_creds(conn)
|
||||||
|
if not base or not key:
|
||||||
|
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||||
|
assembled = assemble_call2(conn, slab_id)
|
||||||
|
if assembled is None:
|
||||||
|
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||||
|
client = InferenceClient(base, key, model)
|
||||||
|
text = assembled["text"]
|
||||||
|
image_parts = assembled["image_parts"]
|
||||||
|
photo_meta = assembled["photo_meta"]
|
||||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -478,10 +683,14 @@ async def call2(slab_id: str) -> dict:
|
|||||||
except InferenceError as e:
|
except InferenceError as e:
|
||||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||||
latency = int((time.time() - t0) * 1000)
|
latency = int((time.time() - t0) * 1000)
|
||||||
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
_log_call(
|
||||||
|
conn, slab_id, 2, assembled["prompt_revision_id"], model, messages, raw, latency, photo_meta,
|
||||||
|
)
|
||||||
# Store as description, mark content_source=llm.
|
# Store as description, mark content_source=llm.
|
||||||
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
conn.execute(
|
||||||
(raw.strip(), _now(), slab_id))
|
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||||
|
(raw.strip(), _now(), slab_id),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "description": raw.strip()}
|
return {"ok": True, "description": raw.strip()}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ _REQUIRED = [
|
|||||||
"sku", "length_in", "thickness_in", "sqft", "bdft",
|
"sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||||
"width_min_in", "width_max_in", "width_avg_in",
|
"width_min_in", "width_max_in", "width_avg_in",
|
||||||
"species_id", "wood_category_ids", "edge_type_term_id",
|
"species_id", "wood_category_ids", "edge_type_term_id",
|
||||||
|
"figure_term_ids", # UX requires ≥1 figure (Create-desc gate + ready)
|
||||||
"grade_term_id", "thickness_term_id", "moisture_term_id",
|
"grade_term_id", "thickness_term_id", "moisture_term_id",
|
||||||
"price", "price_source", "title", "short_description", "description",
|
"price", "price_source", "title", "short_description", "description",
|
||||||
"content_source",
|
"content_source",
|
||||||
|
|||||||
@@ -342,3 +342,116 @@ def test_call1_sends_long_edge_dormant_while_disabled(env):
|
|||||||
r = client.post("/api/v1/slabs/missing/inference/call1")
|
r = client.post("/api/v1/slabs/missing/inference/call1")
|
||||||
assert r.status_code == 410
|
assert r.status_code == 410
|
||||||
assert r.json()["error"]["code"] == "call1_disabled"
|
assert r.json()["error"]["code"] == "call1_disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_assemble_call2_includes_declared_attrs_and_dims(env):
|
||||||
|
"""Call 2 text = seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; shared assembler."""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES "
|
||||||
|
"(10,'Species','species',NULL,10),"
|
||||||
|
"(11,'Black Walnut','black-walnut',10,11),"
|
||||||
|
"(20,'Wcat','wcat',NULL,20),"
|
||||||
|
"(21,'Slabs & Lumber','snl',20,21),"
|
||||||
|
"(22,'Live edge wood','liveedge',21,22)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES "
|
||||||
|
"(6,'Edge type','pa_etype',6),(8,'Figure','pa_fig',8),"
|
||||||
|
"(9,'Grade','pa_gr',9),(7,'Moisture','pa_mst',7)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES "
|
||||||
|
"(321,6,'Live Edge 1 side','le1',321),"
|
||||||
|
"(328,8,'Curl','curl',328),"
|
||||||
|
"(342,9,'Fancy','fnc',342),"
|
||||||
|
"(326,7,'Kiln dried','kd',326)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
|
||||||
|
"('brand_voice','Warm and precise.',NULL),"
|
||||||
|
"('geo_context','Pacific Northwest mill.',NULL)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||||
|
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,moisture_term_id,"
|
||||||
|
"feat_tag_ids,title,short_description,status,server_rev,client_rev,created_at,updated_at) "
|
||||||
|
"VALUES ('s1','SLAB-01',114.0,1.75,24.3,18.0,24.3,18.4,11,?,321,?,342,326,?,"
|
||||||
|
"'Figured Black Walnut Live Edge Slab','short line','ready',1,1,'t','t')",
|
||||||
|
(json.dumps([22]), json.dumps([328]), json.dumps([1])),
|
||||||
|
)
|
||||||
|
png = _rgba_png(200, 200)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p9','s1',1,?, 't')",
|
||||||
|
(png,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
from backend.inference import assemble_call2, assemble_call2_text, CALL2_SEED
|
||||||
|
|
||||||
|
assembled = assemble_call2(conn, "s1")
|
||||||
|
assert assembled is not None
|
||||||
|
text = assembled["text"]
|
||||||
|
assert "DECLARED ATTRIBUTES" in text
|
||||||
|
assert "species: Black Walnut" in text
|
||||||
|
assert "primary wood category: Live edge wood" in text
|
||||||
|
assert "edge: Live Edge 1 side" in text
|
||||||
|
assert "figure: Curl" in text
|
||||||
|
assert "grade: Fancy" in text
|
||||||
|
assert "moisture: Kiln dried" in text
|
||||||
|
assert "features: knot" in text
|
||||||
|
assert "DIMENSION FACT BLOCK" in text
|
||||||
|
assert "length=114.0in" in text
|
||||||
|
assert "Brand voice: Warm and precise." in text
|
||||||
|
assert "Geo: Pacific Northwest mill." in text
|
||||||
|
assert CALL2_SEED.split("\n")[0] in text
|
||||||
|
# No raw base64 in View-prompt shape
|
||||||
|
assert "photos" in assembled
|
||||||
|
assert assembled["photos"][0]["id"] == "p9"
|
||||||
|
assert assembled["photos"][0]["seq"] == 1
|
||||||
|
assert assembled["photos"][0]["bytes"] > 0
|
||||||
|
assert "base64" not in json.dumps(assembled["photos"])
|
||||||
|
# View prompt GET mirrors assembler (no model)
|
||||||
|
r = client.get("/api/v1/slabs/s1/inference/call2/prompt")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["text"] == text
|
||||||
|
assert body["photos"][0]["id"] == "p9"
|
||||||
|
assert "data:image" not in json.dumps(body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call2_post_uses_same_assembler_text(env, monkeypatch):
|
||||||
|
"""POST call2 sends the same assembled text View prompt returns."""
|
||||||
|
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 "
|
||||||
|
"('brand_voice','voice',NULL),('geo_context','geo',NULL)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||||
|
"title,status,server_rev,client_rev,created_at,updated_at) "
|
||||||
|
"VALUES ('s1','SLAB-01',100,2,20,18,22,18,'Walnut Slab','ready',1,1,'t','t')"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
|
||||||
|
(_rgba_png(64, 64),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
from backend.inference import InferenceClient, assemble_call2
|
||||||
|
|
||||||
|
expected = assemble_call2(conn, "s1")["text"]
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_chat(self, messages):
|
||||||
|
captured["text"] = messages[0]["content"][-1]["text"]
|
||||||
|
return "<p>ok</p>"
|
||||||
|
|
||||||
|
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||||
|
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert captured["text"] == expected
|
||||||
|
assert "DECLARED ATTRIBUTES" in captured["text"]
|
||||||
|
assert "DIMENSION FACT BLOCK" in captured["text"]
|
||||||
|
|||||||
@@ -194,3 +194,29 @@ def test_review_persists_owner_overrides(env):
|
|||||||
assert got["sku"] == "SLAB-99"
|
assert got["sku"] == "SLAB-99"
|
||||||
# server_rev was incremented (the UPDATE fired).
|
# server_rev was incremented (the UPDATE fired).
|
||||||
assert got["server_rev"] == 2
|
assert got["server_rev"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_requires_figure_term_ids(env):
|
||||||
|
"""UX requires ≥1 figure — figure_term_ids is in _REQUIRED."""
|
||||||
|
from backend.review import missing_required, compute_ready
|
||||||
|
|
||||||
|
slab = _slab(figure_term_ids=[])
|
||||||
|
miss = missing_required(slab)
|
||||||
|
assert "figure_term_ids" in miss
|
||||||
|
ready, missing = compute_ready({**slab, "price": 1.0, "price_source": "manual",
|
||||||
|
"title": "T", "short_description": "S",
|
||||||
|
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||||
|
"thickness_term_id": 1, "moisture_term_id": 1})
|
||||||
|
assert ready is False
|
||||||
|
assert "figure_term_ids" in missing
|
||||||
|
|
||||||
|
ready2, missing2 = compute_ready({
|
||||||
|
**slab,
|
||||||
|
"figure_term_ids": [1],
|
||||||
|
"price": 1.0, "price_source": "manual",
|
||||||
|
"title": "T", "short_description": "S",
|
||||||
|
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||||
|
"thickness_term_id": 1, "moisture_term_id": 1,
|
||||||
|
})
|
||||||
|
# May still miss other fields depending on fixture; figure must not be listed
|
||||||
|
assert "figure_term_ids" not in missing2
|
||||||
|
|||||||
@@ -379,6 +379,26 @@ export function inferContent(slabId: string): Promise<Call2Result> {
|
|||||||
}).then((r) => parse<Call2Result>(r));
|
}).then((r) => parse<Call2Result>(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** View prompt: assembled Call 2 text + photo slots (no base64, no model call). */
|
||||||
|
export interface Call2PromptPhoto {
|
||||||
|
seq: number;
|
||||||
|
id: string;
|
||||||
|
bytes: number;
|
||||||
|
long_edge_px: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Call2Prompt {
|
||||||
|
text: string;
|
||||||
|
photos: Call2PromptPhoto[];
|
||||||
|
prompt_revision_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCall2Prompt(slabId: string): Promise<Call2Prompt> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2/prompt`).then((r) =>
|
||||||
|
parse<Call2Prompt>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Taxonomy (METADATA-5/15/30; only legal picker options)
|
// Taxonomy (METADATA-5/15/30; only legal picker options)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import {
|
import {
|
||||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferContent,
|
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferContent, getCall2Prompt,
|
||||||
listPhotos, persistLineup, photoUrl, ApiError,
|
listPhotos, persistLineup, photoUrl, ApiError,
|
||||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
||||||
|
type Call2Prompt,
|
||||||
} from '../../api/slabs';
|
} from '../../api/slabs';
|
||||||
import type { CalibratedCapture } from '../flow';
|
import type { CalibratedCapture } from '../flow';
|
||||||
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
||||||
@@ -15,6 +16,9 @@
|
|||||||
type LineupItem,
|
type LineupItem,
|
||||||
} from './lineup';
|
} from './lineup';
|
||||||
import { nextPrimary } from './woodPrimary';
|
import { nextPrimary } from './woodPrimary';
|
||||||
|
import { missingCreateDescLabels } from './createDescGate';
|
||||||
|
import { orderWoodCatGroups } from './woodCatOrder';
|
||||||
|
import { applyMoistureDefault } from './moistureDefault';
|
||||||
|
|
||||||
// Props handed off by App (one-column flow, UX.md §1).
|
// Props handed off by App (one-column flow, UX.md §1).
|
||||||
export let slabId: string;
|
export let slabId: string;
|
||||||
@@ -29,7 +33,7 @@
|
|||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
// Picker option types (id + name + whether the AI recommended it).
|
// Picker option types (id + name + whether the AI recommended it).
|
||||||
interface Opt { id: number; name: string; ai: boolean; lowIn?: number }
|
interface Opt { id: number; name: string; ai: boolean; lowIn?: number; slug?: string }
|
||||||
const byId = <T extends { id: number }>(arr: T[]) => {
|
const byId = <T extends { id: number }>(arr: T[]) => {
|
||||||
const m = new Map<number, T>();
|
const m = new Map<number, T>();
|
||||||
for (const x of arr) m.set(x.id, x);
|
for (const x of arr) m.set(x.id, x);
|
||||||
@@ -70,13 +74,18 @@
|
|||||||
// assigned). Each direct child of `wcat` that has its own children becomes a parent
|
// assigned). Each direct child of `wcat` that has its own children becomes a parent
|
||||||
// group with those children as leaves; a child with no children is a leaf under the
|
// group with those children as leaves; a child with no children is a leaf under the
|
||||||
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
|
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
|
||||||
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
// UI order only: pin Slabs & Lumber (snl) parent first; Live edge wood (liveedge) leaf first.
|
||||||
|
const rawGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
||||||
const kids = cats.filter((c) => c.parent_id === parent.id);
|
const kids = cats.filter((c) => c.parent_id === parent.id);
|
||||||
const leaves: Opt[] = kids.length
|
const leaves: Opt[] = kids.length
|
||||||
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false }))
|
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false, slug: c.slug }))
|
||||||
: [{ id: parent.id, name: decodeName(parent.name), ai: false }];
|
: [{ id: parent.id, name: decodeName(parent.name), ai: false, slug: parent.slug }];
|
||||||
return { parent: { id: parent.id, name: decodeName(parent.name) }, leaves };
|
return {
|
||||||
|
parent: { id: parent.id, name: decodeName(parent.name), slug: parent.slug },
|
||||||
|
leaves,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
woodCatGroups = orderWoodCatGroups(rawGroups);
|
||||||
edgeOpts = termsOf('edge type');
|
edgeOpts = termsOf('edge type');
|
||||||
figureOpts = termsOf('figure');
|
figureOpts = termsOf('figure');
|
||||||
gradeOpts = termsOf('grade');
|
gradeOpts = termsOf('grade');
|
||||||
@@ -104,7 +113,7 @@
|
|||||||
let gradeSel: string = '';
|
let gradeSel: string = '';
|
||||||
let woodCatIds: number[] = [];
|
let woodCatIds: number[] = [];
|
||||||
let figureIds: number[] = [];
|
let figureIds: number[] = [];
|
||||||
let moistureId: number | '' = '';
|
let moistureSel: string = '';
|
||||||
let figTagIds: number[] = [];
|
let figTagIds: number[] = [];
|
||||||
let featTagIds: number[] = [];
|
let featTagIds: number[] = [];
|
||||||
let primaryCatId: number | '' = '';
|
let primaryCatId: number | '' = '';
|
||||||
@@ -135,8 +144,21 @@
|
|||||||
// 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);
|
||||||
$: canCreateDesc =
|
// Create-desc gate: recompute on every field change (no blur required). Description not required.
|
||||||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
$: createDescMissing = missingCreateDescLabels({
|
||||||
|
contentLlmEnabled: canGenerateContent,
|
||||||
|
title,
|
||||||
|
shortDescription,
|
||||||
|
speciesId: resolvedSpeciesId,
|
||||||
|
woodCategoryIds: woodCatIds,
|
||||||
|
primaryCatId,
|
||||||
|
edgeTermId: edgeSel,
|
||||||
|
figureTermIds: figureIds,
|
||||||
|
gradeTermId: gradeSel,
|
||||||
|
moistureTermId: moistureSel,
|
||||||
|
price,
|
||||||
|
});
|
||||||
|
$: canCreateDesc = createDescMissing.length === 0;
|
||||||
// Monotonic token: each commit captures the current value and ignores its
|
// Monotonic token: each commit captures the current value and ignores its
|
||||||
// result if a newer commit has since started. Without this, two concurrent
|
// result if a newer commit has since started. Without this, two concurrent
|
||||||
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||||||
@@ -323,7 +345,15 @@
|
|||||||
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
||||||
figureIds = [...(s.figure_term_ids ?? [])];
|
figureIds = [...(s.figure_term_ids ?? [])];
|
||||||
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
||||||
moistureId = s.moisture_term_id ?? '';
|
// Moisture default Kiln dried (slug kd) when empty — persist via commitReview after load.
|
||||||
|
{
|
||||||
|
const moistAttr = t?.attributes.find((a) => a.name.toLowerCase() === 'moisture');
|
||||||
|
const moistTerms = (moistAttr?.terms ?? []).map((x) => ({
|
||||||
|
id: x.id, name: x.name, slug: x.slug,
|
||||||
|
}));
|
||||||
|
const applied = applyMoistureDefault(s.moisture_term_id, moistTerms);
|
||||||
|
moistureSel = applied === '' ? '' : String(applied);
|
||||||
|
}
|
||||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||||
title = s.title ?? '';
|
title = s.title ?? '';
|
||||||
shortDescription = s.short_description ?? '';
|
shortDescription = s.short_description ?? '';
|
||||||
@@ -396,7 +426,7 @@
|
|||||||
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
||||||
figure_term_ids: figureIds,
|
figure_term_ids: figureIds,
|
||||||
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
|
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
|
||||||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
moisture_term_id: moistureSel === '' ? null : Number(moistureSel),
|
||||||
fig_tag_ids: figTagIds,
|
fig_tag_ids: figTagIds,
|
||||||
feat_tag_ids: featTagIds,
|
feat_tag_ids: featTagIds,
|
||||||
title: title || null,
|
title: title || null,
|
||||||
@@ -529,6 +559,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View prompt: read-only assembled Call 2 text + photo slots (shared server assembler).
|
||||||
|
let viewPromptOpen = false;
|
||||||
|
let viewPromptBusy = false;
|
||||||
|
let viewPromptError = '';
|
||||||
|
let viewPrompt: Call2Prompt | null = null;
|
||||||
|
let viewPromptCopied = false;
|
||||||
|
|
||||||
|
async function loadViewPrompt() {
|
||||||
|
viewPromptBusy = true;
|
||||||
|
viewPromptError = '';
|
||||||
|
viewPromptCopied = false;
|
||||||
|
try {
|
||||||
|
// Persist current Review fields first so the assembled text matches what Create desc would send.
|
||||||
|
await commitReview();
|
||||||
|
viewPrompt = await getCall2Prompt(slabId);
|
||||||
|
viewPromptOpen = true;
|
||||||
|
} catch (e) {
|
||||||
|
viewPromptError = e instanceof ApiError ? e.message : 'Could not load the prompt.';
|
||||||
|
viewPromptOpen = true;
|
||||||
|
} finally {
|
||||||
|
viewPromptBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyViewPrompt() {
|
||||||
|
if (!viewPrompt?.text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(viewPrompt.text);
|
||||||
|
viewPromptCopied = true;
|
||||||
|
} catch {
|
||||||
|
viewPromptError = 'Clipboard unavailable — select the text and copy manually.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const missingCount = () => (review ? review.missing.length : null);
|
const missingCount = () => (review ? review.missing.length : null);
|
||||||
const ready = () => review?.ready === true;
|
const ready = () => review?.ready === true;
|
||||||
// The button's enabled state must derive from the SAME reactive `review` object
|
// The button's enabled state must derive from the SAME reactive `review` object
|
||||||
@@ -554,9 +618,11 @@
|
|||||||
await tick();
|
await tick();
|
||||||
if (edgeEl && edgeEl.value !== edgeSel) edgeEl.value = edgeSel;
|
if (edgeEl && edgeEl.value !== edgeSel) edgeEl.value = edgeSel;
|
||||||
if (gradeEl && gradeEl.value !== gradeSel) gradeEl.value = gradeSel;
|
if (gradeEl && gradeEl.value !== gradeSel) gradeEl.value = gradeSel;
|
||||||
|
if (moistureEl && moistureEl.value !== moistureSel) moistureEl.value = moistureSel;
|
||||||
}
|
}
|
||||||
let edgeEl: HTMLSelectElement | undefined;
|
let edgeEl: HTMLSelectElement | undefined;
|
||||||
let gradeEl: HTMLSelectElement | undefined;
|
let gradeEl: HTMLSelectElement | undefined;
|
||||||
|
let moistureEl: HTMLSelectElement | undefined;
|
||||||
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
|
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
|
||||||
function toggleWoodCat(id: number) {
|
function toggleWoodCat(id: number) {
|
||||||
woodCatIds = toggleMulti(woodCatIds, id);
|
woodCatIds = toggleMulti(woodCatIds, id);
|
||||||
@@ -675,6 +741,7 @@
|
|||||||
placeholder="Choose wood categories"
|
placeholder="Choose wood categories"
|
||||||
testid="wood-cat"
|
testid="wood-cat"
|
||||||
onToggle={toggleWoodCat}
|
onToggle={toggleWoodCat}
|
||||||
|
onCommit={commitReview}
|
||||||
on:commit={commitReview}
|
on:commit={commitReview}
|
||||||
/>
|
/>
|
||||||
{#if primaryOpts.length}
|
{#if primaryOpts.length}
|
||||||
@@ -723,6 +790,7 @@
|
|||||||
placeholder="Choose figure"
|
placeholder="Choose figure"
|
||||||
testid="figure"
|
testid="figure"
|
||||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||||
|
onCommit={commitReview}
|
||||||
on:commit={commitReview}
|
on:commit={commitReview}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -746,6 +814,7 @@
|
|||||||
placeholder="Choose features"
|
placeholder="Choose features"
|
||||||
testid="feat-tag"
|
testid="feat-tag"
|
||||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||||
|
onCommit={commitReview}
|
||||||
on:commit={commitReview}
|
on:commit={commitReview}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -754,7 +823,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
||||||
<label class="lbl" for="moisture">Moisture</label>
|
<label class="lbl" for="moisture">Moisture</label>
|
||||||
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
<select id="moisture" class="sel" data-testid="moisture" bind:this={moistureEl} bind:value={moistureSel} on:change={commitReview}>
|
||||||
<option value="">— select —</option>
|
<option value="">— select —</option>
|
||||||
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -784,11 +853,59 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl" for="desc">Description</label>
|
<label class="lbl" for="desc">Description</label>
|
||||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
||||||
{#if canCreateDesc}
|
<div class="gen">
|
||||||
<div class="gen">
|
<button
|
||||||
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
|
type="button"
|
||||||
{genBusy ? 'Writing…' : 'Create desc'}
|
class="gen-btn"
|
||||||
</button>
|
data-testid="create-desc"
|
||||||
|
on:click={generateContent}
|
||||||
|
disabled={!canCreateDesc || genBusy}
|
||||||
|
title={canCreateDesc ? 'Generate long description (Call 2)' : `Missing: ${createDescMissing.join(', ')}`}
|
||||||
|
>
|
||||||
|
{genBusy ? 'Writing…' : 'Create desc'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="view-prompt-btn"
|
||||||
|
data-testid="view-prompt"
|
||||||
|
on:click={() => { if (viewPromptOpen) viewPromptOpen = false; else void loadViewPrompt(); }}
|
||||||
|
disabled={viewPromptBusy}
|
||||||
|
>
|
||||||
|
{viewPromptBusy ? 'Loading…' : (viewPromptOpen ? 'Hide prompt' : 'View prompt')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if !canCreateDesc}
|
||||||
|
<p class="nudge" data-testid="create-desc-missing">
|
||||||
|
Create desc needs: {createDescMissing.join(', ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if viewPromptOpen}
|
||||||
|
<div class="prompt-panel" data-testid="view-prompt-panel">
|
||||||
|
<div class="prompt-head">
|
||||||
|
<span class="prompt-title">Call 2 prompt (read-only)</span>
|
||||||
|
<button type="button" class="mini" data-testid="copy-prompt" on:click={copyViewPrompt} disabled={!viewPrompt?.text}>
|
||||||
|
{viewPromptCopied ? 'Copied' : 'Copy text'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mini" data-testid="close-prompt" on:click={() => (viewPromptOpen = false)}>Close</button>
|
||||||
|
</div>
|
||||||
|
{#if viewPromptError}<p class="nudge" role="alert">{viewPromptError}</p>{/if}
|
||||||
|
{#if viewPrompt}
|
||||||
|
<pre class="prompt-text" data-testid="prompt-text">{viewPrompt.text}</pre>
|
||||||
|
<div class="prompt-photos" data-testid="prompt-photos">
|
||||||
|
<div class="prompt-photos-lbl">Photo slots (no base64) — up to 8 processed PNGs</div>
|
||||||
|
{#if viewPrompt.photos.length === 0}
|
||||||
|
<p class="nudge">No processed photos on this slab.</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each viewPrompt.photos as ph (ph.id)}
|
||||||
|
<li data-testid="prompt-photo-slot">
|
||||||
|
seq={ph.seq} · id={ph.id} · {ph.bytes} bytes · long-edge {ph.long_edge_px ?? '?'}px
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
canCreateDesc,
|
||||||
|
missingCreateDesc,
|
||||||
|
missingCreateDescLabels,
|
||||||
|
} from './createDescGate';
|
||||||
|
|
||||||
|
const full = {
|
||||||
|
contentLlmEnabled: true,
|
||||||
|
title: 'Figured Walnut Live Edge Slab',
|
||||||
|
shortDescription: 'Figured Walnut Live Edge Slab, 9ft …',
|
||||||
|
speciesId: 11,
|
||||||
|
woodCategoryIds: [21],
|
||||||
|
primaryCatId: 21,
|
||||||
|
edgeTermId: '321',
|
||||||
|
figureTermIds: [328],
|
||||||
|
gradeTermId: 342,
|
||||||
|
moistureTermId: '326',
|
||||||
|
price: '184.00',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Create desc gate: full set passes; does not require description', () => {
|
||||||
|
assert.equal(canCreateDesc(full), true);
|
||||||
|
assert.deepEqual(missingCreateDesc(full), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Create desc gate: lists each missing field; no description key', () => {
|
||||||
|
const miss = missingCreateDesc({
|
||||||
|
...full,
|
||||||
|
contentLlmEnabled: false,
|
||||||
|
title: '',
|
||||||
|
shortDescription: ' ',
|
||||||
|
speciesId: null,
|
||||||
|
woodCategoryIds: [],
|
||||||
|
primaryCatId: '',
|
||||||
|
edgeTermId: '',
|
||||||
|
figureTermIds: [],
|
||||||
|
gradeTermId: '',
|
||||||
|
moistureTermId: '',
|
||||||
|
price: '',
|
||||||
|
});
|
||||||
|
assert.deepEqual(miss, [
|
||||||
|
'content_llm',
|
||||||
|
'title',
|
||||||
|
'short_description',
|
||||||
|
'species',
|
||||||
|
'wood_categories',
|
||||||
|
'primary',
|
||||||
|
'edge',
|
||||||
|
'figure',
|
||||||
|
'grade',
|
||||||
|
'moisture',
|
||||||
|
'price',
|
||||||
|
]);
|
||||||
|
assert.equal(miss.includes('description'), false);
|
||||||
|
const labels = missingCreateDescLabels({
|
||||||
|
...full,
|
||||||
|
figureTermIds: [],
|
||||||
|
price: '',
|
||||||
|
});
|
||||||
|
assert.ok(labels.includes('≥1 figure'));
|
||||||
|
assert.ok(labels.includes('price'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Create desc gate: recomputes on field change (reactive-ready)', () => {
|
||||||
|
assert.equal(canCreateDesc({ ...full, figureTermIds: [] }), false);
|
||||||
|
assert.equal(canCreateDesc({ ...full, figureTermIds: [1] }), true);
|
||||||
|
assert.equal(canCreateDesc({ ...full, moistureTermId: '' }), false);
|
||||||
|
assert.equal(canCreateDesc({ ...full, moistureTermId: 326 }), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Create-desc gate (Call 2): disable Create desc unless these are present.
|
||||||
|
* Does NOT require description (that is what Create desc writes).
|
||||||
|
* Price included for parity with publish-ready.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CreateDescInput = {
|
||||||
|
contentLlmEnabled: boolean;
|
||||||
|
title: string;
|
||||||
|
shortDescription: string;
|
||||||
|
speciesId: number | null | undefined;
|
||||||
|
woodCategoryIds: number[];
|
||||||
|
primaryCatId: number | '' | null | undefined;
|
||||||
|
edgeTermId: number | string | '' | null | undefined;
|
||||||
|
figureTermIds: number[];
|
||||||
|
gradeTermId: number | string | '' | null | undefined;
|
||||||
|
moistureTermId: number | string | '' | null | undefined;
|
||||||
|
price: string | number | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Human labels shown under Create desc when the gate fails. */
|
||||||
|
export const CREATE_DESC_LABELS: Record<string, string> = {
|
||||||
|
content_llm: 'content LLM on',
|
||||||
|
title: 'title',
|
||||||
|
short_description: 'short description',
|
||||||
|
species: 'species',
|
||||||
|
wood_categories: '≥1 wood category',
|
||||||
|
primary: 'primary wood category',
|
||||||
|
edge: 'edge type',
|
||||||
|
figure: '≥1 figure',
|
||||||
|
grade: 'grade',
|
||||||
|
moisture: 'moisture',
|
||||||
|
price: 'price',
|
||||||
|
};
|
||||||
|
|
||||||
|
function presentId(v: number | string | '' | null | undefined): boolean {
|
||||||
|
if (v === null || v === undefined || v === '') return false;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v);
|
||||||
|
return String(v).trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentPrice(v: string | number | null | undefined): boolean {
|
||||||
|
if (v === null || v === undefined || v === '') return false;
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
return Number.isFinite(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return missing field keys (stable order). Empty ⇒ Create desc enabled. */
|
||||||
|
export function missingCreateDesc(input: CreateDescInput): string[] {
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!input.contentLlmEnabled) missing.push('content_llm');
|
||||||
|
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.woodCategoryIds?.length) missing.push('wood_categories');
|
||||||
|
if (!presentId(input.primaryCatId)) missing.push('primary');
|
||||||
|
if (!presentId(input.edgeTermId)) missing.push('edge');
|
||||||
|
if (!input.figureTermIds?.length) missing.push('figure');
|
||||||
|
if (!presentId(input.gradeTermId)) missing.push('grade');
|
||||||
|
if (!presentId(input.moistureTermId)) missing.push('moisture');
|
||||||
|
if (!presentPrice(input.price)) missing.push('price');
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCreateDesc(input: CreateDescInput): boolean {
|
||||||
|
return missingCreateDesc(input).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function missingCreateDescLabels(input: CreateDescInput): string[] {
|
||||||
|
return missingCreateDesc(input).map((k) => CREATE_DESC_LABELS[k] ?? k);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { applyMoistureDefault, resolveKilnDriedId } from './moistureDefault';
|
||||||
|
|
||||||
|
const terms = [
|
||||||
|
{ id: 1, name: 'Air dried', slug: 'ad' },
|
||||||
|
{ id: 326, name: 'Kiln dried', slug: 'kd' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('moisture default: slug kd wins; name match fallback; scoped empty stays empty', () => {
|
||||||
|
assert.equal(resolveKilnDriedId(terms), 326);
|
||||||
|
assert.equal(resolveKilnDriedId([{ id: 9, name: 'Kiln dried', slug: 'kiln-dried' }]), 9);
|
||||||
|
assert.equal(resolveKilnDriedId([{ id: 1, name: 'Air dried', slug: 'ad' }]), null);
|
||||||
|
assert.equal(applyMoistureDefault('', terms), 326);
|
||||||
|
assert.equal(applyMoistureDefault(null, terms), 326);
|
||||||
|
assert.equal(applyMoistureDefault(1, terms), 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Moisture default: Kiln dried (slug `kd` / name match). METADATA-15.
|
||||||
|
* Resolve by synced taxonomy — never a hardcoded Woo term id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MoistureTerm = { id: number; name: string; slug: string };
|
||||||
|
|
||||||
|
export function resolveKilnDriedId(terms: MoistureTerm[]): number | null {
|
||||||
|
const bySlug = terms.find((t) => t.slug.toLowerCase() === 'kd');
|
||||||
|
if (bySlug) return bySlug.id;
|
||||||
|
const byName = terms.find((t) => {
|
||||||
|
const n = t.name.trim().toLowerCase();
|
||||||
|
return n === 'kiln dried' || n === 'kiln-dried';
|
||||||
|
});
|
||||||
|
return byName ? byName.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If moisture is empty, return kiln-dried id; else keep current. */
|
||||||
|
export function applyMoistureDefault(
|
||||||
|
current: number | '' | null | undefined,
|
||||||
|
terms: MoistureTerm[],
|
||||||
|
): number | '' {
|
||||||
|
if (current !== null && current !== undefined && current !== '') {
|
||||||
|
return typeof current === 'number' ? current : Number(current) || '';
|
||||||
|
}
|
||||||
|
const kd = resolveKilnDriedId(terms);
|
||||||
|
return kd == null ? '' : kd;
|
||||||
|
}
|
||||||
@@ -32,3 +32,15 @@ test('Tag family scope uses hyphenated slug prefix', () => {
|
|||||||
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
|
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
|
||||||
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
|
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Review UI: View prompt + Create desc gate wiring', () => {
|
||||||
|
assert.match(reviewSrc, /data-testid="view-prompt"/);
|
||||||
|
assert.match(reviewSrc, /getCall2Prompt/);
|
||||||
|
assert.match(reviewSrc, /createDescMissing/);
|
||||||
|
assert.match(reviewSrc, /missingCreateDescLabels/);
|
||||||
|
assert.match(reviewSrc, /orderWoodCatGroups/);
|
||||||
|
assert.match(reviewSrc, /applyMoistureDefault/);
|
||||||
|
assert.match(reviewSrc, /onCommit=\{commitReview\}/);
|
||||||
|
// Description is not part of the Create-desc gate label list
|
||||||
|
assert.equal(reviewSrc.includes("Create desc needs: description"), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { orderWoodCatGroups, PIN_LEAF_SLUG, PIN_PARENT_SLUG } from './woodCatOrder';
|
||||||
|
|
||||||
|
test('wood cat UI: snl parent first; liveedge leaf first under its parent', () => {
|
||||||
|
const groups = orderWoodCatGroups([
|
||||||
|
{
|
||||||
|
parent: { id: 2, name: 'Turning', slug: 'turn' },
|
||||||
|
leaves: [
|
||||||
|
{ id: 20, name: 'Bowl blanks', slug: 'bowl' },
|
||||||
|
{ id: 21, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: { id: 1, name: 'Slabs & Lumber', slug: PIN_PARENT_SLUG },
|
||||||
|
leaves: [
|
||||||
|
{ id: 11, name: 'Dimensional', slug: 'dim' },
|
||||||
|
{ id: 10, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(groups[0].parent.slug, PIN_PARENT_SLUG);
|
||||||
|
assert.equal(groups[0].leaves[0].slug, PIN_LEAF_SLUG);
|
||||||
|
assert.equal(groups[0].leaves[1].slug, 'dim');
|
||||||
|
assert.equal(groups[1].parent.slug, 'turn');
|
||||||
|
// liveedge also pinned first under Turning when present
|
||||||
|
assert.equal(groups[1].leaves[0].slug, PIN_LEAF_SLUG);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* UI-only wood-category display order on Review.
|
||||||
|
* Pin parent Slabs & Lumber (`snl`) first; pin leaf Live edge wood (`liveedge`)
|
||||||
|
* first under its parent. Never changes assignment ids — sort only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PIN_PARENT_SLUG = 'snl';
|
||||||
|
export const PIN_LEAF_SLUG = 'liveedge';
|
||||||
|
|
||||||
|
export type OrderedLeaf = { id: number; name: string; slug?: string; ai?: boolean };
|
||||||
|
export type OrderedGroup = {
|
||||||
|
parent: { id: number; name: string; slug?: string };
|
||||||
|
leaves: OrderedLeaf[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function pinFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
|
||||||
|
const pinned: T[] = [];
|
||||||
|
const rest: T[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
if (isPinned(it)) pinned.push(it);
|
||||||
|
else rest.push(it);
|
||||||
|
}
|
||||||
|
return [...pinned, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort groups + leaves for the wood-category MultiSelect. */
|
||||||
|
export function orderWoodCatGroups<T extends OrderedGroup>(groups: T[]): T[] {
|
||||||
|
const withLeaves = groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
leaves: pinFirst(g.leaves, (l) => (l.slug ?? '') === PIN_LEAF_SLUG),
|
||||||
|
})) as T[];
|
||||||
|
return pinFirst(withLeaves, (g) => (g.parent.slug ?? '') === PIN_PARENT_SLUG);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user