merge issue-89-metadata-lock → main (Create-string model, #89/#90)

Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

Taken wholesale from the branch: photo strip, wood primary/secondary,
server-side thickness band (hidden from UI, passed to Woo on publish),
and fixes #80/#82/#83/#84.

- backend: remove metadata_locked from schema.sql, db sentinels,
  slabs.py client fields/exposure, review.py lock action; review
  always computes title/short_desc
- frontend: slabs.ts + Review.svelte drop the lock model, add
  Create-string workflow + wood primary + photo strip
- e2e: m3-create + review-photo-strip rewritten for the Create-string
  contract; thickness-band test asserts absence (per #90)
- deploy: recreate LAN binds (override-m3.yml was absent); verify
  metadata_locked gone, review builds title, PUT persists

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
This commit is contained in:
Ty
2026-09-21 11:54:48 -07:00
parent e5acef4c9b
commit 1a50fad0a5
11 changed files with 234 additions and 314 deletions
-1
View File
@@ -55,7 +55,6 @@ _SCHEMA_SENTINELS = [
("settings", "value"),
("slabs", "deleted_at"),
("slabs", "server_rev"),
("slabs", "metadata_locked"),
("slab_photos", "original_blob"),
("sync_log", "payload_hash"),
]
-3
View File
@@ -35,9 +35,6 @@ CREATE TABLE IF NOT EXISTS slabs (
species_confidence REAL,
figure_confidence REAL,
grade_confidence REAL,
-- #89: explicit owner lock of the confirmed taxonomy set. Deterministic
-- title/short_description are built and persisted only once this is 1.
metadata_locked INTEGER CHECK(metadata_locked IN (0,1) OR metadata_locked IS NULL),
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
calibration_photo_id TEXT,
client_rev INTEGER NOT NULL DEFAULT 1,
+15 -68
View File
@@ -155,65 +155,15 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row)
locked = bool(slab.get("metadata_locked"))
# --- Explicit lock (#89): owner confirms the taxonomy set; server then
# builds AND PERSISTS the deterministic title + short_description. Until
# this action runs, the prose fields stay unset (no "ready" copy from an
# unlocked AI draft). Owner prose (title/short_description) always wins.
if payload.get("action") == "lock":
if not slab.get("species_id"):
return JSONResponse(status_code=422, content=_error(
"not_ready", "Select a species before locking the metadata"))
primary_id = payload.get("primary_wood_category_id")
wood_cats = slab.get("wood_category_ids") or []
if not primary_id:
primary_id = wood_cats[0] if wood_cats else None
if not primary_id:
return JSONResponse(status_code=422, content=_error(
"not_ready", "Select a wood category before locking the metadata"))
cats, terms = _taxonomy_lookup(conn)
primary_leaf = cats.get(primary_id, {})
if not primary_leaf:
return JSONResponse(status_code=422, content=_error(
"not_ready", "Wood category is no longer in the current taxonomy"))
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
owner_title = (payload.get("title") or slab.get("title") or "").strip()
owner_short = (payload.get("short_description") or slab.get("short_description") or "").strip()
title = owner_title or build_title(slab, figure, species_name, primary_leaf)
short_desc = owner_short or build_short_description(slab, figure, species_name, primary_leaf)
now = _now()
cur = conn.execute(
"UPDATE slabs SET title=?, short_description=?, metadata_locked=1, server_rev=server_rev+1, updated_at=? WHERE id=?",
(title, short_desc, now, slab_id),
)
conn.commit()
if cur.rowcount == 0:
return JSONResponse(status_code=409, content=_error("not_found", "Slab not found"))
slab["title"] = title
slab["short_description"] = short_desc
slab["metadata_locked"] = 1
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price})
return {
"ready": ready, "missing": missing, "title": title,
"short_description": short_desc, "price": price,
"price_suggestion": price_suggestion(conn, slab),
"primary_wood_category_id": primary_id, "locked": True,
}
# --- Regular review (no lock): report ready/missing + suggested prose.
# #80: persist owner overrides (price/sku/title/short_description) from the
# payload so the review endpoint is self-contained — the client doesn't
# need a separate PUT for each override. #89: deterministic
# title/short_description are NOT set until an explicit lock; report the
# stored values (which stay empty until locked) so the UI shows them as
# pending, and never as "ready" from an unlocked AI draft.
# --- Deterministic review: report ready/missing + the deterministic
# title/short_description built from the current locked metadata. The client's
# "Create title" / "Create short desc" buttons call this endpoint and write the
# returned strings into the fields (item 1: no lock action, no metadata_locked).
# #80: persist owner overrides (price/sku) from the payload so the review
# endpoint is self-contained. title/short_description are NOT overridden here —
# they are produced by the deterministic build below, not persisted from the
# payload (so the review POST can't clobber the owner's Create workflow).
cats, terms = _taxonomy_lookup(conn)
primary_id = payload.get("primary_wood_category_id")
wood_cats = slab.get("wood_category_ids") or []
@@ -224,9 +174,9 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
# #80: persist owner overrides from the payload.
# #80: persist owner overrides from the payload (price/sku only).
overrides: dict[str, Any] = {}
for k in ("price", "sku", "title", "short_description"):
for k in ("price", "sku"):
if k in payload and payload[k] is not None:
overrides[k] = payload[k]
if overrides:
@@ -238,20 +188,17 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
conn.commit()
slab.update(overrides)
if not locked:
title = slab.get("title") or None
short_desc = slab.get("short_description") or None
else:
# Locked: re-derive only if the owner has not overridden the prose.
title = slab.get("title") or build_title(slab, figure, species_name, primary_leaf)
short_desc = slab.get("short_description") or build_short_description(slab, figure, species_name, primary_leaf)
# Deterministic title / short_description, re-derived from the current
# metadata on every review (the Create button's source of truth).
title = build_title(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("title") or None
short_desc = build_short_description(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("short_description") or None
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
return {
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
"price": price, "price_suggestion": price_suggestion(conn, slab),
"primary_wood_category_id": primary_id, "locked": locked,
"primary_wood_category_id": primary_id,
}
+4 -5
View File
@@ -65,9 +65,10 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
return out
# #89: server-managed — only the explicit review lock action may set it. A plain
# PUT must not flip the lock (the deterministic copy is built inside that action).
CLIENT_FORBIDDEN_FIELDS = {"metadata_locked"}
# #89: server-managed fields that a plain client PUT must not set directly.
# (metadata_locked was dropped — the deterministic title/short-desc are built by
# the review endpoint and written back by the client's Create buttons.)
CLIENT_FORBIDDEN_FIELDS = set()
def _row_to_slab(row) -> Dict[str, Any]:
@@ -78,8 +79,6 @@ def _row_to_slab(row) -> Dict[str, Any]:
d[f] = json.loads(d[f])
except (ValueError, TypeError):
pass
# #89: expose the lock flag to the client (0/None → False, 1 → True).
d["metadata_locked"] = bool(d.get("metadata_locked"))
return d
+23 -79
View File
@@ -55,19 +55,14 @@ def test_title_live_edge_no_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: title/short_description are built AND persisted only after an explicit lock.
r = env[0].post("/api/v1/slabs/s1/review", json={"action": "lock"})
# #89: the review always returns the deterministic title/short_description
# built from the current metadata (the Create button's source of truth).
r = env[0].post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["locked"] is True
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in")
assert "18.4 bdft" in body["short_description"]
# GET shows the stored (persisted) copy — it matches the deterministic pattern.
got = env[0].get("/api/v1/slabs/s1").json()
assert got["title"] == "Figured Black Walnut Live Edge Slab"
assert got["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
assert got["metadata_locked"] is True
def test_ready_requires_full_set(env):
@@ -141,103 +136,54 @@ def test_knife_blank_carries_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: build happens on lock.
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
# #89: the deterministic build runs on every review.
r = client.post("/api/v1/slabs/s1/review", json={})
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
# --- #89: explicit lock gate for deterministic title/short_description ---
def test_review_before_lock_reports_no_title(env):
"""#89 AC: until lock, title/short_description are not set (no 'ready' copy
from an unlocked AI draft). A plain review reports the stored (empty) values."""
def test_review_always_reports_deterministic_title(env):
"""#89: the review always returns the deterministic title/short_description
built from the current metadata. The client's Create button writes these into
the fields; the server does not persist them from the review payload."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["locked"] is False
assert body["title"] is None
assert body["short_description"] is None
# title/short_description are in the missing set (not ready until locked).
assert "title" in body["missing"] and "short_description" in body["missing"]
# Nothing was persisted: GET still shows empty copy.
assert client.get("/api/v1/slabs/s1").json()["title"] is None
def test_lock_persists_title_and_get_matches(env):
"""#89 AC: after lock with species + primary wood category, title/short_desc
are populated deterministically, stored, and GET reflects them."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 200
body = r.json()
assert body["locked"] is True
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
# The review does NOT persist title/short_description from the payload.
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] == body["title"]
assert got["short_description"] == body["short_description"]
assert got["metadata_locked"] is True
assert got["title"] is None
assert got["short_description"] is None
def test_lock_requires_species(env):
def test_review_no_species_reports_stored_title(env):
"""With no species, there is no deterministic pattern; the review reports the
stored title (None here) and lists title as missing."""
client, conn = env
# No species_id -> lock is rejected with a plain message.
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 422
assert r.json()["error"]["code"] == "not_ready"
def test_lock_requires_wood_category(env):
client, conn = env
# species present but no wood category -> rejected.
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 422
assert r.json()["error"]["code"] == "not_ready"
def test_lock_owner_prose_wins(env):
# METADATA-50: typed owner title/short_description win over the pattern.
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={
"action": "lock",
"title": "My Handcrafted Walnut Slab",
"short_description": "Owner-authored short description.",
})
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] == "My Handcrafted Walnut Slab"
assert body["short_description"] == "Owner-authored short description."
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] == "My Handcrafted Walnut Slab"
assert body["title"] is None
assert "title" in body["missing"]
def test_review_persists_owner_overrides(env):
"""#80: regular review (no lock) persists price/sku/title/short_description
overrides from the payload to the DB, so the client doesn't need a separate
PUT for each override."""
"""#80: regular review persists price/sku overrides from the payload to the DB,
so the client doesn't need a separate PUT for each. (title/short_description
are NOT persisted here — they come from the deterministic build, not the payload.)"""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# Review with owner overrides for price + sku + title + short_description.
# Review with owner overrides for price + sku.
r = client.post("/api/v1/slabs/s1/review", json={
"price": 250.0,
"sku": "SLAB-99",
"title": "Owner Title",
"short_description": "Owner Short Desc",
})
assert r.status_code == 200
body = r.json()
@@ -246,7 +192,5 @@ def test_review_persists_owner_overrides(env):
got = client.get("/api/v1/slabs/s1").json()
assert got["price"] == 250.0
assert got["sku"] == "SLAB-99"
assert got["title"] == "Owner Title"
assert got["short_description"] == "Owner Short Desc"
# server_rev was incremented (the UPDATE fired).
assert got["server_rev"] == 2
+25
View File
@@ -51,3 +51,28 @@ Do not put a host name in the product spec.
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement".
- Sample data unchanged (still 25 slabs post-recreate).
- All gates (unit, build, requirements, coverage, m3-create e2e) pass.
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
deterministic title/short_description are now built by the review endpoint on every call; the
owner's **Create title / Create short desc** buttons fire the build and write the result
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
`woo_reachable:true`.
- Sample data preserved: **yes** — 34 slabs before/after.
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
not exercised this round.
+6 -2
View File
@@ -16,7 +16,9 @@ services:
build:
context: ..
dockerfile: frontend/Dockerfile
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"]
ports:
- "127.0.0.1:${FRONTEND_PORT:-18080}:80"
- "0.0.0.0:10881:80"
depends_on: [fastapi]
restart: unless-stopped
@@ -34,7 +36,9 @@ services:
volumes:
- slab_db:/data
- slab_images:/images
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"]
ports:
- "127.0.0.1:${FASTAPI_PORT:-18081}:8000"
- "0.0.0.0:10882:8000"
mem_limit: 2g
restart: unless-stopped
-13
View File
@@ -39,8 +39,6 @@ export interface Slab {
short_description: string | null;
description: string | null;
content_source: string | null;
// #89: set true once the owner has locked the taxonomy (deterministic copy built).
metadata_locked: boolean;
species_confidence: number | null;
figure_confidence: number | null;
grade_confidence: number | null;
@@ -271,8 +269,6 @@ export interface ReviewResult {
price: number | null;
price_suggestion: number | null;
primary_wood_category_id: number | null;
// #89: whether the metadata is locked (deterministic copy built/persisted).
locked: boolean;
}
export interface ReviewInput {
@@ -284,8 +280,6 @@ export interface ReviewInput {
description?: string;
price_source?: string;
content_source?: string;
// #89: 'lock' triggers the explicit metadata-lock + deterministic copy build.
action?: 'lock';
}
export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
@@ -296,13 +290,6 @@ export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<R
}).then((r) => parse<ReviewResult>(r));
}
// #89: explicit lock of the confirmed taxonomy. The server builds and persists
// the deterministic title/short_description from the locked set (owner prose
// already on the slab wins). Distinct from reviewSlab() which only reports.
export function lockSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
return reviewSlab(slabId, { ...payload, action: 'lock' });
}
// ---------------------------------------------------------------------------
// Publish (PUBLISH-*)
// ---------------------------------------------------------------------------
+126 -91
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
getSlab, updateSlab, reviewSlab, lockSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
listPhotos, persistLineup, photoUrl, ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
} from '../../api/slabs';
@@ -109,14 +109,20 @@
ready: boolean; missing: string[]; title: string | null;
short_description: string | null; price: number | null;
price_suggestion: number | null; primary_wood_category_id: number | null;
locked: boolean;
}
let review: ReviewOut | null = null;
let reviewBusy = false;
let reviewError = '';
// #89: explicit lock of the confirmed taxonomy set.
let lockBusy = false;
let lockError = '';
// #89: explicit "Create" actions. `createBusy` covers the deterministic
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
// category). `canCreateDesc` gates the AI long-description on title+short-desc
// being populated AND the content LLM being enabled.
let createBusy = false;
let createError = '';
$: canCreateTitle =
speciesId !== '' && woodCatIds.length > 0;
$: canCreateDesc =
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
// Monotonic token: each commit captures the current value and ignores its
// 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
@@ -351,12 +357,29 @@
// owner sees here is the measured value in the summary above.
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
// #89: the deterministic title/short_description are written here — the server's
// review returns the build from the current metadata, and if the field is empty
// we fold the deterministic value into THIS same PUT (no extra request, no rev
// race). Owner-edited text is never clobbered (only an empty field is filled).
async function commitReview(): Promise<void> {
if (!slab) return;
const myToken = ++commitToken;
reviewBusy = true;
reviewError = '';
try {
// Run the deterministic review FIRST (reads the current metadata) to get the
// title/short_description build before we persist.
const probe = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
// so this PUT persists it (the Create buttons do the same, on demand).
if (probe.title && title === '') title = probe.title;
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
const patch = {
species_id: speciesId === '' ? null : Number(speciesId),
wood_category_ids: woodCatIds,
@@ -368,7 +391,6 @@
feat_tag_ids: featTagIds,
title: title || null,
short_description: shortDescription || null,
description: description || null,
content_source: contentSource || null,
price: price === '' ? null : Number(price),
price_source: priceSource,
@@ -378,21 +400,14 @@
const fresh = await updateSlab(slabId, patch);
if (myToken !== commitToken) return; // superseded; don't clobber the latest
slab = fresh;
if (primaryCatId !== '') {
const res = await reviewSlab(slabId, {
primary_wood_category_id: Number(primaryCatId),
title: title || undefined,
short_description: shortDescription || undefined,
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
review = res;
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
} else {
review = await reviewSlab(slabId);
if (myToken !== commitToken) return;
}
// Re-run the review against the just-persisted row (authoritative ready/missing).
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
} catch (e) {
if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
@@ -402,46 +417,70 @@
}
}
// #89: explicit lock — persist the current (already-committed) taxonomy, then
// have the server build + store the deterministic title/short_description from
// that locked set. Owner prose in the title/short-desc fields wins (METADATA-50).
// A commitReview() first ensures the reviewed Call 1 results are on the slab
// before the lock reads them.
async function lockMetadata(): Promise<void> {
if (!slab || lockBusy) return;
lockBusy = true;
lockError = '';
// Persist the current taxonomy first (inner commit captures commitToken=N),
// THEN bump our token to N+1 so a later concurrent commit is ignored,
// while the inner commit's PUT is NOT invalidated (it saw token==N).
await commitReview();
const myToken = ++commitToken;
// #89: explicit "Create title" / "Create short desc" — fire the deterministic
// build from the current metadata and write the result into the fields,
// overwriting whatever was there (empty, owner-typed, or previously generated).
// That is the whole contract: the button fires the build. commitReview()
// auto-fills only empty fields; this is the explicit "force overwrite" action.
async function createTitle(): Promise<void> {
if (!slab || createBusy) return;
createBusy = true;
createError = '';
try {
const res = await lockSlab(slabId, {
// 1. Run the deterministic review to get title / short_description.
const res = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
title: title || undefined,
short_description: shortDescription || undefined,
});
if (myToken !== commitToken) return;
review = res;
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
// 2. Write the deterministic values into the bound fields — always overwrite.
if (res.title) title = res.title;
if (res.short_description) shortDescription = res.short_description;
slab = await getSlab(slabId);
if (myToken !== commitToken) return;
// 3. Persist the generated prose (single PUT, fresh client_rev).
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
const prosePatch = {
title: title || null,
short_description: shortDescription || null,
client_rev: (slab as Slab).server_rev,
};
const fresh = await updateSlab(slabId, prosePatch);
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
}
} catch (e) {
if (myToken !== commitToken) return;
lockError = e instanceof ApiError ? e.message : 'Could not lock the metadata. Try again.';
createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
} finally {
if (myToken === commitToken) lockBusy = false;
createBusy = false;
}
}
// #89: the lock is available once the pattern inputs exist (species + a wood
// category). Prefer the live editor model so the button enables before the
// next commit finishes writing the slab row.
let canLock = false;
$: canLock = speciesId !== '' && woodCatIds.length > 0 && primaryCatId !== '';
// #89: the description textarea commits on blur. commitReview() no longer sends
// description (see #89), so this persists the owner-typed description explicitly.
async function commitDescription(): Promise<void> {
if (!slab) return;
const myToken = ++commitToken;
reviewBusy = true;
reviewError = '';
try {
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
} catch (e) {
if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
review = null;
} finally {
if (myToken === commitToken) reviewBusy = false;
}
}
async function runInference() {
if (!slab) return;
@@ -475,14 +514,22 @@
}
async function generateContent() {
if (!slab) return;
if (!slab || !canCreateDesc) return;
genBusy = true;
genError = '';
try {
const r = await inferContent(slabId);
description = r.description;
contentSource = 'llm';
await commitReview();
// commitReview() no longer sends description (see #89), so persist it explicitly.
const myToken = ++commitToken;
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
} catch (e) {
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
} finally {
@@ -736,53 +783,39 @@
</select>
</div>
<!-- #89: explicit lock — the unmistakable "Call 1 → review → LOCK → copy" step.
Until this runs, title/short_description stay empty (no "ready" copy from
an unlocked AI draft). Owner prose already typed into the fields below wins. -->
<div class="lockbox" data-testid="lock-section">
{#if review?.locked}
<p class="locked-note" data-testid="locked-note">
Metadata locked — title &amp; short description built and saved.
</p>
{:else}
<p class="lock-hint">
{canLock
? 'Review the fields above, then lock the metadata to build the title &amp; short description.'
: 'Select a species and a wood category, then lock to build the title &amp; short description.'}
</p>
<button
type="button"
class="lock-btn"
data-testid="lock-metadata"
disabled={!canLock || lockBusy}
on:click={lockMetadata}
>
{lockBusy ? 'Locking…' : 'Lock metadata & build title'}
</button>
{#if lockError}<p class="nudge" role="alert">{lockError}</p>{/if}
{/if}
</div>
<!-- Title / Description -->
<!-- Title / Short description + explicit "Create" build buttons (#89) -->
<div class="field">
<label class="lbl" for="title">Title</label>
<input id="title" class="inp" data-testid="title" bind:value={title} />
<div class="create-row">
<input id="title" class="inp" data-testid="title" bind:value={title} />
<button type="button" class="create-btn" data-testid="create-title"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create title'}
</button>
</div>
</div>
<div class="field">
<label class="lbl" for="short-desc">Short description</label>
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
<div class="create-row">
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
<button type="button" class="create-btn" data-testid="create-short-desc"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create short desc'}
</button>
</div>
</div>
<div class="field">
<label class="lbl" for="desc">Description</label>
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea>
{#if canGenerateContent}
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
{#if canCreateDesc}
<div class="gen">
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Generate text'}
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Create desc'}
</button>
</div>
{/if}
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
</div>
<!-- Price / SKU (SKU last) -->
@@ -856,11 +889,13 @@
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a3a2a; border-radius: 3px; background: #141a14; }
.lockbox .lock-hint { color: #9fb89f; font-size: .78rem; margin: 0 0 8px; }
.lock-btn { box-sizing: border-box; width: 100%; padding: 11px; border: 1px solid #2a3a2a; border-radius: 3px; background: #1e3a28; color: #9fd3a8; font-weight: 700; font-size: .9rem; }
.lock-btn:disabled { background: #2a2a2a; color: #777; border-color: #2a2a2a; }
.locked-note { color: #7fd37f; font-size: .82rem; font-weight: 600; margin: 0; }
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
.create-row { display: flex; gap: 8px; align-items: stretch; }
.create-row .inp { flex: 1; }
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
.create-btn:hover { background: #242424; }
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
+30 -47
View File
@@ -126,7 +126,6 @@ function baseSlab(): Record<string, unknown> {
short_description: null,
description: null,
content_source: 'manual',
metadata_locked: false,
species_confidence: null,
figure_confidence: null,
grade_confidence: null,
@@ -169,56 +168,41 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
'title', 'short_description', 'description', 'content_source', 'price', 'price_source',
'metadata_locked',
]) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
}
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
};
// #89: deterministic copy is built only on an explicit lock. Before lock the
// review endpoint reports the stored (empty) title/short_description.
const buildCopy = () => {
const speciesName = TAXONOMY.categories.find((c) => c.id === slab.species_id)?.name ?? 'Slab';
const fig = Array.isArray(slab.figure_term_ids) && slab.figure_term_ids.length
? 'Figured'
: '';
return `${fig} ${speciesName} Live Edge Slab`;
};
const reviewResult = (payload: Record<string, unknown>) => {
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
// Explicit lock (#89): persist the deterministic copy + lock flag.
if (payload.action === 'lock') {
const title = (typeof payload.title === 'string' && payload.title)
? (payload.title as string)
: buildCopy();
(slab as Record<string, unknown>).title = title;
(slab as Record<string, unknown>).short_description = `${title}, 4ft 0in x 11in x 2in, 12.5 bdft`;
(slab as Record<string, unknown>).metadata_locked = true;
}
// #89: the review ALWAYS returns the deterministic title/short_description built
// from the current metadata (the Create button's source of truth). The server
// does NOT persist title/short_description from the review payload.
const hasSpecies = !!slab.species_id;
const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
const deterministicTitle =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
const deterministicShort =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
const locked = (slab.metadata_locked as boolean) === true;
const missing: string[] = [];
if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species');
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
if (!slab.edge_type_term_id) missing.push('edge type');
if (!slab.title) missing.push('title');
if (!slab.short_description) missing.push('short description');
if (slab.price == null) missing.push('price');
return {
ready: missing.length === 0,
missing,
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
title: deterministicTitle,
short_description: deterministicShort,
price: (slab.price as number) ?? null,
price_suggestion: 120,
primary_wood_category_id: primary,
locked,
};
};
@@ -332,12 +316,11 @@ async function completeForReady(page: Page) {
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
// #89: lock the confirmed metadata so the server builds + persists the
// deterministic title/short_description. Do this BEFORE the SKU blur so a
// subsequent plain review can't clobber the locked-note banner.
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
await page.getByTestId('lock-metadata').click();
// Wait for the lock to complete (title field populated = lock persisted copy).
// #89: fire the deterministic build so the title/short_description populate.
// Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('create-title').click();
// Wait for the build to complete (title field populated = deterministic copy).
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
// SKU commits on blur — press Tab to move focus off it (deterministic, single
@@ -347,15 +330,14 @@ async function completeForReady(page: Page) {
}
test.describe('M3 Review→Publish flow (#77)', () => {
test('golden path: capture→review→lock→ready→publish→done (#77, #89)', async ({ page }) => {
test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
const { slab } = await mockApi(page);
await reachReview(page);
// Not ready until fields are set; copy is empty until an explicit lock.
// Not ready until fields are set; copy is empty until the Create build fires.
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
await expect(page.getByTestId('title')).toHaveValue('');
await completeForReady(page);
// #89: the lock persisted the deterministic copy on the slab row.
expect(slab.metadata_locked).toBe(true);
// #89: the Create build populated the deterministic copy on the slab row.
expect(slab.title).toBeTruthy();
expect(slab.short_description).toBeTruthy();
// Continue to Publish is enabled and hands off.
@@ -396,17 +378,17 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
});
test('thickness band is read-only and shows the derived band (#90, METADATA-15)', async ({ page }) => {
test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
await mockApi(page);
await reachReview(page);
// The band is derived server-side, so Review displays it as data.
const band = page.getByTestId('thickness-band');
await expect(band).toBeVisible();
await expect(band).toContainText('2" - 3"'); // 2 in rounds into the live band
// There is no owner control for it anywhere on Review — not a select, not an input.
// METADATA-15 / QUALITY-95: the band is derived server-side from the measured
// thickness against the live synced Woo bands. It is deliberately ABSENT from
// this screen — no owner control, no read-only echo. The only thickness the
// owner sees is the measured value in the summary. (Commit 204bc5e dropped the
// read-only echo; this test asserts the omission.)
await expect(page.getByTestId('thickness-band')).toHaveCount(0);
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
expect(await band.evaluate((el) => el.tagName)).toBe('P');
});
/**
@@ -476,7 +458,7 @@ test.describe('M3 Review→Publish flow (#77)', () => {
band: box('[data-testid="thickness-band"]'),
primarySet: box('[data-testid="primary-wood-set"]'),
primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null,
lockBtn: box('[data-testid="lock-metadata"]'),
createBtn: box('[data-testid="create-title"]'),
continueBtn: box('[data-testid="continue-publish"]'),
};
});
@@ -495,7 +477,8 @@ test.describe('M3 Review→Publish flow (#77)', () => {
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
expect(metrics.count?.text).toContain('2 selected');
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
expect(metrics.band?.text).toContain('2" - 3"');
// Band is deliberately absent from Review (METADATA-15) — no element to measure.
expect(metrics.band).toBeNull();
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
}
console.log('RENDER REPORT\n' + report.join('\n'));
@@ -43,7 +43,7 @@ function baseSlab(): Record<string, unknown> {
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null,
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
title: null, short_description: null, description: null, content_source: 'manual',
metadata_locked: false, species_confidence: null, figure_confidence: null, grade_confidence: null,
species_confidence: null, figure_confidence: null, grade_confidence: null,
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
};
@@ -66,7 +66,7 @@ async function mockApi(page: Page) {
const merge = (payload: Record<string, unknown>) => {
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false;
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'metadata_locked', 'edge_type_term_id']) {
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'edge_type_term_id']) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
}
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
@@ -91,7 +91,7 @@ async function mockApi(page: Page) {
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
price: (slab.price as number) ?? null, price_suggestion: 120,
primary_wood_category_id: null, locked: slab.metadata_locked === true,
primary_wood_category_id: null,
};
};
@@ -181,8 +181,8 @@ async function completeForReady(page: Page) {
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
await page.getByTestId('lock-metadata').click();
await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('create-title').click();
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
await page.getByTestId('sku').press('Tab');