Files
SlabUploader/frontend/src/api/slabs.ts
T
grokbot_ty fca32396c0 fix(review): Create title no silent fail; gate enables content LLM
Create title used to look enabled while review echoed a pre-typed title when
species/primary were missing from taxonomy (no error). Persist wood cats before
the deterministic build, return title_built / title_build_missing from review,
and surface a Create-title nudge like Create desc.

WooSetupGate now turns on content_llm_enabled when inference URL+key are set
(Call 2 / Create desc). Call 1 stays out. Create-desc gate also requires
inference URL+key so empty Settings show a clear missing list.
2026-09-26 22:50:15 -07:00

566 lines
16 KiB
TypeScript

// Thin client for the SlabUploader app API. Svelte-agnostic.
//
// Mirrors the LIVE backend routes (backend/*.py), which are the source of truth:
// slabs.py POST/GET/PUT/DELETE /api/v1/slabs[/{id}]
// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed]
// review.py POST /api/v1/slabs/{id}/review
// publish.py POST /api/v1/slabs/{id}/publish
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
// Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage (required on those routes).
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
//
// Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain
// owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner).
import { withWooHeaders } from './wooDeviceCreds';
export interface Slab {
id: string;
sku: string;
status: string;
price: number | null;
price_source: string | null;
length_in: number;
thickness_in: number;
sqft: number;
bdft: number;
width_min_in: number;
width_max_in: number;
width_avg_in: number;
species_id: number | null;
wood_category_ids: number[];
edge_type_term_id: number | null;
figure_term_ids: number[];
grade_term_id: number | null;
thickness_term_id: number | null;
moisture_term_id: number | null;
fig_tag_ids: number[];
feat_tag_ids: number[];
title: string | null;
short_description: string | null;
description: string | null;
content_source: string | null;
species_confidence: number | null;
figure_confidence: number | null;
grade_confidence: number | null;
inference_status: string | null;
calibration_photo_id: string | null;
woo_product_id: number | null;
/** Create-time optimistic token; updates compare against server_rev, not this. */
client_rev: number;
/** Optimistic concurrency token; send as client_rev on PUT. */
server_rev: number;
created_at: string;
updated_at: string;
}
// Fields a client may send on create/update (subset of the row).
export type SlabInput = Partial<
Omit<
Slab,
| 'id'
| 'status'
| 'woo_product_id'
| 'server_rev'
| 'created_at'
| 'updated_at'
| 'inference_status'
| 'species_confidence'
| 'figure_confidence'
| 'grade_confidence'
| 'calibration_photo_id'
>
>;
export interface SlabCreateInput extends SlabInput {
id: string; // client-generated UUIDv4 (APP-20)
client_rev: number; // 0 on create (APP-20)
}
export interface ApiErrorBody {
code: string | null;
message: string;
detail?: Record<string, unknown> | null;
}
export class ApiError extends Error {
constructor(
public status: number,
public code: string | null,
message: string,
public detail?: Record<string, unknown> | null,
) {
super(message);
this.name = 'ApiError';
}
}
async function parse<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as T;
let body: unknown = null;
try {
body = await res.json();
} catch {
/* non-JSON body (e.g. 410 Gone) */
}
if (!res.ok) {
const e = (body as { error?: ApiErrorBody } | null)?.error;
throw new ApiError(
res.status,
e?.code ?? null,
e?.message ?? res.statusText,
e?.detail ?? null,
);
}
return body as T;
}
function jsonBody(v: unknown): BodyInit {
return JSON.stringify(v);
}
// ---------------------------------------------------------------------------
// Slabs
// ---------------------------------------------------------------------------
export function createSlab(input: SlabCreateInput): Promise<Slab> {
return fetch('/api/v1/slabs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(input),
}).then((r) => parse<Slab>(r));
}
export function listSlabs(): Promise<Slab[]> {
return fetch('/api/v1/slabs').then((r) => parse<Slab[]>(r));
}
export function getSlab(id: string): Promise<Slab> {
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
}
function putSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(patch),
}).then((r) => parse<Slab>(r));
}
/**
* PUT a slab patch. `client_rev` must equal the slab's current `server_rev`.
* On 409 revision_conflict (stale rev — e.g. review POST bumped server_rev, or
* a concurrent save), refetch once, adopt the latest server_rev, and retry.
* A second 409 surfaces a clear reload message.
*/
export async function updateSlab(
id: string,
patch: SlabInput & { client_rev: number },
): Promise<Slab> {
try {
return await putSlab(id, patch);
} catch (e) {
if (!(e instanceof ApiError) || e.status !== 409) throw e;
const latest = await getSlab(id);
try {
return await putSlab(id, { ...patch, client_rev: latest.server_rev });
} catch (e2) {
if (e2 instanceof ApiError && e2.status === 409) {
throw new ApiError(
409,
e2.code ?? 'revision_conflict',
'This slab changed elsewhere. Reload the page and try again.',
e2.detail,
);
}
throw e2;
}
}
}
export function deleteSlab(id: string): Promise<void> {
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) =>
parse<void>(r),
);
}
// ---------------------------------------------------------------------------
// Photos (lineup order = seq; calibration is is_calibration=1)
// ---------------------------------------------------------------------------
export interface PhotoRow {
id: string;
seq: number;
is_calibration: number;
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
role: 'calibration' | 'overlay' | 'batch';
width_px: number | null;
height_px: number | null;
has_original: number;
has_processed: number;
}
export interface UploadPhotoInput {
photoId: string; // client-generated UUIDv4
seq?: number;
isCalibration: boolean;
/** #90: lineup kind; defaults to batch. */
role?: 'calibration' | 'overlay' | 'batch';
original: Blob;
processed?: Blob | null;
}
export interface UploadPhotoResult {
id: string;
slab_id: string;
seq: number;
is_calibration: number;
role: 'calibration' | 'overlay' | 'batch';
original_retained: boolean;
processed_retained: boolean;
}
export function listPhotos(slabId: string): Promise<PhotoRow[]> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) =>
parse<PhotoRow[]>(r),
);
}
export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<UploadPhotoResult> {
const form = new FormData();
form.append('photo_id', input.photoId);
if (input.seq != null) form.append('seq', String(input.seq));
form.append('is_calibration', input.isCalibration ? '1' : '0');
form.append('role', input.role ?? 'batch');
const origName = input.original instanceof File ? input.original.name : 'original.png';
form.append('original', input.original, origName);
if (input.processed) form.append('processed', input.processed, 'processed.png');
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`, {
method: 'POST',
body: form,
}).then((r) => parse<UploadPhotoResult>(r));
}
// #90: drop a photo the owner removed in Review (keep/remove edit).
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
method: 'DELETE',
}).then((r) => parse<void>(r));
}
/**
* #90: persist an edited Review photo lineup. `kept` is the owner's final
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
* kept photo's blob is re-sent (original + processed) because the server
* stores only the BLOB — there is no separate re-seq endpoint.
*/
export interface LineupPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
original: Blob;
processed: Blob | null;
}
export async function persistLineup(
slabId: string,
kept: LineupPhoto[],
removed: string[],
): Promise<void> {
for (const id of removed) {
await deletePhoto(slabId, id);
}
for (let i = 0; i < kept.length; i++) {
const p = kept[i];
await uploadPhoto(slabId, {
photoId: p.id,
seq: i + 1,
isCalibration: p.isCalibration,
role: p.role,
original: p.original,
processed: p.processed,
});
}
}
export function photoUrl(
slabId: string,
photoId: string,
kind: 'original' | 'processed',
): string {
return `/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}/${kind}`;
}
// ---------------------------------------------------------------------------
// Review (REVIEW-*)
// ---------------------------------------------------------------------------
export interface ReviewResult {
ready: boolean;
missing: string[];
title: string | null;
short_description: string | null;
price: number | null;
price_suggestion: number | null;
primary_wood_category_id: number | null;
/** True when deterministic title/short_desc were built from taxonomy. */
title_built?: boolean;
title_build_missing?: string[];
}
export interface ReviewInput {
primary_wood_category_id?: number;
price?: number;
sku?: string;
title?: string;
short_description?: string;
description?: string;
price_source?: string;
content_source?: string;
}
export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(payload),
}).then((r) => parse<ReviewResult>(r));
}
// ---------------------------------------------------------------------------
// Publish (PUBLISH-*)
// ---------------------------------------------------------------------------
export interface PublishResult {
ok: true;
status: string;
woo_product_id: number;
draft: boolean;
}
export function publishSlab(slabId: string): Promise<PublishResult> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
method: 'POST',
headers: withWooHeaders(),
}).then((r) => parse<PublishResult>(r));
}
// ---------------------------------------------------------------------------
// Inference (METADATA-*)
// ---------------------------------------------------------------------------
export interface Call1Suggestions {
species_id?: number | null;
species_confidence?: number | null;
wood_category_ids?: number[];
edge_type_term_id?: number | null;
figure_term_ids?: number[];
figure_confidence?: number | null;
feature_tag_ids?: number[];
feature_tag_confidence?: number | null;
}
export interface Call1Result {
ok: boolean;
suggestions: Call1Suggestions;
}
export interface Call2Result {
ok: boolean;
description: string;
/** Present when Call 2 returned a parsed short_description. */
short_description?: string;
}
/** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */
export function inferTaxon(slabId: string): Promise<Call1Result> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
method: 'POST',
}).then((r) => parse<Call1Result>(r));
}
export function inferContent(slabId: string): Promise<Call2Result> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, {
method: 'POST',
}).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)
// ---------------------------------------------------------------------------
export interface TaxCategory {
id: number;
name: string;
slug: string;
parent_id: number | null;
woo_id: number | null;
}
export interface TaxTerm {
id: number;
name: string;
slug: string;
}
export interface TaxAttribute {
id: number;
name: string;
slug: string;
terms: TaxTerm[];
}
export interface TaxTag {
id: number;
name: string;
slug: string;
}
export interface Taxonomy {
categories: TaxCategory[];
attributes: TaxAttribute[];
tags: TaxTag[];
taxonomy_anchor: string | null;
taxonomy_last_sync_at: string | null;
}
export function getTaxonomy(): Promise<Taxonomy> {
return fetch('/api/v1/admin/taxonomy').then((r) => parse<Taxonomy>(r));
}
export function syncTaxonomy(): Promise<{
taxonomy_anchor: string;
taxonomy_last_sync_at: string;
}> {
return fetch('/api/v1/admin/taxonomy/sync', {
method: 'POST',
headers: withWooHeaders(),
}).then((r) => parse(r));
}
// ---------------------------------------------------------------------------
// Pricing (SETTINGS-35)
// ---------------------------------------------------------------------------
export interface PriceRule {
id: number;
species_id: number;
price_per_bdft: number;
created_at: string;
updated_at: string;
}
export function listPricing(): Promise<PriceRule[]> {
return fetch('/api/v1/admin/pricing').then((r) => parse<PriceRule[]>(r));
}
export function createPricingRule(rule: {
species_id: number;
price_per_bdft: number;
}): Promise<PriceRule> {
return fetch('/api/v1/admin/pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(rule),
}).then((r) => parse<PriceRule>(r));
}
export function updatePricingRule(
id: number,
patch: { price_per_bdft: number; species_id?: number },
): Promise<PriceRule> {
return fetch(`/api/v1/admin/pricing/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(patch),
}).then((r) => parse<PriceRule>(r));
}
export function deletePricingRule(id: number): Promise<void> {
return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse<void>(r));
}
// ---------------------------------------------------------------------------
// Settings (SETTINGS-*)
// ---------------------------------------------------------------------------
export interface SettingsView {
woo_base_url: string;
woo_wp_username: string | null;
woo_app_password: { configured: boolean } | string;
inference_base_url: { configured: boolean } | string;
inference_api_key: { configured: boolean } | string;
inference_model: string | null;
inference_enabled: boolean;
content_llm_enabled: boolean;
brand_voice: string | null;
geo_context: string | null;
woo_create_status: string;
taxonomy_last_sync_at: string | null;
taxonomy_anchor: string | null;
}
export function getSettings(): Promise<SettingsView> {
return fetch('/api/v1/settings').then((r) => parse<SettingsView>(r));
}
export function updateSettings(
patch: Partial<Record<keyof SettingsView, unknown>>,
): Promise<SettingsView> {
return fetch('/api/v1/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(patch),
}).then((r) => parse<SettingsView>(r));
}
export interface TestWooResult {
ok: boolean;
detail: string;
taxonomy_anchor: string | null;
taxonomy_last_sync_at: string | null;
}
export interface TestInferenceResult {
ok: boolean;
mode: 'stateful' | 'stateless' | 'fail';
vision_capable: boolean;
detail: string;
}
export function testWoo(): Promise<TestWooResult> {
return fetch('/api/v1/settings/test-woo', {
method: 'POST',
headers: withWooHeaders(),
}).then((r) => parse<TestWooResult>(r));
}
export function testInference(): Promise<TestInferenceResult> {
return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) =>
parse<TestInferenceResult>(r),
);
}