fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review

The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.

- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
  stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
  ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
  inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
  ready→publish→done); mock respects optimistic concurrency (client_rev) so
  stale PUTs 409 like the real backend
This commit is contained in:
Ty
2026-09-20 23:36:31 -07:00
parent 41401a7266
commit 693719e85f
8 changed files with 329 additions and 170 deletions
+85 -31
View File
@@ -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