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:
@@ -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-*)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 & short description built and saved.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="lock-hint">
|
||||
{canLock
|
||||
? 'Review the fields above, then lock the metadata to build the title & short description.'
|
||||
: 'Select a species and a wood category, then lock to build the title & 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; }
|
||||
|
||||
Reference in New Issue
Block a user