diff --git a/backend/db/__init__.py b/backend/db/__init__.py index 9b8f6c3..1d43c39 100644 --- a/backend/db/__init__.py +++ b/backend/db/__init__.py @@ -55,6 +55,7 @@ _SCHEMA_SENTINELS = [ ("settings", "value"), ("slabs", "deleted_at"), ("slabs", "server_rev"), + ("slabs", "metadata_locked"), ("slab_photos", "original_blob"), ("sync_log", "payload_hash"), ] diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 7a63c9e..1c4b6ea 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -35,6 +35,9 @@ 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, diff --git a/backend/review.py b/backend/review.py index 02da487..5277c32 100644 --- a/backend/review.py +++ b/backend/review.py @@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35. import json import re +from datetime import datetime, timezone from typing import Any from fastapi import APIRouter @@ -44,6 +45,10 @@ def _error(code: str, message: str, detail: Any = None) -> dict: return {"error": {"code": code, "message": message, "detail": detail}} +def _now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() + + def _split_length(length_in: float) -> tuple[int, float]: ft = int(length_in // 12) rem = round(length_in - ft * 12, 1) @@ -150,15 +155,63 @@ 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) - # Apply any review edits (primary category declaration + prose). - editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"} - for k in editable: - if k in payload and payload[k] is not None: - if k == "primary_wood_category_id": - continue # not stored as a column; derived - slab[k] = payload[k] + 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. + # #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. cats, terms = _taxonomy_lookup(conn) - # Primary wood category: declared, else first wood_category_ids, else first category. primary_id = payload.get("primary_wood_category_id") wood_cats = slab.get("wood_category_ids") or [] if not primary_id: @@ -168,28 +221,29 @@ 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 - # Deterministic copy (only if not already overridden by owner). - if not payload.get("title"): - if primary_leaf and species_name: - slab["title"] = build_title(slab, figure, species_name, primary_leaf) - if not payload.get("short_description"): - if primary_leaf and species_name: - slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf) - # Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks. + 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) + + 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, + } + + +def _resolve_price(conn, slab: dict, payload: dict) -> float | None: + """REVIEW-25: bdft * species rate as the suggestion; owner override sticks, + else the stored price, else the suggestion (None if no rate).""" suggested = price_suggestion(conn, slab) if payload.get("price") is not None: - price = payload["price"] - elif slab.get("price") not in (None, 0): - price = slab["price"] - else: - price = suggested - ready, missing = compute_ready({**slab, "price": price}) - return { - "ready": ready, - "missing": missing, - "title": slab.get("title"), - "short_description": slab.get("short_description"), - "price": price, - "price_suggestion": suggested, - "primary_wood_category_id": primary_id, - } + return payload["price"] + if slab.get("price") not in (None, 0): + return slab["price"] + return suggested diff --git a/backend/slabs.py b/backend/slabs.py index 4669a64..81d7d89 100644 --- a/backend/slabs.py +++ b/backend/slabs.py @@ -64,6 +64,11 @@ 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"} + + def _row_to_slab(row) -> Dict[str, Any]: d = dict(row) for f in JSON_ARRAY_FIELDS: @@ -72,6 +77,8 @@ 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 @@ -155,6 +162,11 @@ async def get_slab(id: str) -> Dict[str, Any]: async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]: if "client_rev" not in payload: return _err("validation", "client_rev required", status=400) + # #89: the metadata lock is server-managed via the explicit review lock + # action; a plain PUT may not flip it. + for field in CLIENT_FORBIDDEN_FIELDS: + if field in payload: + return _err("validation", f"{field} is set by the review lock action, not by update", status=400) conn = get_connection() current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() if current is None: diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index bc430e2..cc1f690 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -55,12 +55,19 @@ 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() - r = env[0].post("/api/v1/slabs/s1/review", json={}) + # #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"}) 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): @@ -134,5 +141,84 @@ 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() - r = client.post("/api/v1/slabs/s1/review", json={}) + # #89: build happens on lock. + r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"}) 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.""" + 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" + 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 + + +def test_lock_requires_species(env): + 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.", + }) + 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" diff --git a/frontend/src/api/slabs.ts b/frontend/src/api/slabs.ts index 66c275e..757da0d 100644 --- a/frontend/src/api/slabs.ts +++ b/frontend/src/api/slabs.ts @@ -39,6 +39,8 @@ 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; @@ -220,6 +222,8 @@ 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 { @@ -231,6 +235,8 @@ 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 { @@ -241,6 +247,13 @@ export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise parse(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 { + return reviewSlab(slabId, { ...payload, action: 'lock' }); +} + // --------------------------------------------------------------------------- // Publish (PUBLISH-*) // --------------------------------------------------------------------------- diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index f8f3cc2..4e38e3a 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -1,7 +1,7 @@