docs: lock revised capture pipeline and hybrid architecture

Rewrite PRD, tech spec, data model, and OpenAPI around the 2026-08-27
decisions: client-side mask/measure/crop, U2Net on demand, Woo as product
source of truth, draft-only local state. Architecture records topology C
through the status machine. Drop the old CONTENT-WOO spec until the
Call 2 prompt review finishes.
This commit is contained in:
Ty
2026-08-27 19:26:45 -07:00
parent bee3bcc278
commit 4e97770373
6 changed files with 962 additions and 1022 deletions
+228 -124
View File
@@ -1,186 +1,290 @@
# OpenAPI Contract — SvelteKit Client ↔ FastAPI
Status: SPEC (deliverable #2) · 2026-08-24 · SlabUploader
This is the binding API contract. The machine-readable `openapi.yaml` is generated
from the FastAPI app at implementation time and must match this doc. Base path:
`/api`. All requests/responses JSON unless noted. All timestamps UTC ISO-8601.
Status: SPEC · 2026-08-27 · SlabUploader
Base path: `/api/v1`. All requests/responses JSON unless noted.
All timestamps UTC ISO-8601.
## Conventions
- **Auth**: none (ARCH §7). Trusted LAN/Tailscale + HTTPS.
- **Errors** — uniform envelope:
```json
{ "error": { "code": "string", "message": "string", "detail": "object|null" } }
```
- `400` `validation` — payload failed validation (Pydantic).
- `404` `not_found` — unknown slab/setting.
- `409` `revision_conflict` — `client_rev`/`server_rev` mismatch (send `detail.current_server_rev`).
- `409` `duplicate_sku` — SKU already exists (FR24a).
- `422` `unpublishable` — slab not in a publishable state / missing required field.
- `502` `woo_error` — WooCommerce rejected the request (`detail` carries Woo body).
- `500` `internal` — unexpected.
- **Revisions**: create/update carry `client_rev`; server echoes `server_rev`. Client
stores `server_rev` and sends it back on the next update. Mismatch → `409 revision_conflict`.
- **Pagination**: v1 lists are small (single owner, slab-by-slab); lists return full
arrays. If volume grows, add `?limit&cursor` (documented, not built).
## Schemas (Pydantic v2)
### Auth
No client auth. Trusted LAN/Tailscale + HTTPS.
### Errors
Uniform envelope:
```json
{ "error": { "code": "string", "message": "string", "detail": "object|null" } }
```
- `400 validation` — payload failed validation.
- `404 not_found` — unknown slab/photo/setting.
- `409 revision_conflict` — client_rev/server_rev mismatch. `detail` carries `current_server_rev`.
- `409 duplicate_sku` — SKU already published in WooCommerce.
- `422 unpublishable` — slab not in a publishable state or missing required field.
- `502 woo_error` — WooCommerce rejected the request. `detail` carries Woo error body.
- `500 internal` — unexpected.
### Revisions
Create/update carry `client_rev`. Server echoes `server_rev`. Client stores `server_rev` and sends it back on the next update. Mismatch → `409 revision_conflict`.
### Photos
Uploaded as `multipart/form-data`. Max 40 MB per request. Two fields per photo:
- `files[]` — the original image file
- `meta[]` — JSON `{ kind: "inventory", role: "topdown"|"extra", seq: 1 }`
### Pagination
Lists are small (single owner, slab-by-slab). Lists return full arrays.
### API Version
URL is `/api/v1`. Breaking changes bump to `/api/v2`.
---
## Schemas
### SlabCreate
```jsonc
{
"id": "uuid (client-generated)",
"sku": "string 3..24 [A-Z0-9-]",
"length_in": 0, // optional at create; required before publish
"length_source": "manual|ruler|null",
"thickness_in": 0, // optional at create; required before publish
"species": "string|null",
"character": "string|null",
"title": "string|null", "short_title": "string|null", "description": "string|null",
"price": 0, "price_source": "recommendation|override|null",
"length_in": 96.0, // 1/8" step
"thickness_in": 1.5, // 1/8" step
"sqft": 0.64, // computed by client
"bdft": 0.96, // computed by client
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42, // → woo_categories (leaf)
"wood_category_ids": [15, 23], // JSON array → woo_categories (leaf, 1+)
"edge_type_term_id": 8, // → woo_attribute_terms
"figure_term_ids": [3], // JSON array → woo_attribute_terms (1+)
"grade_term_ids": [7], // JSON array → woo_attribute_terms (1+)
"thickness_term_id": 12, // → woo_attribute_terms (round-up)
"moisture_term_id": 5, // → woo_attribute_terms (defaults kiln-dried)
"fig_tag_ids": [99], // JSON array → woo_tags (fig-*, 1+)
"price_per_bdft": 12.50, // from pricing_rules
"price": 12.00, // 2 decimals
"price_source": "recommendation|override|null",
"title": "string|null",
"short_title": "string|null",
"description": "string|null",
"content_source": "manual|llm|null",
"client_rev": 1
}
```
Photos come as multipart `files[]` + `meta[]` alongside this JSON (wrapped in a multipart request where one part is the JSON and the others are the files).
### Slab (read model)
All of `SlabCreate` fields (resolved) plus:
`status`, `width_avg_in`, `width_source`, `sqft`, `bdft`, `price_per_bdft`,
`species_confidence`, `character_confidence`, `normalized_fill_ok`,
`woo_product_id`, `published_at`, `server_rev`, `created_at`, `updated_at`,
`photos: [PhotoRead]`.
All `SlabCreate` fields (resolved) plus:
`status`, `species_confidence`, `woo_product_id`, `published_at`,
`server_rev`, `created_at`, `updated_at`, `photos: [PhotoRead]`,
`inference_status: null|inferring|done|failed`, `inference_error: string|null`.
### PhotoRead
```jsonc
{ "id":"uuid","kind":"calibration_sku|calibration_ruler|inventory",
"role":"topdown|extra|null","seq":1,
"original_url":"/api/slabs/{id}/photos/{pid}/original",
"normalized_url":"/api/slabs/{id}/photos/{pid}/normalized|null",
"is_customer_facing":0|1 }
{ "id": "uuid", "kind": "inventory", "role": "topdown|extra",
"seq": 1, "original_url": "/api/v1/slabs/{id}/photos/{pid}/original",
"processed_url": "/api/v1/slabs/{id}/photos/{pid}/processed|null" }
```
### InferenceCall1Result (returned by infer-taxon)
```jsonc
{ "species_id": 42, "species_confidence": 0.92,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3], "figure_confidence": 0.87,
"grade_term_ids": [7], "grade_confidence": 0.75,
"character": { "type": "knot", "severity": "minor" },
"inclusions": { "type": "bark pocket", "severity": "none" },
"voids": "none",
"checks": "none",
"wood_categories": [ { "name": "Live edge", "confidence": 0.95 },
{ "name": "Bowl blank", "confidence": 0.82 },
{ "name": "Table slab", "confidence": 0.61 } ] }
```
### InferenceCall2Result (returned by infer-content)
```jsonc
{ "title": "string", "short_title": "string", "description": "string" }
```
### PriceRule
`{ id, species, character, price_per_bdft, created_at, updated_at }`
```jsonc
{ "id": "uuid", "species": "string", "price_per_bdft": 12.50,
"created_at": "timestamp", "updated_at": "timestamp" }
```
### SettingsView (read; secrets masked)
```jsonc
{ "woo_base_url":"https://www.whidbeywoodstore.com",
{ "woo_base_url": "https://www.whidbeywoodstore.com",
"woo_credentials_configured": true,
"inference_base_url":"http://192.168.1.202:8080",
"inference_model":"<model>", "inference_enabled": true,
"inference_base_url": "http://192.168.1.202:8080/v1",
"inference_model": "<model>", "inference_enabled": true,
"content_llm_enabled": false,
"brand_voice":"…", "geo_context":"…",
"aspect_ratio":"1:1","output_px":1600,"output_px_min":1000,"fill_target":0.80 }
"brand_voice": "...", "geo_context": "...",
"publish_status": "published",
"aspect_ratio": "3:4", "output_px": 1600, "output_px_min": 1600,
"fill_target": 0.80,
"user_sensitivity": 0.5, "user_edge_offset": 0, "user_feather": 1,
"user_sheet_mode": "auto" }
```
Secrets (keys) are never returned; only a `*_configured` boolean.
Secrets (keys) are never returned. Only a `*_configured` boolean.
---
## Endpoints
### Health & meta
- `GET /api/health` → `{ "status":"ok","version":"…","woo_reachable":bool }`
(light probe; used by PWA connectivity check, ARCH §3.3)
- `GET /api/meta` → `{ "version":"…","templates_configured":bool }`
- `GET /api/v1/health` → `{ "status":"ok", "version":"...", "woo_reachable": bool }`
- `GET /api/v1/meta` → `{ "version":"...", "templates_configured": bool }`
### Slabs
- `POST /api/slabs` body `SlabCreate` → `201` `Slab` (status `draft`).
- `409 duplicate_sku` if SKU exists.
- `GET /api/slabs` → `200` `[Slab]` (all, most-recent first).
- `GET /api/slabs/{id}` → `200` `Slab` / `404`.
- `PUT /api/slabs/{id}` body `SlabUpdate` (any subset of editable fields + `client_rev`)
→ `200` `Slab`. Re-runs derived math (sqft/bdft/price recommendation) server-side when
inputs change. `409 revision_conflict`, `409 duplicate_sku`.
- `DELETE /api/slabs/{id}` → `204` (soft: only allowed while unpublished; published
slabs are retained for audit).
- `POST /api/v1/slabs` — Create a calibrated draft. Body: multipart with `data` (SlabCreate JSON) + `files[]` (photos) + `meta[]` (photo metadata).
- Returns `201` `Slab` (status `draft`).
- Server auto-runs Call 1 (vision inference). `inference_status` becomes `inferring`, then `done`/`failed`.
- `409 duplicate_sku` if SKU already published.
- `GET /api/v1/slabs` → `200` `[Slab]` (most recent first).
- `GET /api/v1/slabs/{id}` → `200` `Slab` / `404`.
- Also used as the poll endpoint for inference and publish. Long-running ops set status to `inferring`/`publishing`; client backs off (1s, 2s, 4s, cap 10s).
- `PUT /api/v1/slabs/{id}` — Update draft fields. Body: partial `SlabCreate` (any subset of editable fields + `client_rev`).
- Server re-runs derived math when inputs change.
- `409 revision_conflict`, `409 duplicate_sku`.
- `DELETE /api/v1/slabs/{id}` → `204`. Soft: only unpublished drafts. Published slabs cannot be deleted from the app.
### Photo upload (offline queue, ARCH §3.2)
- `POST /api/slabs/{id}/upload` body: JSON manifest
```jsonc
{ "client_rev":2,
"photos":[ { "id":"uuid","kind":"inventory","role":"topdown","seq":1,
"blob":"<base64>" , "filename":"slab1_topdown.jpg" } ] }
```
(Photos are base64 in v1 for transport simplicity; total request cap 40 MB.
If that proves heavy, switch to multipart — documented extension.)
→ `202` `{ "status":"processing" }`. Server runs the authoritative pipeline
(ruler→scale→geometry→widths→bdft→species/char→pricing→normalization→content)
asynchronously. Client polls `GET /api/slabs/{id}`.
- `409 revision_conflict` on stale `client_rev`.
- Pipeline failure → slab `failed` (poll returns the error).
### Photo operations
- `POST /api/v1/slabs/{id}/u2net/{pid}` — "Try harder." Server runs U2Net on this one photo, returns a better mask.
- Returns `202` `{ mask_url: "..." }`. Client uses the mask to re-compute area/widths locally.
- `404` if photo not found.
- `GET /api/v1/slabs/{id}/photos/{pid}/original` → `image/*` (original, while retained).
- `GET /api/v1/slabs/{id}/photos/{pid}/processed` → `image/*` (processed PNG, while retained).
- After publish, both → `410 Gone`.
### Inference
- `POST /api/v1/slabs/{id}/infer-taxon` — Trigger or retry Call 1 (vision).
- Server uses cached taxonomy + original photos downscaled to 1024px + user metadata.
- Returns `202` `{ "status": "inferring" }`. Client polls `GET /api/v1/slabs/{id}` for results.
- Timeout > 45s → `504 gateway_timeout` with error detail.
- `POST /api/v1/slabs/{id}/infer-content` — Trigger Call 2 (text).
- Server uses curated Call 1 results + deterministic numbers + brand voice + GEO context + prompt.
- Returns `202` `{ "status": "inferring" }`. Client polls for results.
- Timeout > 45s → `504` with error detail.
### Publish
- `POST /api/slabs/{id}/publish` → `202` `{ "status":"publishing" }`.
Pre-conditions (else `422 unpublishable`): status in `ready`/`review`, length+width+
thickness present, price present, ≥1 inventory photo, species/character confirmed.
- Dedupe: if SKU already has a published slab → `409 duplicate_sku` (FR24a).
- `POST /api/v1/slabs/{id}/publish` — Publish to WooCommerce.
- Pre-conditions: status in `ready`, all required fields set. Else `422 unpublishable`.
- Dedupe: if SKU already has a published product → `409 duplicate_sku`.
- Returns `202` `{ "status": "publishing" }`. Client polls.
- Woo failure → slab `failed`, `sync_log` written, poll returns `502 woo_error`.
- Success → slab `published`, `woo_product_id` set, images purged (Decision 12).
- Success → slab `published`, `woo_product_id` set, images purged, draft row deleted.
### Status poll
- `GET /api/slabs/{id}` (same as above) is the poll endpoint. Long-running ops set
`status` to `processing`/`publishing`; client backs off (1s,2s,4s… cap 10s).
### Taxonomy
- `GET /api/v1/admin/taxonomy` → `200` taxonomy grouped by kind:
```jsonc
{ "categories": [...], "attributes": [{ name, slug, terms: [...] }],
"tags": [...] }
```
- `POST /api/v1/admin/taxonomy/sync` → `200` `{ "categories": n, "attributes": n, "tags": n, "synced_at": "..." }`.
- Pulls from Woo, rebuilds the cache. `502 woo_error` on failure; previous cache retained.
### Photos (serve)
- `GET /api/slabs/{id}/photos/{pid}/original` → `image/*` (original, while retained)
- `GET /api/slabs/{id}/photos/{pid}/normalized` → `image/*` (normalized, while retained)
- After publish, both → `410 Gone` (images purged, Decision 12).
### Pricing
- `GET /api/v1/admin/pricing` → `[PriceRule]`
- `POST /api/v1/admin/pricing` → `201` `PriceRule`
- `PUT /api/v1/admin/pricing/{id}` → `200` `PriceRule`
- `DELETE /api/v1/admin/pricing/{id}` → `204`
### Taxonomy (FR25/FR26)
- `POST /api/admin/taxonomy/sync` → `200` `{ "categories":n,"tags":n,"attributes":n,"synced_at":"…" }`
Pulls from Woo, rebuilds `woo_taxonomy` cache. `502 woo_error` on failure.
- `GET /api/admin/taxonomy` → `200` the cached taxonomy (grouped by kind).
This is the controlled list for species (Decision 4) and auto-assign (FR26).
### Pricing rules (FR13/14/15)
- `GET /api/pricing-rules` → `[PriceRule]`
- `POST /api/pricing-rules` body `{ species, character, price_per_bdft }` → `201` `PriceRule`
(`409` if pair exists → use PUT).
- `PUT /api/pricing-rules/{id}` body `{ price_per_bdft }` → `200` `PriceRule`
- `DELETE /api/pricing-rules/{id}` → `204`
- `GET /api/pricing-rules/recommend`?species=…&character=…&bdft=… → `200`
`{ "price_per_bdft":n|null, "recommendation":n|null }` (FR14; null if no rule).
### Settings (FR32)
- `GET /api/settings` → `200` `SettingsView`
- `PUT /api/settings` body: partial `{ key: value }` for non-secret fields, and
secret fields by name (values re-encrypted server-side, ARCH §7).
→ `200` `SettingsView`.
- `POST /api/settings/test-woo` → `200` `{ "ok":bool,"detail":"…" }` (validates Woo creds).
- `POST /api/settings/test-inference` → `200` `{ "ok":bool,"vision_capable":bool,"detail":"…" }`
(sends a tiny image; `vision_capable` false → UI warns, species inference disabled).
### Settings
- `GET /api/v1/settings` → `200` `SettingsView`
- `PUT /api/v1/settings` — Update settings. Body: partial `{ key: value }`.
- Secrets re-encrypted server-side. `200` `SettingsView` returned.
- `POST /api/v1/settings/test-woo` → `200` `{ "ok": bool, "detail": "..." }`
- `POST /api/v1/settings/test-inference` → `200` `{ "ok": bool, "vision_capable": bool, "detail": "..." }`
---
## Request/response examples
### Create a slab
### Create a slab (multipart)
```
POST /api/slabs
{ "id":"9f1c…","sku":"BW-0042","length_source":null,"client_rev":1 }
→ 201
{ "id":"9f1c…","sku":"BW-0042","status":"draft","server_rev":1, "photos":[], … }
POST /api/v1/slabs
Content-Type: multipart/form-data; boundary=---
---
Content-Disposition: form-data; name="data"
Content-Type: application/json
{ "id":"9f1c...","sku":"BW-0042","length_in":96.0,"thickness_in":1.5,
"sqft":0.64,"bdft":0.96,"width_min_in":14.0,"width_max_in":22.0,
"width_avg_in":18.2,"species_id":42,"wood_category_ids":[15,23],
"edge_type_term_id":8,"figure_term_ids":[3],"grade_term_ids":[7],
"thickness_term_id":12,"moisture_term_id":5,"fig_tag_ids":[99],
"price_per_bdft":12.50,"price":12.00,"price_source":"recommendation",
"title":null,"short_title":null,"description":null,
"content_source":null,"client_rev":1 }
---
Content-Disposition: form-data; name="files[]"; filename="topdown.jpg"
Content-Type: image/jpeg
<binary>
---
Content-Disposition: form-data; name="meta[]"
Content-Type: application/json
{ "kind": "inventory", "role": "topdown", "seq": 1 }
---
```
Response: `201`
```json
{ "id":"9f1c...","sku":"BW-0042","status":"draft",
"inference_status":"inferring","server_rev":1,"photos":[...],... }
```
### Upload photos (triggers pipeline)
### Call 1 completes (poll)
```
POST /api/slabs/9f1c…/upload
{ "client_rev":1, "photos":[ …base64… ] }
→ 202 { "status":"processing" }
GET /api/slabs/9f1c… (poll)
→ 200 { "status":"ready","bdft":218.4,"width_avg_in":18.2,"price":2730.0,
"species":"Black Walnut","character":"Cathedral","server_rev":2, … }
GET /api/v1/slabs/9f1c...
→ 200 { "status":"draft","inference_status":"done",
"species_id":42,"species_confidence":0.92,
"wood_category_ids":[15],"edge_type_term_id":8,
"figure_term_ids":[3],"grade_term_ids":[7],
"inference_error":null, ... }
```
### Call 2 (user taps Generate)
```
POST /api/v1/slabs/9f1c.../infer-content
→ 202 { "status":"inferring" }
GET /api/v1/slabs/9f1c...
→ 200 { "title":"Black Walnut 8ft 0in × 18.2in Slab — Cathedral",
"short_title":"Black Walnut Slab — Cathedral",
"description":"A one-of-a-one cathedral black walnut slab...",
"content_source":"llm", ... }
```
### Publish
```
POST /api/slabs/9f1c…/publish
POST /api/v1/slabs/9f1c.../publish
→ 202 { "status":"publishing" }
GET /api/slabs/9f1c… (poll)
→ 200 { "status":"published","woo_product_id":1042,"published_at":"…", … }
GET /api/v1/slabs/9f1c...
→ 200 { "status":"published","woo_product_id":1042,"published_at":"..." }
```
### Taxonomy sync
```
POST /api/v1/admin/taxonomy/sync
→ 200 { "categories": 24, "attributes": 5, "tags": 12, "synced_at": "..." }
```
### Test connections
```
POST /api/v1/settings/test-woo
→ 200 { "ok": true, "detail": "Connected to Whidbey Wood Store" }
POST /api/v1/settings/test-inference
→ 200 { "ok": true, "vision_capable": true, "detail": "Model Qwen3.8-27B confirmed" }
```
---
## Versioning
URL is `/api/v1` at implementation (this doc omits the prefix for brevity). Breaking
changes bump to `/api/v2`; the PWA pins the version it was built against.
URL is `/api/v1`. Breaking changes bump to `/api/v2`. The PWA pins the version it was built against.
## Out of contract (v1)
- Bulk endpoints, staff/role endpoints, webhook push (client polls), analytics.
- Bulk endpoints, staff/role endpoints, webhook push, analytics.