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:
+240
-217
@@ -1,217 +1,240 @@
|
|||||||
# Slab Capture & WooCommerce Publishing App — PRD
|
# Slab Capture & WooCommerce Publishing App — PRD
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
**Purpose**
|
Build a mobile-first, self-hosted web app that streamlines publishing one-of-a-one wood
|
||||||
Build a mobile‑first, self‑hosted web app that streamlines publishing one‑of‑one wood slabs to a WooCommerce store. The app is a field capture → enrichment → publish pipeline that enforces deterministic core logic (measurements, pricing, normalization, sync) and uses inference only in tightly scoped areas (species/character detection and optional content refinement) via a configurable OpenAI‑compatible endpoint.
|
slabs to a WooCommerce store. The app is a photo capture → calibration → inference →
|
||||||
|
publish pipeline.
|
||||||
**Primary constraints**
|
|
||||||
- No slab versioning in v1.
|
**Primary constraints**
|
||||||
- No staff accounts or role complexity in v1.
|
- No slab versioning in v1.
|
||||||
- No bulk publishing; slab‑by‑slab submission only.
|
- No staff accounts or role complexity in v1.
|
||||||
- Measurements must be accurate; manual length entry is authoritative and used to extrapolate widths and bdft.
|
- No bulk publishing; slab-by-slab submission only.
|
||||||
- No analytics in v1.
|
- No analytics in v1.
|
||||||
- No pricing tiers in v1 — flat price-per-bdft per species/character.
|
- No pricing tiers in v1 — flat price-per-bdft per species/wood-category.
|
||||||
- No app-level auth in v1 — no JWT/login; app relies on HTTPS + trusted network.
|
- No app-level auth in v1 — app relies on HTTPS + trusted network.
|
||||||
- Single SKU per slab; duplicate SKU publish is blocked.
|
- Single SKU per slab; duplicate SKU publish is blocked.
|
||||||
- Images are purged after successful publish (retained only while draft/retry).
|
- Images are purged after successful publish (retained only while draft/retry).
|
||||||
- Deploy target: .201, Docker Compose + Caddy.
|
- Deploy target: .201, Docker Compose + Caddy.
|
||||||
|
|
||||||
## Decisions log (2026-08-24, Ty)
|
## Decisions log (2026-08-27, Ty)
|
||||||
1. Widths: sample 3–5 widths at peaks/troughs of the slab edge (see Open items for the bdft formula).
|
1. No calibration photos — user manually enters SKU, length, thickness.
|
||||||
2. Fallback chain: ruler shot if readable → manual length entry; from manual length + a top-down photo, derive pixel scale and extrapolate widths.
|
2. First photo is the measurement photo (must be top-down, lens parallel to broad side).
|
||||||
3. Ruler is always in inches.
|
3. All photos go through background removal + 80% frame-fill crop.
|
||||||
4. Species/character list = synced from WooCommerce categories (FR25); user sets price-per-bdft per value.
|
4. Output: transparent PNGs, minimum 1600 px on the smaller dimension, aspect ratio preserved.
|
||||||
5. No tiers — price = bdft × price_per_bdft, user override before Woo submission.
|
5. Green or black sheet — algorithm auto-detects background color from image borders.
|
||||||
6. All inference models must support vision (species/character detection operates on slab photos).
|
6. WooCommerce categories synced from store: Species (1 required, leaf-only), Wood Category (1+ required, leaf-only).
|
||||||
7. v1 target store: www.whidbeywoodstore.com.
|
7. WooCommerce attributes: Edge Type (1 required), Figure (1+ required), Grade (1+ required), Thickness (1 required, rounded up to nearest Woo attribute range).
|
||||||
8. Aspect ratio: 1:1 (Woo best practice), ≥1000×1000 px, slab centered at ~80% fill.
|
8. WooCommerce tags: fig-* only (1+ required, e.g., fig-chat = Chatoyant).
|
||||||
9. Same SKU = blocked (simple dedupe).
|
9. Two-call inference: Call 1 (vision, wood expert) → Call 2 (text, content gen). Both configurable.
|
||||||
10. No app-level auth; Woo API auth = username (consumer key) + app password.
|
10. Call 1 runs automatically after calibration. User reviews/corrects before triggering Call 2.
|
||||||
11. Single FastAPI backend (Node service dropped — Python handles WooCommerce REST).
|
11. Inference timeout >45s or non-200 → error with retry. User can fill fields manually.
|
||||||
12. Images not needed after WooCommerce submission; purged on successful publish.
|
12. Woo product status set to "published" by default (configurable in settings).
|
||||||
13. Deploy on .201; Docker + Caddy preferred.
|
13. All inference prompts stored server-side, editable in settings with reset-to-default.
|
||||||
14. UATs built after the prototype.
|
14. Brand voice + GEO context injected into Call 2 prompt.
|
||||||
15. The schema in the Security section is the pricing-rule shape — confirmed.
|
15. WooCommerce credentials (username + App Password) stored client-side in settings.
|
||||||
16. (Orphan ```md fence fixed.)
|
16. Inference endpoint: configurable OpenAI-compatible API (user provides base URL, key, model).
|
||||||
17. Bolt.dev deliverable disregarded — this is a Hermes project.
|
17. Test connection verifies text completion + vision support.
|
||||||
18. bdft formula: average of sampled widths → bdft = L × w_avg × t / 12 (accepted 2026-08-24).
|
18. Offline capture not required for MVP.
|
||||||
19. No usable top-down photo → manual width entry (accepted 2026-08-24).
|
|
||||||
20. Normalized image size: 1600×1600 preferred output, 1000×1000 minimum (accepted 2026-08-24).
|
## Open items
|
||||||
|
- (none — all resolved 2026-08-27)
|
||||||
## Open items
|
|
||||||
- (none — all resolved 2026-08-24; see Decisions log items 18–20)
|
## Goals and success metrics
|
||||||
|
**Goals**
|
||||||
## Goals and success metrics
|
- Reduce per-slab prep time (target: 20-30 to 5-10 minutes).
|
||||||
**Goals**
|
- Produce consistent, high-quality product pages (title, description, taxonomy, images).
|
||||||
- Reduce per‑slab prep time (target: 20–30 → 5–10 minutes).
|
- Keep deterministic core logic testable and auditable.
|
||||||
- Produce consistent, high‑quality product pages (title, description, taxonomy, images).
|
- Mobile-first UX with fast feedback.
|
||||||
- Keep deterministic business logic testable and auditable.
|
- Allow Ty to host and control inference via a configurable OpenAI endpoint.
|
||||||
- Mobile‑first UX with offline capture and fast feedback.
|
|
||||||
- Allow Ty to host and control inference via a configurable OpenAI endpoint.
|
**Success metrics**
|
||||||
|
- Average time from first photo to published product <= 10 minutes.
|
||||||
**Success metrics**
|
- Dimension extraction accuracy within +/- 0.25 in.
|
||||||
- Average time from first photo to published product ≤ 10 minutes.
|
- Photo normalization produces consistent 80% frame fill for 95% of inventory photos.
|
||||||
- OCR SKU accuracy ≥ 95% in staged conditions.
|
- Inference results accepted or corrected by user in <= 2 edits for 90% of slabs.
|
||||||
- Dimension extraction accuracy within ±0.25 in when ruler photo usable.
|
|
||||||
- Photo normalization produces consistent aspect ratio and ~80% frame fill for 95% of inventory photos.
|
## Non-goals
|
||||||
- Price recommendation accepted or adjusted by user in ≤ 2 edits for 90% of slabs.
|
- Multi-tenant SaaS or ERP integrations in v1.
|
||||||
|
- Generic SKU inventory management.
|
||||||
## Non‑goals
|
- Complex user roles or staff permissions.
|
||||||
- Multi‑tenant SaaS or ERP integrations in v1.
|
- Bulk import/publish workflows.
|
||||||
- Generic SKU inventory management.
|
- Offline capture for MVP.
|
||||||
- Complex user roles or staff permissions.
|
|
||||||
- Bulk import/publish workflows.
|
## Users and core use cases
|
||||||
|
|
||||||
## Users and core use cases
|
**Primary user**
|
||||||
|
- Ty (Owner/Expert): captures, configures, reviews, and publishes slabs.
|
||||||
**Primary user**
|
|
||||||
- Ty (Owner/Expert): captures, configures pricing rules, reviews and publishes slabs.
|
**Core use cases**
|
||||||
|
- Capture: Stage slab on green or black sheet with lights, take 1-5 photos.
|
||||||
**Secondary user**
|
- Enter metadata: SKU, length (in), thickness (in).
|
||||||
- Field operator (optional): may capture photos and basic data; no special permissions required.
|
- Calibration: App processes first photo - BG removal, longest dimension detection,
|
||||||
|
area measurement (sqft), bdft calc, then processes all photos for BG removal + crop.
|
||||||
**Core use cases**
|
- Inference: Call 1 (vision) auto-runs, populating taxonomy/content. User reviews/corrects.
|
||||||
- Capture: Stage slab (green sheet, SKU label, ruler), take calibration photos (SKU close‑up, ruler shot), then 1–5 inventory photos.
|
Call 2 (text) user-triggered for title/description/short description.
|
||||||
- Fallbacks: If SKU or ruler unreadable, allow manual entry (SKU or length). Manual length is authoritative and used to extrapolate widths and sqft.
|
- Review & publish: Final review screen with full override capability; publish to WooCommerce.
|
||||||
- Enrich: Species detection (optional inference) or manual selection; character selection (manual or optional inference); thickness manual entry.
|
- Configuration: Settings for inference endpoint, Woo credentials, pricing rules,
|
||||||
- Pricing: Apply bdft pricing rules (species + character) to compute price recommendation; allow override.
|
brand voice, GEO context, prompts.
|
||||||
- Photo normalization: Background removal, auto‑crop to ~80% frame fill, enforce 1:1 aspect ratio, optional merchandising filter.
|
|
||||||
- Review & publish: Final review screen with full override capability; publish single slab to WooCommerce using App Passwords.
|
## High level requirements summary
|
||||||
- Configuration: Configure OpenAI endpoint, WooCommerce credentials, pricing rules, brand voice, and GEO context.
|
- Deterministic core: all measurement math, pricing, normalization, and WooCommerce sync
|
||||||
|
must be deterministic and unit-tested.
|
||||||
## High level requirements summary
|
- Scoped inference: species/category/attribute detection and content generation only;
|
||||||
- Deterministic core: all measurement math, pricing, normalization, and WooCommerce sync must be deterministic and unit‑tested.
|
inference must be toggleable and use Ty's configured OpenAI endpoint.
|
||||||
- Scoped inference: species/character detection and optional content refinement only; inference must be toggleable and use Ty’s configured OpenAI endpoint.
|
- Mobile first: usable on modern iPhone and Android browsers.
|
||||||
- Mobile first: usable on modern iPhone and Android browsers; offline capture and local drafts.
|
- Single slab workflow: no batch publish in v1; each slab is captured, enriched,
|
||||||
- Single slab workflow: no batch publish in v1; each slab is captured, enriched, reviewed, and published individually.
|
reviewed, and published individually.
|
||||||
|
|
||||||
## Functional requirements (detailed)
|
## Functional requirements (detailed)
|
||||||
|
|
||||||
### Capture and calibration
|
### Capture and calibration
|
||||||
- **FR1:** Camera capture for calibration photos (SKU close‑up, ruler shot) and 1–5 inventory photos.
|
- **FR1:** Camera capture for 1-5 inventory photos. First photo must be top-down,
|
||||||
- **FR2:** Calibration photos are internal only and never uploaded to WooCommerce.
|
lens parallel to the slab's broad side.
|
||||||
- **FR3:** Client runs deterministic SKU OCR (Tesseract.js) on SKU close‑up; if OCR fails, prompt manual SKU entry.
|
- **FR2:** User manually enters SKU (3-24 chars, [A-Z0-9-]), length (in), thickness (in).
|
||||||
- **FR4:** Client runs lightweight ruler detection (OpenCV.js). If client pass fails, upload ruler photo to server for advanced deterministic processing.
|
- **FR3:** App detects background color from image borders (green or black sheet).
|
||||||
- **FR5:** If ruler detection fails or ruler photo missing, prompt manual length entry. Manual length is authoritative; system extrapolates widths using pixel ratios from inventory photos when available.
|
- **FR4:** First photo: BG removal - find longest dimension - compute scale
|
||||||
- **FR6:** All capture flows must be offline‑first with local caching and an upload queue.
|
(longest_px / user-length) - compute area (sqft) - bdft = sqft * thickness / 12.
|
||||||
|
- **FR5:** All photos: BG removal - crop to 80% frame fill - save as transparent PNG,
|
||||||
### Dimensions, thickness, and calculations
|
minimum 1600 px on smaller dimension, aspect ratio preserved.
|
||||||
- **FR7:** Compute length, one or more widths, sqft, and bdft deterministically.
|
- **FR6:** User can retake the first photo or abort and start fresh.
|
||||||
- **FR8:** Thickness is always manually entered and treated as authoritative.
|
|
||||||
- **FR9:** All dimension math must be deterministic, unit‑tested, and reproducible.
|
### Dimensions and calculations
|
||||||
|
- **FR7:** Compute length (user-entered), area (sqft), and bdft deterministically.
|
||||||
### Species and character
|
- **FR8:** Thickness is always manually entered and treated as authoritative.
|
||||||
- **FR10:** Support species selection via optional inference or manual selection from a controlled list. Inference must return a confidence score; below threshold require manual confirmation.
|
- **FR9:** All dimension math must be deterministic, unit-tested, and reproducible.
|
||||||
- **FR11:** Character selection is manual by default; optional inference allowed but must be confirmable.
|
|
||||||
- **FR12:** User can override species/character at any time prior to publish.
|
### Taxonomy (WooCommerce sync)
|
||||||
|
- **FR10:** Sync categories, attributes, and tags from WooCommerce and cache locally.
|
||||||
### Pricing engine
|
- **FR11:** Species category: 1 required, leaf-only selection.
|
||||||
- **FR13:** UI to define bdft pricing rules by species and character (flat price per bdft; no tiers in v1). Species/character values come from the synced WooCommerce category list (FR25).
|
- **FR12:** Wood Category: 1+ required, leaf-only selection (parents shown for grouping only).
|
||||||
- **FR14:** Deterministic bdft calculation and application of pricing rules to produce a price recommendation.
|
- **FR13:** Edge Type attribute: 1 required.
|
||||||
- **FR15:** Price recommendation editable by user; final price stored as authoritative.
|
- **FR14:** Figure attribute: 1+ required.
|
||||||
|
- **FR15:** Grade attribute: 1+ required.
|
||||||
### Content generation (scoped inference)
|
- **FR16:** Thickness attribute: 1 required, mapped by rounding up user-entered
|
||||||
- **FR16:** Generate title, short title, and description using deterministic templates augmented by optional LLM refinement via Ty’s OpenAI endpoint.
|
thickness to nearest Woo attribute range.
|
||||||
- **FR17:** Generated content must be SEO/GEO aligned and reflect configured brand voice; LLM use is optional and toggleable.
|
- **FR17:** Tags: fig-* only, 1+ required.
|
||||||
- **FR18:** All generated content is editable before publish; no automatic publishing of LLM output.
|
- **FR18:** Auto-assign taxonomy based on inference results; allow manual override.
|
||||||
|
|
||||||
### Photo normalization and merchandising
|
### Pricing engine
|
||||||
- **FR19:** Remove green‑sheet background deterministically (RemBG/U²Net or equivalent).
|
- **FR19:** UI to define bdft pricing rules by species and wood category.
|
||||||
- **FR20:** Detect slab boundaries and auto‑crop so slab fills ~80% of frame; enforce 1:1 aspect ratio, slab centered; output 1600×1600 px preferred (1000×1000 minimum).
|
- **FR20:** Deterministic bdft calculation and application of pricing rules to produce
|
||||||
- **FR21:** Store both original and normalized images.
|
a price recommendation.
|
||||||
- **FR22:** Optional merchandising filter (contrast, warmth, grain enhancement) applied deterministically; toggleable per slab.
|
- **FR21:** Price recommendation editable by user; final price stored as authoritative.
|
||||||
- **FR23:** Provide photo review UI to reorder, delete, or retake images.
|
|
||||||
|
### Inference (two-call architecture)
|
||||||
### WooCommerce sync and taxonomy
|
- **FR22:** Call 1 (vision): auto-run after calibration. Input: all inventory photos
|
||||||
- **FR24:** Authenticate to WooCommerce using consumer key (username) + consumer secret (App Password) against www.whidbeywoodstore.com.
|
(downscaled to 1024px max, using original pre-crop photos), user metadata, synced
|
||||||
- **FR24a:** Publish is blocked if the slab SKU already exists in WooCommerce (simple dedupe, no update path in v1).
|
Woo taxonomy. Output: species, wood categories, edge type, figure, grade,
|
||||||
- **FR25:** Sync categories, tags, and attributes from WooCommerce and cache locally.
|
character/inclusions/voids/checks with severity, top 3 wood categories with
|
||||||
- **FR26:** Auto‑assign taxonomy based on species/character mapping; allow manual override.
|
confidence. If no species match above threshold, omit and require manual selection.
|
||||||
- **FR27:** Create/update single WooCommerce product per slab with SKU, titles, description, price, categories, attributes, and normalized images.
|
- **FR23:** Call 2 (text): user-triggered after Call 1 curation. Input: deterministic
|
||||||
- **FR28:** Log sync results and surface errors; store WooCommerce product ID on success.
|
data + Call 1 results + brand voice + GEO context. Output: title, description,
|
||||||
|
short description.
|
||||||
### Drafts, review, and publish
|
- **FR24:** Inference prompts stored server-side, editable in settings, reset-to-default available.
|
||||||
- **FR29:** Maintain a draft slab state with full metadata and photos.
|
- **FR25:** Progress indicator during inference calls (latency expected).
|
||||||
- **FR30:** Provide a single review screen showing all fields and normalized images; every field is editable.
|
- **FR26:** Timeout >45s or non-200 - visible error with retry option.
|
||||||
- **FR31:** Publish action performs deterministic server‑side processing and WooCommerce sync; update status to Published/Failed with error details.
|
- **FR27:** All inference results editable; no automatic publish of LLM output.
|
||||||
|
- **FR28:** Inference endpoint configured via settings: base URL, API key, model name.
|
||||||
### Configuration and hosting
|
Test connection verifies text + vision support.
|
||||||
- **FR32:** Settings UI for OpenAI base URL, OpenAI API key, model name, WooCommerce URL, WooCommerce App Password, pricing rules, brand voice, GEO context.
|
|
||||||
- **FR33:** App deploys via Docker Compose on .201 with Caddy for HTTPS (single domain, reverse proxy to frontend + fastapi).
|
### Photo normalization and output
|
||||||
- **FR34:** All secrets stored encrypted at rest; all client‑server traffic over HTTPS.
|
- **FR29:** Background removal: chroma-key for green/black, fallback to U2Net matting.
|
||||||
|
- **FR30:** Crop to 80% frame fill, slab centered, aspect ratio preserved,
|
||||||
## Non‑functional requirements
|
minimum 1600 px on smaller dimension.
|
||||||
- **Performance:** Client capture and local OCR within 1–2s; server normalization and inference within 3–5s per slab target.
|
- **FR31:** Output as transparent PNG (ideal for WooCommerce).
|
||||||
- **Reliability:** Offline capture and queued sync; deterministic logic fully unit‑tested.
|
- **FR32:** Store both original and processed images. Purge after successful publish.
|
||||||
- **Security:** Encrypted secrets, HTTPS, minimal persistent credentials on client.
|
|
||||||
- **Maintainability:** Clear separation of deterministic code and inference code; modular services.
|
### WooCommerce sync and publish
|
||||||
- **Usability:** One‑hand operation, large touch targets, clear distinction between internal and customer photos.
|
- **FR33:** Authenticate to WooCommerce using consumer key + App Password.
|
||||||
|
- **FR34:** Publish is blocked if the slab SKU already exists (simple dedupe, no update path).
|
||||||
## Architecture and tech stack
|
- **FR35:** Create WooCommerce product with SKU, title, description, short description,
|
||||||
|
price, categories, attributes, tags, and normalized PNG images.
|
||||||
**Client (mobile web / PWA)**
|
- **FR36:** Default product status: "published" (configurable in settings).
|
||||||
- SvelteKit (mobile‑first)
|
- **FR37:** Log sync results and surface errors; store WooCommerce product ID on success.
|
||||||
- TailwindCSS for UI
|
|
||||||
- Tesseract.js for client SKU OCR (deterministic)
|
### Drafts, review, and publish
|
||||||
- OpenCV.js for lightweight ruler detection and preview cropping
|
- **FR38:** Maintain a draft slab state with full metadata and photos.
|
||||||
- IndexedDB for offline drafts and photo caching
|
- **FR39:** Provide a single review screen showing all fields; every field is editable.
|
||||||
- Camera API for capture and immediate preview
|
- **FR40:** Publish action performs deterministic server-side processing and WooCommerce
|
||||||
|
sync; update status to Published/Failed with error details.
|
||||||
**Server (self‑hosted, containerized)**
|
|
||||||
- FastAPI (Python) for deterministic image pipeline, advanced ruler detection, bdft math, RemBG/U²Net background removal, species/character inference endpoint (optional), content generation wrapper that calls Ty’s OpenAI endpoint, and WooCommerce REST integration (taxonomy sync, product creation, sync logging) — the Node.js service from the original draft was folded in (see Decision 11).
|
### Configuration and hosting
|
||||||
- SQLite for v1 (slab records, pricing rules, taxonomy cache, sync history). Optional Postgres later.
|
- **FR41:** Settings UI for inference endpoint, Woo credentials, pricing rules,
|
||||||
|
brand voice, GEO context, prompts.
|
||||||
**Inference**
|
- **FR42:** App deploys via Docker Compose on .201 with Caddy for HTTPS.
|
||||||
- Configurable OpenAI‑compatible endpoint (user supplies base URL and key). **The endpoint/model must support vision** — species/character detection operates on slab photos. Inference limited to species/character detection and optional content refinement. All inference calls logged and include confidence scores.
|
- **FR43:** All secrets stored encrypted at rest; all client-server traffic over HTTPS.
|
||||||
|
|
||||||
**Deployment**
|
## Non-functional requirements
|
||||||
- Docker Compose with `frontend` and `fastapi` services on .201.
|
- **Performance:** Client capture and local processing within 1-2s; server normalization
|
||||||
- Caddy for HTTPS and reverse proxy.
|
within 3-5s per slab; inference calls within 45s.
|
||||||
- Docker compose as the service supervisor.
|
- **Reliability:** Deterministic logic fully unit-tested.
|
||||||
|
- **Security:** Encrypted secrets, HTTPS, minimal persistent credentials on client.
|
||||||
**Security**
|
- **Maintainability:** Clear separation of deterministic code and inference code.
|
||||||
- AES encryption for stored secrets.
|
- **Usability:** One-hand operation, large touch targets, clear distinction between
|
||||||
- No app-level auth in v1 (single owner; app protected by HTTPS + network trust).
|
internal processing and customer-facing output.
|
||||||
- HTTPS enforced.
|
|
||||||
|
## Architecture and tech stack
|
||||||
### Pricing rule schema (confirmed as the pricing-rule shape — Decision 15)
|
|
||||||
id: uuid
|
**Client (mobile web / PWA)**
|
||||||
species: string
|
- SvelteKit (mobile-first)
|
||||||
character: string
|
- TailwindCSS for UI
|
||||||
price_per_bdft: float
|
- Camera API for capture and immediate preview
|
||||||
created_at: timestamp
|
- No OCR, no OpenCV on client
|
||||||
updated_at: timestamp
|
|
||||||
|
**Server (self-hosted, containerized)**
|
||||||
|
- FastAPI (Python) for deterministic image pipeline, bdft math, pricing,
|
||||||
## Key flows and acceptance criteria
|
background removal, crop/normalize, WooCommerce REST integration,
|
||||||
|
inference wrappers, taxonomy sync
|
||||||
### Capture → Enrich → Publish flow
|
- SQLite for v1 (slab records, pricing rules, taxonomy cache, sync history)
|
||||||
1. New slab: user stages slab and opens app.
|
|
||||||
2. Calibration: take SKU close‑up and ruler shot (internal only). Client runs SKU OCR and lightweight ruler detection. If either fails, prompt manual SKU or length. Manual length is authoritative.
|
**Inference**
|
||||||
3. Inventory photos: capture 1–5 customer‑facing photos. Client stores originals locally.
|
- Configurable OpenAI-compatible endpoint (user supplies base URL, key, model).
|
||||||
4. Upload: client uploads draft to server for advanced processing.
|
Must support vision. Two calls: wood expert (vision) + content generation (text).
|
||||||
5. Server processing: advanced ruler detection → pixel→inch scaling → widths inference → bdft calc → species/character inference (optional) → pricing engine → photo normalization → content generation (optional via configured OpenAI endpoint).
|
All inference calls logged.
|
||||||
6. Review: client receives enriched draft; user edits/overrides any field.
|
|
||||||
7. Publish: user triggers publish; FastAPI syncs taxonomy and creates the WooCommerce product with normalized images (SKU-deduped per FR24a). Status updated and logged.
|
**Deployment**
|
||||||
|
- Docker Compose with frontend and fastapi services on .201.
|
||||||
### Acceptance criteria
|
- Caddy for HTTPS and reverse proxy.
|
||||||
- End‑to‑end slab capture to published product works on modern iPhone and Android browsers.
|
|
||||||
- Manual length override correctly recalculates widths, sqft, and bdft.
|
**Security**
|
||||||
- Photo normalization produces theme‑compliant images with slab occupying ~80% frame in 95% of tests.
|
- AES encryption for stored secrets.
|
||||||
- WooCommerce product created with normalized images and correct taxonomy mapping.
|
- No app-level auth in v1.
|
||||||
- Deterministic functions covered by unit tests; inference calls logged and stored for audit.
|
- HTTPS enforced.
|
||||||
|
|
||||||
## Operational and debugging notes
|
### Pricing rule schema
|
||||||
- Local dev: Docker Compose with `frontend` and `fastapi`. Mobile testing over LAN/Tailscale against the Caddy-served domain (no public tunnel needed).
|
id: uuid
|
||||||
- Logging: centralized logs per service; store inference request/response payloads for audit.
|
species: string
|
||||||
- Testing: unit tests for deterministic math and image processing; integration tests for WooCommerce sync against a staging store.
|
wood_category: string
|
||||||
- Secrets: store App Passwords and OpenAI keys encrypted; rotate keys periodically.
|
price_per_bdft: float
|
||||||
- Monitoring: health endpoints and job queue visibility for image normalization and publish tasks.
|
created_at: timestamp
|
||||||
|
updated_at: timestamp
|
||||||
## Next recommended deliverables
|
|
||||||
1. Technical spec for the deterministic image pipeline and ruler→inch math (algorithms and test vectors).
|
## Key flows and acceptance criteria
|
||||||
2. OpenAPI contract between SvelteKit client and FastAPI.
|
|
||||||
3. (Disregarded — this is a Hermes project, not bolt.diy.)
|
### Capture -> Calibrate -> Infer -> Publish flow
|
||||||
4. Deployment playbook (Docker Compose, Caddy, backup, key rotation).
|
1. New slab: user stages slab and opens app.
|
||||||
|
2. Capture: take 1-5 inventory photos.
|
||||||
|
3. Enter metadata: SKU, length, thickness.
|
||||||
|
4. Calibration: server processes first photo (BG removal, area, bdft),
|
||||||
|
processes all photos (BG removal, crop, PNG).
|
||||||
|
5. Inference: Call 1 auto-runs (vision), populating taxonomy/content.
|
||||||
|
User reviews/corrects. Call 2 user-triggered (text), generating
|
||||||
|
title/description/short description.
|
||||||
|
6. Review: user edits/overrides any field.
|
||||||
|
7. Publish: user triggers publish; FastAPI syncs taxonomy, creates
|
||||||
|
WooCommerce product, purges images. Status updated and logged.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
- End-to-end slab capture to published product works on modern iPhone and Android browsers.
|
||||||
|
- Area measurement accuracy within +/- 0.25 in^2.
|
||||||
|
- Photo normalization produces 80% frame fill, transparent PNG, 1600px min on smaller dim.
|
||||||
|
- WooCommerce product created with normalized images, correct taxonomy, and correct content.
|
||||||
|
- Deterministic functions covered by unit tests; inference calls logged.
|
||||||
|
|
||||||
|
## Operational and debugging notes
|
||||||
|
- Local dev: Docker Compose with frontend and fastapi. Mobile testing over LAN/Tailscale.
|
||||||
|
- Logging: centralized logs per service; inference request/response payloads stored for audit.
|
||||||
|
- Testing: unit tests for deterministic math and image processing; integration tests
|
||||||
|
for WooCommerce sync against a staging store.
|
||||||
|
- Secrets: store App Passwords and inference keys encrypted; rotate keys periodically.
|
||||||
|
- Monitoring: health endpoints and job queue visibility.
|
||||||
|
|||||||
+117
-154
@@ -1,174 +1,137 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
Status: SPEC · 2026-08-24 · SlabUploader
|
Status: SPEC · 2026-08-27 · SlabUploader
|
||||||
Companion to: PRD (root), TECH-SPEC-PIPELINE.md, DATA-MODEL.md, OPENAPI.md, DEPLOYMENT.md.
|
Locked so far: topology C (hybrid), swimlane, what-runs-where. Remaining sections TBD.
|
||||||
|
|
||||||
## 1. Service topology (2 containers + 1 reverse proxy)
|
Companion: PRD (root), TECH-SPEC-PIPELINE.md.
|
||||||
|
|
||||||
Per Decision 11 the original Node service is folded into FastAPI. Final v1 topology:
|
## 1. Topology (hybrid)
|
||||||
|
|
||||||
|
Phone does the happy-path pipeline (mask, length axis, sqft/bdft, 3:4 PNG).
|
||||||
|
FastAPI stores drafts, runs U2Net only on demand, proxies inference, talks to Woo.
|
||||||
|
reverse proxy terminates HTTPS.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────┐
|
phone (SvelteKit)
|
||||||
mobile (PWA) │ .201 host │
|
capture, mask, sliders, length axis, sqft/bdft, 3:4 PNG
|
||||||
┌──────────────┐ HTTPS │ ┌─────────┐ ┌──────────────────┐ │
|
if mask still bad → POST one photo → FastAPI U2Net → mask back → user continues
|
||||||
│ SvelteKit │───────┼─▶│ Caddy │───────▶│ frontend (nginx │ │
|
|
||||||
│ browser │ │ │ :443 │ :5173 │ static + /api │ │
|
server
|
||||||
│ IndexedDB │ │ │ slab.* │ ─────▶│ proxy) │ │
|
reverse proxy → frontend (static)
|
||||||
│ camera API │ │ └─────────┘ └──────────────────┘ │
|
→ fastapi : store, settings, prompts, taxonomy, Woo, inference proxy, U2Net
|
||||||
└──────────────┘ │ │ /api → :8000 │
|
|
||||||
│ (offline: │ ▼ │
|
|
||||||
all local) │ ┌──────────────────┐ │
|
|
||||||
│ │ fastapi │ │
|
|
||||||
│ │ :8000 │ │
|
|
||||||
│ │ ┌────────────┐ │ │
|
|
||||||
│ │ │ pipeline │ │ │
|
|
||||||
│ │ │ (determin. │ │ │
|
|
||||||
│ │ └────────────┘ │ │
|
|
||||||
│ │ ┌────────────┐ │ │
|
|
||||||
│ │ │ woo client │ │ │
|
|
||||||
│ │ └────────────┘ │ │
|
|
||||||
│ │ ┌────────────┐ │ │
|
|
||||||
│ │ │ inference │ │ │
|
|
||||||
│ │ └────────────┘ │ │
|
|
||||||
│ └───────┬──────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ SQLite + image vols (named volumes)│
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
│ (configurable OpenAI-compat
|
|
||||||
▼ vision endpoint, e.g. .202)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **frontend** — SvelteKit (SSG/SPA mode) + Tailwind. Built to static, served by an
|
- Happy path never leaves the phone.
|
||||||
nginx container. Pure client app; talks to `/api` on the same origin. No build-time
|
- U2Net is explicit “Try harder” (or coverage failure), not every slab.
|
||||||
secrets. This is the PWA (SW + IndexedDB).
|
- Woo + LLM keys stay on the server. Browser does not call Woo or the vision endpoint directly.
|
||||||
- **fastapi** — one Python service owning: deterministic image pipeline, bdft math,
|
|
||||||
pricing engine, WooCommerce REST client, taxonomy sync, inference wrapper,
|
|
||||||
content generation. SQLite + image storage in named volumes.
|
|
||||||
- **caddy** — reverse proxy + automatic HTTPS. Fronts `frontend` and routes `/api`
|
|
||||||
to `fastapi`. No separate Traefik/systemd — compose is the supervisor (Decision 13).
|
|
||||||
|
|
||||||
No message broker in v1. Long jobs (normalization, publish) run in-process via a
|
## 2. Swimlane
|
||||||
small background worker (FastAPI `BackgroundTasks` + a lightweight in-process queue,
|
|
||||||
or `arq` on SQLite/Redis only if needed). See §6.
|
|
||||||
|
|
||||||
## 2. Client/server split (authoritative vs hint)
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Phone["Phone — SvelteKit"]
|
||||||
|
P1[New Wood: 1–5 photos]
|
||||||
|
P2[BG overlay + sheet / sensitivity / edge / feather]
|
||||||
|
P3{Edge OK?}
|
||||||
|
P4[Length axis overlay — rotate / confirm]
|
||||||
|
P5[User length + thickness + SKU]
|
||||||
|
P6[sqft, bdft, 6in widths, 3:4 PNGs]
|
||||||
|
P7[Review Call 1 fields]
|
||||||
|
P8[Tap Generate text]
|
||||||
|
P9[Review title / desc — publish]
|
||||||
|
end
|
||||||
|
|
||||||
Per TECH-SPEC §1.5: the client runs a *light* pass for instant feedback; the server
|
subgraph API["server — FastAPI"]
|
||||||
runs the *authoritative* pass. Stored and published values are always the server's.
|
A1[U2Net: one photo in, mask out]
|
||||||
|
A2[Store draft: originals + PNGs + numbers]
|
||||||
|
A3[Call 1 proxy — vision]
|
||||||
|
A4[Call 2 proxy — text]
|
||||||
|
A5[Woo: taxonomy, SKU check, create product]
|
||||||
|
end
|
||||||
|
|
||||||
| Capability | Client (hint) | Server (authoritative) |
|
subgraph LLM["Your OpenAI-compatible endpoint"]
|
||||||
|---|---|---|
|
L1[Wood expert — all photos at 1024]
|
||||||
| SKU OCR | Tesseract.js (accept/manual) | re-run; on mismatch surface both, user picks |
|
L2[Title / desc / short desc]
|
||||||
| Ruler scale | OpenCV.js quick check | full tick detection + cross-check |
|
end
|
||||||
| Widths / bdft | none (needs server geometry) | full pipeline |
|
|
||||||
| Green removal / crop | preview only | full normalization |
|
|
||||||
| Species/character | none | inference (vision) + manual |
|
|
||||||
| Pricing | none | pricing engine |
|
|
||||||
| Content (title/desc) | none | templates + optional LLM |
|
|
||||||
|
|
||||||
The client never publishes. Only the server writes to WooCommerce (FR24/FR27).
|
subgraph Woo["WooCommerce store"]
|
||||||
|
W1[Categories / attributes / fig-* tags]
|
||||||
|
W2[New product live or draft per setting]
|
||||||
|
end
|
||||||
|
|
||||||
## 3. Offline-first PWA (Decision: full offline per PRD)
|
P1 --> P2 --> P3
|
||||||
|
P3 -->|no — Try harder| A1
|
||||||
### 3.1 What lives on the client (IndexedDB)
|
A1 --> P2
|
||||||
- **Draft slabs**: full metadata (all review-screen fields) + photo blobs
|
P3 -->|no — Retake| P1
|
||||||
(originals). Calibration photos included. IndexedDB object stores:
|
P3 -->|yes| P4 --> P5 --> P6
|
||||||
`slabs`, `photos` (blob), `syncQueue`.
|
P6 --> A2
|
||||||
- **Service Worker**: precache app shell (SvelteKit build). API is *not* cached —
|
A2 --> A3 --> L1 --> A3 --> P7
|
||||||
network-first for API, but the capture flow itself needs no network.
|
P7 --> P8 --> A4 --> L2 --> A4 --> P9
|
||||||
|
P9 --> A5
|
||||||
### 3.2 Capture → queue → sync flow
|
A5 --> W1
|
||||||
1. Capture is fully offline: photos → IndexedDB, OCR/ruler hints run locally.
|
A5 --> W2
|
||||||
2. Draft persists to IndexedDB after every step (no "save" button — always saved).
|
|
||||||
3. **Upload queue**: when online, the PWA pushes pending drafts (metadata + photo
|
|
||||||
blobs) to `POST /api/slabs/{id}/upload`. Server runs the authoritative pipeline,
|
|
||||||
returns the enriched draft. Client merges enriched values, marks synced.
|
|
||||||
4. **Publish** is always an online action (needs Woo). If offline, the slab stays
|
|
||||||
`ready`; publish is enabled when connectivity returns.
|
|
||||||
5. Reconciliation: each draft carries a `client_rev` (monotonic int). Server rejects
|
|
||||||
an upload whose `client_rev` < the stored rev for that slab (stale). Conflict
|
|
||||||
surfaces in UI for the user to resolve. v1 has a single owner, so conflicts are
|
|
||||||
rare; the mechanism is there for correctness.
|
|
||||||
|
|
||||||
### 3.3 Connectivity detection
|
|
||||||
`navigator.onLine` + a lightweight `GET /api/health` probe. UI shows offline banner
|
|
||||||
and disables publish. No hard dependency on either — capture works regardless.
|
|
||||||
|
|
||||||
## 4. Data flow (happy path)
|
|
||||||
|
|
||||||
```
|
|
||||||
[client] capture (offline)
|
|
||||||
→ OCR SKU (hint) / ruler (hint) / 1–5 photos → IndexedDB draft (rev=1)
|
|
||||||
[online] PWA → POST /api/slabs {draft, rev=1}
|
|
||||||
POST /api/slabs/{id}/upload {photo blobs}
|
|
||||||
[server] authoritative pipeline:
|
|
||||||
ruler→scale → geometry → widths → bdft → species/char (inference, opt)
|
|
||||||
→ pricing → normalization → content (opt LLM)
|
|
||||||
→ store enriched draft (status=review, rev=2), store original+normalized images
|
|
||||||
[client] GET /api/slabs/{id} → review screen (all fields editable)
|
|
||||||
[client] PUT /api/slabs/{id} {overrides, rev=2}
|
|
||||||
[client] POST /api/slabs/{id}/publish
|
|
||||||
[server] taxonomy sync (if needed) → Woo create (draft/pending) → store woo_product_id
|
|
||||||
→ status=published → purge images (Decision 12)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Determinism boundary
|
## 3. Clarifications
|
||||||
|
|
||||||
Everything left of the "inference" and "content" boxes is deterministic and unit-
|
1. **Happy path** stays on the phone through PNG + numbers. FastAPI is idle until the user has a confirmed mask (or taps Try harder).
|
||||||
tested (PRD "Deterministic core"). The two inference-touching paths are:
|
2. **Try harder** is U2Net on **one photo** (the one on screen). Mask comes back; sliders still apply. Not a full re-pipeline on the server.
|
||||||
- species/character detection (vision) — toggleable, returns confidence
|
3. **Draft upload** sends **originals + processed PNGs + length/thickness/SKU/sqft/bdft/widths**. Originals are needed if Call 1 or a later retry must not depend on the tab still being open. After successful Woo publish, server deletes both.
|
||||||
- optional content refinement (text LLM) — toggleable
|
4. **Call 1** is server-side so the vision key never sits in the browser. Body: all originals downscaled to 1024 + Woo taxonomy snapshot + SKU/length/thickness. Timeout 45s → error + retry on the phone. User **must** curate before Call 2.
|
||||||
|
5. **Call 2** does not auto-fire. User taps Generate text. Server sends curated Call 1 + deterministic numbers + brand/GEO + prompt.
|
||||||
|
6. **Woo** is only FastAPI. Browser never sees the App Password. Duplicate SKU → stop, show error, no edit-same-SKU in MVP.
|
||||||
|
7. **Settings** (species $/bdft, prompts, endpoint, Woo, publish status) live on the server; the phone is just the form.
|
||||||
|
|
||||||
Both are wrapped so that toggling them OFF leaves a fully functional deterministic
|
## 4. What runs where
|
||||||
app. The app must pass its entire test suite with inference disabled.
|
|
||||||
|
|
||||||
## 6. Concurrency & jobs
|
| Job | Where |
|
||||||
|
|
||||||
- v1 is single-owner, single-slab workflow (no bulk). Concurrency is minimal.
|
|
||||||
- Long ops (normalization ~3–5s, publish with image upload) use FastAPI
|
|
||||||
`BackgroundTasks` + a per-slab status field (`processing`/`ready`/`publishing`/
|
|
||||||
`published`/`failed`). Client polls `GET /api/slabs/{id}` (short, exponential backoff).
|
|
||||||
- If a job dies, the slab is `failed` with a reason; re-publish is idempotent by
|
|
||||||
SKU-dedupe (FR24a) — but v1 has no update path, so a failed publish that already
|
|
||||||
created the Woo product is surfaced for manual handling (see IMPL-PLAN risk).
|
|
||||||
- No external broker (Redis) in v1. If the in-process queue proves insufficient,
|
|
||||||
add `arq` + a single Redis container — a documented, reversible extension.
|
|
||||||
|
|
||||||
## 7. Security (Decision 10, PRD Security)
|
|
||||||
|
|
||||||
- No app-level auth. App is reachable only on the trusted LAN/Tailscale
|
|
||||||
(domain `slab.tyubumini.local`). HTTPS enforced by Caddy.
|
|
||||||
- Secrets (Woo consumer key/secret, inference key, AES master key) stored encrypted
|
|
||||||
at rest (AES-GCM) in SQLite; the AES key itself is an env var injected by compose,
|
|
||||||
never in the DB. Config is editable via the Settings UI (FR32) but values are
|
|
||||||
re-encrypted on save.
|
|
||||||
- Client stores no persistent secrets. The PWA needs none — all secrets server-side.
|
|
||||||
- All client↔server traffic HTTPS.
|
|
||||||
|
|
||||||
## 8. Deployment target
|
|
||||||
|
|
||||||
- Host: **.201** (no inference there; inference is a remote configurable endpoint).
|
|
||||||
- Stack: Docker Compose (`frontend`, `fastapi`) + Caddy (container or host-managed —
|
|
||||||
see DEPLOYMENT.md). Domain: `slab.tyubumini.local` (Decision: LAN/Tailscale only).
|
|
||||||
- Named volumes: `slab_db` (SQLite), `slab_images` (original+normalized). Backed up
|
|
||||||
per DEPLOYMENT.md.
|
|
||||||
|
|
||||||
## 9. Extensibility (documented, not built)
|
|
||||||
|
|
||||||
- Postgres swap for SQLite (DATA-MODEL keeps the schema portable).
|
|
||||||
- Redis/arq if job volume grows.
|
|
||||||
- Update path for existing Woo products (v2; v1 is create-only by SKU dedupe).
|
|
||||||
- Multi-slab bulk (explicit non-goal v1).
|
|
||||||
- Staff accounts/roles (explicit non-goal v1).
|
|
||||||
|
|
||||||
## 10. Tech stack (pinned intent — exact versions at scaffold)
|
|
||||||
|
|
||||||
| Layer | Choice |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Client | SvelteKit, TailwindCSS, Tesseract.js, OpenCV.js, IndexedDB, Camera API, Workbox/SW |
|
| Camera, 1-5 photos | Client |
|
||||||
| Server | Python 3.12, FastAPI, Pydantic v2, OpenCV (cv2), numpy, Pillow, RemBG/U²Net (onnx), httpx |
|
| Sheet detect, chroma-key / black threshold, sliders, flood-fill | Client |
|
||||||
| Woo | WooCommerce REST v3 via httpx (consumer key/secret, Basic auth) |
|
| U2Net | Server, on demand |
|
||||||
| DB | SQLite (stdlib `sqlite3` or SQLAlchemy; see DATA-MODEL) |
|
| Length axis overlay, 6" widths, sqft, bdft | Client |
|
||||||
| Inference | OpenAI-compatible client (configurable base_url/key/model; must be vision-capable) |
|
| 3:4 PNG, 80% fill | Client |
|
||||||
| Proxy/HTTPS | Caddy |
|
| Draft + originals + processed PNGs | Server (after user continues) |
|
||||||
| Packaging | Docker Compose |
|
| Species $/bdft, Woo creds, prompts, brand/GEO | Server |
|
||||||
|
| Call 1 (vision, all photos @1024) | Server proxy |
|
||||||
|
| Call 2 (title/desc) | Server proxy, after Call 1 curation |
|
||||||
|
| Woo taxonomy sync + publish | Server |
|
||||||
|
|
||||||
|
## 5. Secrets and settings
|
||||||
|
|
||||||
|
**Server-side only (never in browser JS):**
|
||||||
|
1. WooCommerce consumer key + consumer secret (App Password). Server uses HTTP Basic Auth to call `/wp-json/wc/v3/`. Stored encrypted at rest in SQLite (AES-GCM).
|
||||||
|
2. Inference endpoint base URL + API key. Stored encrypted. Server proxies all inference calls.
|
||||||
|
3. AES master key. Env var on server, never in the DB.
|
||||||
|
|
||||||
|
**Browser-side (no secrets):**
|
||||||
|
1. User preferences: sheet mode, sensitivity, edge offset, feather. Persisted in localStorage, reset-to-default available.
|
||||||
|
2. Species list with $/bdft. Read from server settings sync.
|
||||||
|
3. Taxonomy snapshot (categories, attributes, tags). Read from server. No auth needed.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User enters Woo credentials in Settings UI on the phone.
|
||||||
|
2. Browser POSTs them to `/api/settings` on FastAPI. Server stores encrypted.
|
||||||
|
3. Server syncs taxonomy from Woo, caches in SQLite.
|
||||||
|
4. Client reads taxonomy from `/api/admin/taxonomy` — no auth needed.
|
||||||
|
5. All Woo calls go server-to-woo. Browser never calls Woo REST directly.
|
||||||
|
|
||||||
|
This is the canonical WooCommerce App Password integration pattern. Browser-to-Woo REST requires CORS configuration on the Woo site, which is not under your control.
|
||||||
|
|
||||||
|
## 6. Status machine
|
||||||
|
|
||||||
|
```
|
||||||
|
draft → calibrated → ready → publishing → published
|
||||||
|
│ │ ↑
|
||||||
|
└→ failed ────┴────────────────┘ (woo_product_id set)
|
||||||
|
any → failed (with error)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **draft** — user took photos, no mask confirmed yet.
|
||||||
|
2. **calibrated** — user confirmed the BG edge, confirmed length axis, entered length/thickness/SKU. Client computed sqft/bdft/widths. Draft uploaded to server.
|
||||||
|
3. **ready** — all mandatory fields populated (species, wood category, edge type, figure, grade, thickness, price, title/desc/short desc, at least one photo). Values can come from Call 1, manual entry, or a mix. Inference is optional; the user can fill everything by hand and go straight to ready.
|
||||||
|
4. **publishing** — Woo create in flight. Poll for result.
|
||||||
|
5. **published** — Woo product created, woo_product_id stored. Images purged.
|
||||||
|
6. **failed** — pipeline, inference, or publish error. Error detail stored. Retry available.
|
||||||
|
|
||||||
|
Call 1 and Call 2 are assist-only. They do not gate the flow. If the user skips inference, the path is: calibrated → ready → publishing → published.
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
# Content Generation & WooCommerce Integration
|
|
||||||
|
|
||||||
Status: SPEC · 2026-08-24 · SlabUploader
|
|
||||||
Covers: FR16 (deterministic content templates + optional LLM refinement), FR17
|
|
||||||
(brand voice / GEO), FR18 (editable, never auto-published), FR25–28 (taxonomy sync,
|
|
||||||
product create, sync log), and the exact WooCommerce REST v3 payload.
|
|
||||||
|
|
||||||
## 1. Content generation (FR16–18)
|
|
||||||
|
|
||||||
### 1.1 Deterministic templates (always on)
|
|
||||||
Templates are configured in Settings (`content_templates`, editable per FR32).
|
|
||||||
Defaults below. Placeholders use `{field}`.
|
|
||||||
|
|
||||||
**title** (default):
|
|
||||||
```
|
|
||||||
{species} {length_ft}ft {length_in_remainder}in × {width_avg_in}in Wood Slab — {character}
|
|
||||||
```
|
|
||||||
Example: `Black Walnut 16ft 0in × 18.2in Wood Slab — Cathedral`
|
|
||||||
- `length_ft` = floor(length_in / 12), `length_in_remainder` = length_in mod 12 (1 dp).
|
|
||||||
|
|
||||||
**short_title** (default):
|
|
||||||
```
|
|
||||||
{species} Slab — {character}
|
|
||||||
```
|
|
||||||
Example: `Black Walnut Slab — Cathedral`
|
|
||||||
|
|
||||||
**description** (default, multi-paragraph):
|
|
||||||
```
|
|
||||||
A one-of-a-one {character} {species} slab, {length_ft}ft {length_in_remainder}in long with an
|
|
||||||
average width of {width_avg_in}in and {thickness_in}in thickness ({bdft} bdft).
|
|
||||||
|
|
||||||
Each slab is naturally shaped — width varies along the length at {width_min_in}in to
|
|
||||||
{width_max_in}in. Sold by the slab; dimensions as measured. {geo_sentence}
|
|
||||||
```
|
|
||||||
`geo_sentence` = GEO context sentence from Settings (FR17), e.g.
|
|
||||||
`"Locally milled and shipped from the Pacific Northwest."` (default if unset: none).
|
|
||||||
|
|
||||||
All template math fields come from the deterministic pipeline (never from the LLM).
|
|
||||||
|
|
||||||
### 1.2 Optional LLM refinement (FR17, toggleable)
|
|
||||||
- Triggered only if `content_llm_enabled` is true AND the user taps "Refine with AI"
|
|
||||||
on the review screen (never automatic — FR18).
|
|
||||||
- Prompt (server-side, assembled): system = brand voice + GEO context; user = the
|
|
||||||
template-generated title/description + slab facts; task = "improve the title and
|
|
||||||
description for a premium one-of-a-one wood slab listing. Keep all measurements
|
|
||||||
exact. Do not invent features. Return JSON: {title, short_title, description}."
|
|
||||||
- **Guardrail (deterministic)**: after the LLM returns, the server re-checks that every
|
|
||||||
numeric dimension in the LLM text still matches the pipeline values (regex on
|
|
||||||
numbers). Mismatch → reject the LLM output, keep the template version, log the
|
|
||||||
rejection. This keeps measurement accuracy deterministic even when the LLM is on.
|
|
||||||
- Output is always editable (FR18); `content_source` = `llm` when used.
|
|
||||||
- If the inference endpoint is text-only or unreachable → the button is disabled,
|
|
||||||
templates still work. No failure mode blocks publish.
|
|
||||||
|
|
||||||
### 1.3 Audit
|
|
||||||
Every content generation (template or LLM) is logged with inputs (template version,
|
|
||||||
slab facts), the LLM request/response payload when used (PRD "inference calls logged"),
|
|
||||||
and the final stored content.
|
|
||||||
|
|
||||||
## 2. WooCommerce integration (FR24–28)
|
|
||||||
|
|
||||||
### 2.1 Auth & client
|
|
||||||
- REST v3: `GET/POST/PATCH/DELETE {woo_base_url}/wp-json/wc/v3/…`
|
|
||||||
- Basic auth: `woo_consumer_key:woo_consumer_secret` (Decision 10; the "username" is
|
|
||||||
the consumer key, the "app password" is the consumer secret).
|
|
||||||
- Client: `httpx` with timeouts (connect 10s, read 60s for image-heavy create).
|
|
||||||
- `woo_base_url` default: `https://www.whidbeywoodstore.com`.
|
|
||||||
|
|
||||||
### 2.2 Taxonomy sync (FR25)
|
|
||||||
`POST /api/admin/taxonomy/sync` pulls, in order:
|
|
||||||
1. `GET /products/categories?per_page=100` (paginate) → `woo_taxonomy(kind=category)`
|
|
||||||
2. `GET /products/tags?per_page=100` → `kind=tag`
|
|
||||||
3. `GET /products/attributes` → `kind=attribute`
|
|
||||||
Rebuilds the cache table (delete + insert, single transaction). Returns counts.
|
|
||||||
Failure → `502 woo_error`; previous cache retained (never delete-then-fail).
|
|
||||||
|
|
||||||
### 2.3 Auto-assign taxonomy (FR26)
|
|
||||||
- **Category**: species value is matched to a Woo category by name (case-insensitive,
|
|
||||||
trimmed). Match → that category id. No match → the slab's `category_override`
|
|
||||||
(user picks in review) or empty (Woo required-field validation will surface it).
|
|
||||||
- **Tags**: `[character]` plus optional `[species]` if present as a tag.
|
|
||||||
- **Attributes**: species/character/length/thickness as product attributes if the
|
|
||||||
corresponding Woo attributes exist in the cache (by name). Missing attributes are
|
|
||||||
skipped, not created (v1: no attribute creation).
|
|
||||||
- Every assignment is overridable in the review screen (FR26).
|
|
||||||
|
|
||||||
### 2.4 Product create payload (FR27)
|
|
||||||
`POST /products` — body (only non-null fields sent):
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"name": "{title}",
|
|
||||||
"type": "simple",
|
|
||||||
"status": "pending", // test-target decision: NEVER "publish" automatically
|
|
||||||
"sku": "{sku}",
|
|
||||||
"regular_price": "{price}", // 2 dp string, e.g. "2730.00"
|
|
||||||
"description": "{description}",
|
|
||||||
"short_description": "{short_title}",
|
|
||||||
"categories": [ { "id": <species category id> } ],
|
|
||||||
"tags": [ { "id": <char tag id> }, … ],
|
|
||||||
"images": [
|
|
||||||
{ "src": "<data URL or pre-uploaded attachment URL>", "alt": "{species} {character} slab" }
|
|
||||||
],
|
|
||||||
"attributes": [ … if present … ],
|
|
||||||
"virtual": false,
|
|
||||||
"downloadable": false,
|
|
||||||
"catalog_visibility": "visible"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Image upload strategy** (Woo v3): `images[].src` accepts a URL or a data URL.
|
|
||||||
For reliability the server:
|
|
||||||
1. For each customer-facing (inventory, normalized) photo in `seq` order:
|
|
||||||
`POST /media` with the JPEG bytes → get attachment `source_url`.
|
|
||||||
2. Reference those `source_url` values in `images` (≤5, per PRD 1–5 inventory photos).
|
|
||||||
3. Calibration photos are excluded (FR2).
|
|
||||||
|
|
||||||
**Status = `pending`** by default (test-target decision: test products must never go
|
|
||||||
live without human action). The Settings key `woo_publish_status` (default `pending`)
|
|
||||||
controls this; Ty flips it to `publish` only when intentionally going live.
|
|
||||||
|
|
||||||
### 2.5 Dedupe & idempotency (FR24a)
|
|
||||||
Before create: `GET /products?sku={sku}`.
|
|
||||||
- Found → `409 duplicate_sku` (v1 has no update path; human resolves).
|
|
||||||
- Not found → proceed. (Single-owner, slab-by-slab, so no TOCTOU concern in practice;
|
|
||||||
the check is still done for correctness.)
|
|
||||||
|
|
||||||
### 2.6 Sync result & logging (FR28)
|
|
||||||
- On success: set `slabs.woo_product_id`, `status=published`, `published_at`, write
|
|
||||||
`sync_log(status=success, woo_product_id, payload_hash)`.
|
|
||||||
- On Woo HTTP error: `status=failed`, `sync_log(status=failed, http_status, error,
|
|
||||||
detail=<Woo error body>)`. Surface in UI + poll response.
|
|
||||||
- **Partial-failure note** (risk, see IMPL-PLAN): if media uploads succeed but the
|
|
||||||
final product POST fails, orphaned Woo attachments may exist. v1: log it; a manual
|
|
||||||
cleanup note is surfaced. Not auto-deleted (could delete a real image).
|
|
||||||
|
|
||||||
## 3. End-to-end publish sequence (server side)
|
|
||||||
```
|
|
||||||
1. validate slab publishable (length,width,thickness,price,species,char,≥1 photo)
|
|
||||||
2. dedupe by SKU (GET /products?sku=) → 409 duplicate_sku if exists
|
|
||||||
3. (re)sync taxonomy if cache older than 1h → best-effort; use existing cache on fail
|
|
||||||
4. resolve category/tags/attributes (auto-assign + overrides)
|
|
||||||
5. upload normalized inventory images → media → source_urls
|
|
||||||
6. POST /products (status=pending) → woo_product_id
|
|
||||||
7. write sync_log(success), set slab published, purge images
|
|
||||||
on any failure at 2–6: write sync_log(failed), slab=failed, (images retained for retry)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. What is deterministic vs scoped-inference here
|
|
||||||
- Deterministic: templates, all dimension text, category/tag/attribute resolution,
|
|
||||||
payload assembly, dedupe, logging.
|
|
||||||
- Scoped inference: optional LLM title/description refinement only, with the
|
|
||||||
numeric-accuracy guardrail (§1.2).
|
|
||||||
+212
-158
@@ -1,166 +1,220 @@
|
|||||||
# Data Model (SQLite, v1)
|
# Data Model
|
||||||
|
|
||||||
Status: SPEC · 2026-08-24 · SlabUploader
|
Status: SPEC · 2026-08-27 · SlabUploader
|
||||||
Storage: single SQLite file (volume `slab_db`). Schema kept portable to Postgres
|
Storage: single SQLite file. Schema portable to Postgres.
|
||||||
(DATETIME→TIMESTAMPTZ, TEXT blobs→bytea/objects). All math values stored full
|
|
||||||
precision (float64); display rounding is a presentation concern (TECH-SPEC §2).
|
|
||||||
|
|
||||||
## Conventions
|
Conventions:
|
||||||
- IDs: `TEXT` UUIDv4 (client-generated for slabs so the PWA can reference a slab
|
- IDs: client-generated UUIDv4 for slabs so the PWA can reference a slab before it exists server-side.
|
||||||
before it exists server-side).
|
- Timestamps: UTC ISO-8601 TEXT.
|
||||||
- Timestamps: UTC ISO-8601 `TEXT` (`YYYY-MM-DDTHH:MM:SSZ`).
|
- Soft state via status column; no hard deletes for slabs (audit).
|
||||||
- Soft state via `status` column; no hard deletes for slabs (audit). Images purged
|
- Money: float64 USD, rounded half-up to cents at write.
|
||||||
on publish (Decision 12) but the slab row + metadata retained.
|
- All rounded values are what gets stored. No full-precision internal storage.
|
||||||
- Money stored as float64 USD, rounded half-up to cents at write (TECH-SPEC §2).
|
- Inches stored as 1/8" increments. Sqft/bdft to 2 decimals.
|
||||||
- `client_rev` / `server_rev`: monotonic ints for optimistic concurrency (ARCH §3.2).
|
- Multi-select fields stored as JSON arrays of Woo IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## slabs
|
||||||
|
|
||||||
|
Draft-only. Deleted on successful publish. WooCommerce is the source of truth for product data.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | TEXT PK | client-generated UUID |
|
||||||
|
| `sku` | TEXT UNIQUE NOT NULL | 3-24 [A-Z0-9-] |
|
||||||
|
| `status` | TEXT NOT NULL | draft \| calibrated \| ready \| publishing \| published \| failed |
|
||||||
|
| `length_in` | REAL NOT NULL | 1/8" step |
|
||||||
|
| `thickness_in` | REAL NOT NULL | 1/8" step |
|
||||||
|
| `sqft` | REAL | 2 decimals |
|
||||||
|
| `bdft` | REAL | 2 decimals |
|
||||||
|
| `width_min_in` | REAL | min width at 6" stations, 2 decimals |
|
||||||
|
| `width_max_in` | REAL | max width at 6" stations, 2 decimals |
|
||||||
|
| `width_avg_in` | REAL | avg width at 6" stations, 2 decimals |
|
||||||
|
| `species_id` | INTEGER | → woo_categories (leaf only, 1 required) |
|
||||||
|
| `wood_category_ids` | INTEGER[] JSON | → woo_categories (leaf, 1+) |
|
||||||
|
| `edge_type_term_id` | INTEGER | → woo_attribute_terms (1 required) |
|
||||||
|
| `figure_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
|
||||||
|
| `grade_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
|
||||||
|
| `thickness_term_id` | INTEGER | → woo_attribute_terms (round-up) |
|
||||||
|
| `moisture_term_id` | INTEGER | → woo_attribute_terms (1 required, defaults to kiln-dried term; user overrides; not inferred) |
|
||||||
|
| `fig_tag_ids` | INTEGER[] JSON | → woo_tags (fig-*, 1+) |
|
||||||
|
| `price_per_bdft` | REAL | from pricing_rules for species |
|
||||||
|
| `price` | REAL NOT NULL | 2 decimals |
|
||||||
|
| `price_source` | TEXT | 'recommendation' \| 'override' |
|
||||||
|
| `title` | TEXT | |
|
||||||
|
| `short_title` | TEXT | |
|
||||||
|
| `description` | TEXT | |
|
||||||
|
| `content_source` | TEXT | 'manual' \| 'llm' |
|
||||||
|
| `client_rev` | INTEGER NOT NULL DEFAULT 1 | |
|
||||||
|
| `server_rev` | INTEGER NOT NULL DEFAULT 1 | |
|
||||||
|
| `created_at` | TEXT NOT NULL | |
|
||||||
|
| `updated_at` | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
Indexes: `sku` (unique), `status`, `updated_at`.
|
||||||
|
|
||||||
|
### Status state machine
|
||||||
|
|
||||||
## ERD (text)
|
|
||||||
```
|
```
|
||||||
slabs 1─N slab_photos
|
draft → calibrated → ready → publishing → published
|
||||||
slabs 1─0..1 sync_log (latest) [sync_log is an append-only history; 1 per publish attempt]
|
│ │ ↑
|
||||||
pricing_rules N─1 (species, character) [unique pair]
|
└→ failed ────┴────────────────┘ (woo_product_id in sync_log)
|
||||||
woo_taxonomy (cache) categories / tags / attributes
|
|
||||||
settings 1 row (key→encrypted value)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tables
|
|
||||||
|
|
||||||
### slabs
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | TEXT PK | client-generated UUID |
|
|
||||||
| sku | TEXT UNIQUE NOT NULL | from OCR or manual; dedupe key (FR24a) |
|
|
||||||
| status | TEXT NOT NULL | `draft` `processing` `ready` `review` `publishing` `published` `failed` |
|
|
||||||
| species | TEXT | controlled value (from woo_taxonomy categories) or free until confirmed |
|
|
||||||
| species_confidence | REAL NULL | from inference, if used |
|
|
||||||
| species_source | TEXT | `inference` `manual` |
|
|
||||||
| character | TEXT | controlled value |
|
|
||||||
| character_confidence | REAL NULL | |
|
|
||||||
| character_source | TEXT | `inference` `manual` |
|
|
||||||
| length_in | REAL NOT NULL | authoritative (ruler-derived or manual) |
|
|
||||||
| length_source | TEXT | `ruler` `manual` |
|
|
||||||
| width_avg_in | REAL | average of sampled widths (TECH-SPEC §5.2) |
|
|
||||||
| width_source | TEXT | `sampled` `manual` |
|
|
||||||
| thickness_in | REAL NOT NULL | always manual (FR8) |
|
|
||||||
| sqft | REAL | (L×w_avg)/144 |
|
|
||||||
| bdft | REAL | (L×w_avg×t)/12 (Decision 18) |
|
|
||||||
| price_per_bdft | REAL NULL | copied from pricing rule at compute time (snapshot) |
|
|
||||||
| price | REAL NOT NULL | final authoritative price (override or recommendation) |
|
|
||||||
| price_source | TEXT | `recommendation` `override` |
|
|
||||||
| title | TEXT | generated/edited |
|
|
||||||
| short_title | TEXT | |
|
|
||||||
| description | TEXT | |
|
|
||||||
| content_source | TEXT | `template` `llm` `manual` |
|
|
||||||
| normalized_fill_ok | INTEGER | 0/1 — met ~80% fill target (TECH-SPEC TV-6) |
|
|
||||||
| woo_product_id | INTEGER NULL | set on successful publish (FR28) |
|
|
||||||
| published_at | TEXT NULL | |
|
|
||||||
| client_rev | INTEGER NOT NULL DEFAULT 1 | |
|
|
||||||
| server_rev | INTEGER NOT NULL DEFAULT 1 | |
|
|
||||||
| created_at | TEXT NOT NULL | |
|
|
||||||
| updated_at | TEXT NOT NULL | |
|
|
||||||
|
|
||||||
Index: `sku` (unique), `status`, `updated_at`.
|
|
||||||
|
|
||||||
### slab_photos
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | TEXT PK | |
|
|
||||||
| slab_id | TEXT FK→slabs.id ON DELETE CASCADE | |
|
|
||||||
| kind | TEXT NOT NULL | `calibration_sku` `calibration_ruler` `inventory` |
|
|
||||||
| role | TEXT | for inventory: `topdown` (the width-sampling photo) or `extra` |
|
|
||||||
| seq | INTEGER | display order (FR23 reorder) |
|
|
||||||
| original_path | TEXT | relative to images volume |
|
|
||||||
| normalized_path | TEXT NULL | set after normalization |
|
|
||||||
| is_customer_facing | INTEGER | calibration=0, inventory=1 (FR2: calibration never uploaded) |
|
|
||||||
| created_at | TEXT | |
|
|
||||||
|
|
||||||
Rule: only `kind=inventory` photos are uploaded to WooCommerce. Calibration photos
|
|
||||||
are internal-only (FR2).
|
|
||||||
|
|
||||||
### pricing_rules
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | TEXT PK | uuid (PRD pricing schema) |
|
|
||||||
| species | TEXT NOT NULL | |
|
|
||||||
| character | TEXT NOT NULL | |
|
|
||||||
| price_per_bdft | REAL NOT NULL | flat (Decision 5; no tiers) |
|
|
||||||
| created_at | TEXT | |
|
|
||||||
| updated_at | TEXT | |
|
|
||||||
|
|
||||||
`UNIQUE (species, character)`. Seeded from synced Woo categories (Decision 4);
|
|
||||||
user sets `price_per_bdft` per value in Settings (FR13).
|
|
||||||
|
|
||||||
### woo_taxonomy (cache)
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| kind | TEXT | `category` `tag` `attribute` |
|
|
||||||
| woo_id | INTEGER | Woo term id |
|
|
||||||
| name | TEXT | |
|
|
||||||
| parent_id | INTEGER NULL | categories |
|
|
||||||
| slug | TEXT | |
|
|
||||||
| synced_at | TEXT | |
|
|
||||||
|
|
||||||
`UNIQUE (kind, woo_id)`. Rebuilt by `POST /api/admin/taxonomy/sync` (FR25). This is
|
|
||||||
the controlled list for species (Decision 4 — species drawn from categories) and
|
|
||||||
the taxonomy auto-assign source (FR26).
|
|
||||||
|
|
||||||
### sync_log (append-only history)
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | INTEGER PK AUTOINCREMENT | |
|
|
||||||
| slab_id | TEXT FK | |
|
|
||||||
| attempt | INTEGER | 1,2,… per slab |
|
|
||||||
| action | TEXT | `create` |
|
|
||||||
| woo_product_id | INTEGER NULL | |
|
|
||||||
| status | TEXT | `success` `failed` `blocked_duplicate_sku` |
|
|
||||||
| http_status | INTEGER NULL | |
|
|
||||||
| error | TEXT NULL | machine-readable code |
|
|
||||||
| detail | TEXT NULL | human-readable message / Woo error body |
|
|
||||||
| payload_hash | TEXT | sha256 of the Woo product payload sent (audit, FR28) |
|
|
||||||
| created_at | TEXT | |
|
|
||||||
|
|
||||||
FR28: log sync results, surface errors, store Woo product ID on success.
|
|
||||||
|
|
||||||
### settings (key/value, encrypted values)
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| key | TEXT PK | |
|
|
||||||
| value_enc | BLOB | AES-GCM ciphertext (see Security) |
|
|
||||||
| updated_at | TEXT | |
|
|
||||||
|
|
||||||
Keys:
|
|
||||||
- `woo_base_url` (plaintext-OK, but encrypted for uniformity)
|
|
||||||
- `woo_consumer_key`, `woo_consumer_secret` (secret)
|
|
||||||
- `inference_base_url`, `inference_api_key` (secret), `inference_model`
|
|
||||||
- `inference_enabled` (bool), `content_llm_enabled` (bool)
|
|
||||||
- `brand_voice` (text), `geo_context` (text)
|
|
||||||
- `content_templates` (JSON: title/short_title/description templates, FR16)
|
|
||||||
- `aspect_ratio` = `1:1`, `output_px` = 1600, `output_px_min` = 1000 (Decision 20)
|
|
||||||
- `fill_target` = 0.80
|
|
||||||
|
|
||||||
The AES-GCM master key is an env var (`SLAB_AES_KEY`, 32 bytes base64) injected by
|
|
||||||
compose — never stored in the DB. Each `value_enc` = `nonce(12) || ciphertext`.
|
|
||||||
|
|
||||||
## Status state machine
|
|
||||||
```
|
|
||||||
draft → processing → ready → review → publishing → published
|
|
||||||
│ ↑ (woo_product_id set)
|
|
||||||
└→ failed (retry → publishing)
|
|
||||||
any → failed (with error)
|
any → failed (with error)
|
||||||
```
|
```
|
||||||
- `processing`: server pipeline running.
|
|
||||||
- `ready`: enriched, awaiting user review.
|
|
||||||
- `review`: user has opened/edited the review screen.
|
|
||||||
- `publishing`: Woo create in flight.
|
|
||||||
- `published`: Woo product created (draft/pending on the store — never live without
|
|
||||||
human action, per test-target decision).
|
|
||||||
- `failed`: pipeline or publish error; `sync_log.detail` has the reason.
|
|
||||||
|
|
||||||
## Concurrency
|
1. **draft** — photos taken, no mask confirmed.
|
||||||
- Single owner. Optimistic locking via `server_rev`: an update must send the `server_rev`
|
2. **calibrated** — BG edge confirmed, length axis confirmed, length/thickness/SKU entered. Client computed sqft/bdft/widths. Draft uploaded to server.
|
||||||
it last read; server rejects on mismatch (409) → client re-fetches. This is the
|
3. **ready** — all mandatory fields populated. Values from Call 1, manual, or mix. Inference is optional. Publishable.
|
||||||
server-side counterpart to the client `client_rev` (ARCH §3.2).
|
4. **publishing** — Woo create in flight. Poll for result.
|
||||||
|
5. **published** — Woo product created. This row + all photos deleted. sync_log retained.
|
||||||
|
6. **failed** — pipeline, inference, or publish error. Error detail stored. Retry available.
|
||||||
|
|
||||||
## Backup
|
---
|
||||||
- SQLite file + images volume are the only durable state. Backup = `sqlite3 .backup`
|
|
||||||
of the DB file (WAL-safe) + `tar` of the images volume. See DEPLOYMENT.md.
|
## slab_photos
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | TEXT PK | |
|
||||||
|
| `slab_id` | TEXT FK → slabs.id ON DELETE CASCADE | |
|
||||||
|
| `kind` | TEXT NOT NULL | 'inventory' |
|
||||||
|
| `role` | TEXT | 'topdown' \| 'extra' |
|
||||||
|
| `seq` | INTEGER | display order |
|
||||||
|
| `original_path` | TEXT | relative to images volume |
|
||||||
|
| `processed_path` | TEXT | relative to images volume |
|
||||||
|
| `created_at` | TEXT | |
|
||||||
|
|
||||||
|
Rule: only inventory photos. Calibration photos do not exist in this design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## pricing_rules
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | TEXT PK UUID | |
|
||||||
|
| `species` | TEXT NOT NULL | matches woo_categories.name (leaf) |
|
||||||
|
| `price_per_bdft` | REAL NOT NULL | flat, no tiers |
|
||||||
|
| `created_at` | TEXT | |
|
||||||
|
| `updated_at` | TEXT | |
|
||||||
|
|
||||||
|
`UNIQUE(species)`. Seeded from synced Woo categories. User sets price per species.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## woo_categories
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `woo_id` | INTEGER PK | |
|
||||||
|
| `name` | TEXT | |
|
||||||
|
| `slug` | TEXT | |
|
||||||
|
| `parent_id` | INTEGER | NULL for root |
|
||||||
|
| `synced_at` | TEXT | |
|
||||||
|
|
||||||
|
Only species + wood category. Sync pulls all Woo categories; the app filters to the two we use.
|
||||||
|
|
||||||
|
`UNIQUE(woo_id)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## woo_attributes
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `woo_id` | INTEGER PK | |
|
||||||
|
| `name` | TEXT | |
|
||||||
|
| `slug` | TEXT | |
|
||||||
|
| `type` | TEXT | 'select' |
|
||||||
|
| `synced_at` | TEXT | |
|
||||||
|
|
||||||
|
Only the five first-class attributes: Edge Type, Figure, Grade, Thickness, Moisture.
|
||||||
|
|
||||||
|
`UNIQUE(woo_id)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## woo_attribute_terms
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `woo_id` | INTEGER PK | |
|
||||||
|
| `attribute_id` | INTEGER FK → woo_attributes.woo_id | |
|
||||||
|
| `name` | TEXT | |
|
||||||
|
| `slug` | TEXT | |
|
||||||
|
| `synced_at` | TEXT | |
|
||||||
|
|
||||||
|
The actual selectable values. E.g., "Cathedral" under Figure, "1\"–1 1/2\"" under Thickness.
|
||||||
|
|
||||||
|
`UNIQUE(woo_id)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## woo_tags
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `woo_id` | INTEGER PK | |
|
||||||
|
| `name` | TEXT | |
|
||||||
|
| `slug` | TEXT | |
|
||||||
|
| `synced_at` | TEXT | |
|
||||||
|
|
||||||
|
Only `fig-*` tags. Other Woo tags ignored.
|
||||||
|
|
||||||
|
`UNIQUE(woo_id)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## settings
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `key` | TEXT PK | |
|
||||||
|
| `value_enc` | BLOB | AES-GCM ciphertext |
|
||||||
|
| `updated_at` | TEXT | |
|
||||||
|
|
||||||
|
Keys:
|
||||||
|
- `woo_base_url`, `woo_consumer_key`, `woo_consumer_secret`
|
||||||
|
- `inference_base_url`, `inference_api_key`, `inference_model`
|
||||||
|
- `inference_enabled`, `content_llm_enabled`
|
||||||
|
- `brand_voice`, `geo_context`
|
||||||
|
- `prompts` (JSON: call1_prompt, call2_prompt, reset-to-default)
|
||||||
|
- `publish_status` ('published' | 'draft')
|
||||||
|
- `aspect_ratio` ('3:4'), `output_px` (1600), `output_px_min` (1600), `fill_target` (0.80)
|
||||||
|
- `user_sensitivity`, `user_edge_offset`, `user_feather`, `user_sheet_mode`
|
||||||
|
|
||||||
|
AES master key is an env var (`SLAB_AES_KEY`, 32 bytes base64). Never in the DB.
|
||||||
|
Each `value_enc` = `nonce(12) || ciphertext`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## sync_log
|
||||||
|
|
||||||
|
Append-only history. Not deleted on publish.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| `slab_id` | TEXT FK | |
|
||||||
|
| `sku` | TEXT | |
|
||||||
|
| `woo_product_id` | INTEGER | |
|
||||||
|
| `status` | TEXT | 'success' \| 'failed' |
|
||||||
|
| `http_status` | INTEGER | |
|
||||||
|
| `error` | TEXT | machine-readable code |
|
||||||
|
| `detail` | TEXT | Woo error body |
|
||||||
|
| `payload_hash` | TEXT | sha256 of Woo payload sent |
|
||||||
|
| `created_at` | TEXT | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## inference_log
|
||||||
|
|
||||||
|
Append-only audit.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| `slab_id` | TEXT FK | |
|
||||||
|
| `call` | TEXT | '1' \| '2' |
|
||||||
|
| `request_payload` | TEXT | JSON of what was sent |
|
||||||
|
| `response_payload` | TEXT | JSON of what came back |
|
||||||
|
| `created_at` | TEXT | |
|
||||||
|
|||||||
+228
-124
@@ -1,186 +1,290 @@
|
|||||||
# OpenAPI Contract — SvelteKit Client ↔ FastAPI
|
# OpenAPI Contract — SvelteKit Client ↔ FastAPI
|
||||||
|
|
||||||
Status: SPEC (deliverable #2) · 2026-08-24 · SlabUploader
|
Status: SPEC · 2026-08-27 · SlabUploader
|
||||||
This is the binding API contract. The machine-readable `openapi.yaml` is generated
|
Base path: `/api/v1`. All requests/responses JSON unless noted.
|
||||||
from the FastAPI app at implementation time and must match this doc. Base path:
|
All timestamps UTC ISO-8601.
|
||||||
`/api`. All requests/responses JSON unless noted. All timestamps UTC ISO-8601.
|
|
||||||
|
|
||||||
## Conventions
|
## 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
|
### SlabCreate
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"id": "uuid (client-generated)",
|
"id": "uuid (client-generated)",
|
||||||
"sku": "string 3..24 [A-Z0-9-]",
|
"sku": "string 3..24 [A-Z0-9-]",
|
||||||
"length_in": 0, // optional at create; required before publish
|
"length_in": 96.0, // 1/8" step
|
||||||
"length_source": "manual|ruler|null",
|
"thickness_in": 1.5, // 1/8" step
|
||||||
"thickness_in": 0, // optional at create; required before publish
|
"sqft": 0.64, // computed by client
|
||||||
"species": "string|null",
|
"bdft": 0.96, // computed by client
|
||||||
"character": "string|null",
|
"width_min_in": 14.0,
|
||||||
"title": "string|null", "short_title": "string|null", "description": "string|null",
|
"width_max_in": 22.0,
|
||||||
"price": 0, "price_source": "recommendation|override|null",
|
"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
|
"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)
|
### Slab (read model)
|
||||||
All of `SlabCreate` fields (resolved) plus:
|
All `SlabCreate` fields (resolved) plus:
|
||||||
`status`, `width_avg_in`, `width_source`, `sqft`, `bdft`, `price_per_bdft`,
|
`status`, `species_confidence`, `woo_product_id`, `published_at`,
|
||||||
`species_confidence`, `character_confidence`, `normalized_fill_ok`,
|
`server_rev`, `created_at`, `updated_at`, `photos: [PhotoRead]`,
|
||||||
`woo_product_id`, `published_at`, `server_rev`, `created_at`, `updated_at`,
|
`inference_status: null|inferring|done|failed`, `inference_error: string|null`.
|
||||||
`photos: [PhotoRead]`.
|
|
||||||
|
|
||||||
### PhotoRead
|
### PhotoRead
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "id":"uuid","kind":"calibration_sku|calibration_ruler|inventory",
|
{ "id": "uuid", "kind": "inventory", "role": "topdown|extra",
|
||||||
"role":"topdown|extra|null","seq":1,
|
"seq": 1, "original_url": "/api/v1/slabs/{id}/photos/{pid}/original",
|
||||||
"original_url":"/api/slabs/{id}/photos/{pid}/original",
|
"processed_url": "/api/v1/slabs/{id}/photos/{pid}/processed|null" }
|
||||||
"normalized_url":"/api/slabs/{id}/photos/{pid}/normalized|null",
|
```
|
||||||
"is_customer_facing":0|1 }
|
|
||||||
|
### 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
|
### 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)
|
### SettingsView (read; secrets masked)
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "woo_base_url":"https://www.whidbeywoodstore.com",
|
{ "woo_base_url": "https://www.whidbeywoodstore.com",
|
||||||
"woo_credentials_configured": true,
|
"woo_credentials_configured": true,
|
||||||
"inference_base_url":"http://192.168.1.202:8080",
|
"inference_base_url": "http://192.168.1.202:8080/v1",
|
||||||
"inference_model":"<model>", "inference_enabled": true,
|
"inference_model": "<model>", "inference_enabled": true,
|
||||||
"content_llm_enabled": false,
|
"content_llm_enabled": false,
|
||||||
"brand_voice":"…", "geo_context":"…",
|
"brand_voice": "...", "geo_context": "...",
|
||||||
"aspect_ratio":"1:1","output_px":1600,"output_px_min":1000,"fill_target":0.80 }
|
"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
|
## Endpoints
|
||||||
|
|
||||||
### Health & meta
|
### Health & meta
|
||||||
- `GET /api/health` → `{ "status":"ok","version":"…","woo_reachable":bool }`
|
- `GET /api/v1/health` → `{ "status":"ok", "version":"...", "woo_reachable": bool }`
|
||||||
(light probe; used by PWA connectivity check, ARCH §3.3)
|
- `GET /api/v1/meta` → `{ "version":"...", "templates_configured": bool }`
|
||||||
- `GET /api/meta` → `{ "version":"…","templates_configured":bool }`
|
|
||||||
|
|
||||||
### Slabs
|
### Slabs
|
||||||
- `POST /api/slabs` body `SlabCreate` → `201` `Slab` (status `draft`).
|
- `POST /api/v1/slabs` — Create a calibrated draft. Body: multipart with `data` (SlabCreate JSON) + `files[]` (photos) + `meta[]` (photo metadata).
|
||||||
- `409 duplicate_sku` if SKU exists.
|
- Returns `201` `Slab` (status `draft`).
|
||||||
- `GET /api/slabs` → `200` `[Slab]` (all, most-recent first).
|
- Server auto-runs Call 1 (vision inference). `inference_status` becomes `inferring`, then `done`/`failed`.
|
||||||
- `GET /api/slabs/{id}` → `200` `Slab` / `404`.
|
- `409 duplicate_sku` if SKU already published.
|
||||||
- `PUT /api/slabs/{id}` body `SlabUpdate` (any subset of editable fields + `client_rev`)
|
- `GET /api/v1/slabs` → `200` `[Slab]` (most recent first).
|
||||||
→ `200` `Slab`. Re-runs derived math (sqft/bdft/price recommendation) server-side when
|
- `GET /api/v1/slabs/{id}` → `200` `Slab` / `404`.
|
||||||
inputs change. `409 revision_conflict`, `409 duplicate_sku`.
|
- 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).
|
||||||
- `DELETE /api/slabs/{id}` → `204` (soft: only allowed while unpublished; published
|
- `PUT /api/v1/slabs/{id}` — Update draft fields. Body: partial `SlabCreate` (any subset of editable fields + `client_rev`).
|
||||||
slabs are retained for audit).
|
- 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)
|
### Photo operations
|
||||||
- `POST /api/slabs/{id}/upload` body: JSON manifest
|
- `POST /api/v1/slabs/{id}/u2net/{pid}` — "Try harder." Server runs U2Net on this one photo, returns a better mask.
|
||||||
```jsonc
|
- Returns `202` `{ mask_url: "..." }`. Client uses the mask to re-compute area/widths locally.
|
||||||
{ "client_rev":2,
|
- `404` if photo not found.
|
||||||
"photos":[ { "id":"uuid","kind":"inventory","role":"topdown","seq":1,
|
- `GET /api/v1/slabs/{id}/photos/{pid}/original` → `image/*` (original, while retained).
|
||||||
"blob":"<base64>" , "filename":"slab1_topdown.jpg" } ] }
|
- `GET /api/v1/slabs/{id}/photos/{pid}/processed` → `image/*` (processed PNG, while retained).
|
||||||
```
|
- After publish, both → `410 Gone`.
|
||||||
(Photos are base64 in v1 for transport simplicity; total request cap 40 MB.
|
|
||||||
If that proves heavy, switch to multipart — documented extension.)
|
### Inference
|
||||||
→ `202` `{ "status":"processing" }`. Server runs the authoritative pipeline
|
- `POST /api/v1/slabs/{id}/infer-taxon` — Trigger or retry Call 1 (vision).
|
||||||
(ruler→scale→geometry→widths→bdft→species/char→pricing→normalization→content)
|
- Server uses cached taxonomy + original photos downscaled to 1024px + user metadata.
|
||||||
asynchronously. Client polls `GET /api/slabs/{id}`.
|
- Returns `202` `{ "status": "inferring" }`. Client polls `GET /api/v1/slabs/{id}` for results.
|
||||||
- `409 revision_conflict` on stale `client_rev`.
|
- Timeout > 45s → `504 gateway_timeout` with error detail.
|
||||||
- Pipeline failure → slab `failed` (poll returns the error).
|
- `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
|
### Publish
|
||||||
- `POST /api/slabs/{id}/publish` → `202` `{ "status":"publishing" }`.
|
- `POST /api/v1/slabs/{id}/publish` — Publish to WooCommerce.
|
||||||
Pre-conditions (else `422 unpublishable`): status in `ready`/`review`, length+width+
|
- Pre-conditions: status in `ready`, all required fields set. Else `422 unpublishable`.
|
||||||
thickness present, price present, ≥1 inventory photo, species/character confirmed.
|
- Dedupe: if SKU already has a published product → `409 duplicate_sku`.
|
||||||
- Dedupe: if SKU already has a published slab → `409 duplicate_sku` (FR24a).
|
- Returns `202` `{ "status": "publishing" }`. Client polls.
|
||||||
- Woo failure → slab `failed`, `sync_log` written, poll returns `502 woo_error`.
|
- 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
|
### Taxonomy
|
||||||
- `GET /api/slabs/{id}` (same as above) is the poll endpoint. Long-running ops set
|
- `GET /api/v1/admin/taxonomy` → `200` taxonomy grouped by kind:
|
||||||
`status` to `processing`/`publishing`; client backs off (1s,2s,4s… cap 10s).
|
```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)
|
### Pricing
|
||||||
- `GET /api/slabs/{id}/photos/{pid}/original` → `image/*` (original, while retained)
|
- `GET /api/v1/admin/pricing` → `[PriceRule]`
|
||||||
- `GET /api/slabs/{id}/photos/{pid}/normalized` → `image/*` (normalized, while retained)
|
- `POST /api/v1/admin/pricing` → `201` `PriceRule`
|
||||||
- After publish, both → `410 Gone` (images purged, Decision 12).
|
- `PUT /api/v1/admin/pricing/{id}` → `200` `PriceRule`
|
||||||
|
- `DELETE /api/v1/admin/pricing/{id}` → `204`
|
||||||
|
|
||||||
### Taxonomy (FR25/FR26)
|
### Settings
|
||||||
- `POST /api/admin/taxonomy/sync` → `200` `{ "categories":n,"tags":n,"attributes":n,"synced_at":"…" }`
|
- `GET /api/v1/settings` → `200` `SettingsView`
|
||||||
Pulls from Woo, rebuilds `woo_taxonomy` cache. `502 woo_error` on failure.
|
- `PUT /api/v1/settings` — Update settings. Body: partial `{ key: value }`.
|
||||||
- `GET /api/admin/taxonomy` → `200` the cached taxonomy (grouped by kind).
|
- Secrets re-encrypted server-side. `200` `SettingsView` returned.
|
||||||
This is the controlled list for species (Decision 4) and auto-assign (FR26).
|
- `POST /api/v1/settings/test-woo` → `200` `{ "ok": bool, "detail": "..." }`
|
||||||
|
- `POST /api/v1/settings/test-inference` → `200` `{ "ok": bool, "vision_capable": bool, "detail": "..." }`
|
||||||
### 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Request/response examples
|
## Request/response examples
|
||||||
|
|
||||||
### Create a slab
|
### Create a slab (multipart)
|
||||||
```
|
```
|
||||||
POST /api/slabs
|
POST /api/v1/slabs
|
||||||
{ "id":"9f1c…","sku":"BW-0042","length_source":null,"client_rev":1 }
|
Content-Type: multipart/form-data; boundary=---
|
||||||
→ 201
|
|
||||||
{ "id":"9f1c…","sku":"BW-0042","status":"draft","server_rev":1, "photos":[], … }
|
---
|
||||||
|
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
|
GET /api/v1/slabs/9f1c...
|
||||||
{ "client_rev":1, "photos":[ …base64… ] }
|
→ 200 { "status":"draft","inference_status":"done",
|
||||||
→ 202 { "status":"processing" }
|
"species_id":42,"species_confidence":0.92,
|
||||||
GET /api/slabs/9f1c… (poll)
|
"wood_category_ids":[15],"edge_type_term_id":8,
|
||||||
→ 200 { "status":"ready","bdft":218.4,"width_avg_in":18.2,"price":2730.0,
|
"figure_term_ids":[3],"grade_term_ids":[7],
|
||||||
"species":"Black Walnut","character":"Cathedral","server_rev":2, … }
|
"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
|
### Publish
|
||||||
```
|
```
|
||||||
POST /api/slabs/9f1c…/publish
|
POST /api/v1/slabs/9f1c.../publish
|
||||||
→ 202 { "status":"publishing" }
|
→ 202 { "status":"publishing" }
|
||||||
GET /api/slabs/9f1c… (poll)
|
GET /api/v1/slabs/9f1c...
|
||||||
→ 200 { "status":"published","woo_product_id":1042,"published_at":"…", … }
|
→ 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
|
## Versioning
|
||||||
URL is `/api/v1` at implementation (this doc omits the prefix for brevity). Breaking
|
URL is `/api/v1`. Breaking changes bump to `/api/v2`. The PWA pins the version it was built against.
|
||||||
changes bump to `/api/v2`; the PWA pins the version it was built against.
|
|
||||||
|
|
||||||
## Out of contract (v1)
|
## Out of contract (v1)
|
||||||
- Bulk endpoints, staff/role endpoints, webhook push (client polls), analytics.
|
- Bulk endpoints, staff/role endpoints, webhook push, analytics.
|
||||||
|
|||||||
+165
-218
@@ -1,271 +1,218 @@
|
|||||||
# Technical Spec: Deterministic Image Pipeline & Ruler→Inch Math
|
# Technical Spec: Deterministic Image Pipeline & Area → Bdft
|
||||||
|
|
||||||
Status: SPEC (pre-implementation) · 2026-08-24 · TyTech / SlabUploader
|
Status: SPEC · 2026-08-27 · TyTech / SlabUploader
|
||||||
Scope: PRD deliverable #1 — algorithms and test vectors for the deterministic core.
|
Scope: PRD deliverable #1 — algorithms and test vectors for the deterministic core.
|
||||||
No inference, no LLM. Everything here is pure functions of (pixels, inputs) → outputs.
|
No inference, no LLM. Everything here is a pure function of (pixels, user inputs) → outputs.
|
||||||
Every threshold/constant lives in §8 (single source of truth) and is unit-tested.
|
Inference, Woo payload, API, and schema live in other docs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Invariants
|
## 1. Invariants
|
||||||
|
|
||||||
1. **Determinism** — same inputs → bit-identical outputs. No randomness, no timestamps in math, no network calls.
|
1. **Determinism** — same inputs → bit-identical outputs. No randomness, no timestamps in math, no network calls for the math.
|
||||||
2. **Manual entry is authoritative** — a user-entered value (length, width, thickness, SKU, price) always wins over any computed value.
|
2. **Manual entry is authoritative** — user-entered length, thickness, SKU, and price always win over any computed value.
|
||||||
3. **Fallback chain is explicit** — every computed value has a defined fallback; the system never silently guesses.
|
3. **Fallback chain is explicit** — every computed value has a defined fallback; the system never silently guesses.
|
||||||
4. **All math in inches and board feet** — stored as float64; display rounding per §2.
|
- BG removal: chroma-key / threshold → user slider / sheet toggle → U2Net → retake.
|
||||||
5. **Client vs server split** — client runs the *light* pass (preview, OCR, quick ruler check) for feedback; server runs the *authoritative* pass. Server values are what get stored and published. Client results are hints only.
|
- Length axis: min-area bounding rect → user rotate/confirm → retake if the photo is unusable.
|
||||||
|
- Area: requires a single contiguous slab mask; broken mask → flag and retake.
|
||||||
|
4. **Units** — inches in 1/8" steps; sqft and bdft to 2 decimals; price half-up to 2 decimals. Those rounded values are what get stored. No sqin in the UI, tests, or product copy.
|
||||||
|
5. **Client is authoritative** — all deterministic processing runs in the browser. The server stores the numbers and files the client sends, and handles Woo sync.
|
||||||
|
|
||||||
## 2. Units & rounding
|
## 2. Units and rounding
|
||||||
|
|
||||||
| Quantity | Internal | Display | Stored |
|
| Quantity | Source | Stored / displayed |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| Length, widths | float64 inches | 1 decimal | full precision |
|
| Length | user, 1/8" steps | nearest 1/8" |
|
||||||
| Thickness | float64 inches (manual) | 2 decimals (eighths OK in UI) | full precision |
|
| Thickness | user pick, 1/8" steps | that pick (used in math and copy) |
|
||||||
| sqft | float64 | 1 decimal | full precision |
|
| Thickness Woo attribute | same pick, rounded **up** into a Woo range | Woo term, not the 1/8" value |
|
||||||
| bdft | float64 | 1 decimal | full precision |
|
| sqft | computed from mask | 2 decimals |
|
||||||
| Price | float64 USD | 2 decimals | 2 decimals (round half-up) |
|
| bdft | sqft × thickness (inches) | 2 decimals |
|
||||||
|
| Price | species $/bdft × bdft, or override | 2 decimals, half-up |
|
||||||
|
|
||||||
- bdft is **never rounded up to whole units** (ASSUMPTION — flag to Ty: lumber-trade practice sometimes rounds up; v1 does not).
|
- bdft is never rounded up to whole board feet.
|
||||||
- Price computed from unrounded bdft, then rounded half-up to cents.
|
- Pixel→inch scale is an implementation detail. Users never see pixels.
|
||||||
- Pixel→inch scale is float64 px/inch, never pre-rounded.
|
|
||||||
|
|
||||||
## 3. Ruler → inch scale
|
## 3. Background removal
|
||||||
|
|
||||||
### 3.1 Inputs
|
The user must see where the cut will land and be able to fix it. Edge quality is the foundation of sqft and bdft.
|
||||||
- `ruler.png` — calibration photo: a standard **12-inch ruler** (Decision 3: always inches), placed flat and parallel to the slab's long axis on the green sheet.
|
|
||||||
- Known: `RULER_INCHES = 12.0` (§8).
|
|
||||||
|
|
||||||
### 3.2 Server algorithm (authoritative)
|
### 3.1 Auto-detect sheet
|
||||||
```
|
1. Sample a 2–5% strip on all four borders.
|
||||||
1. Grayscale, GaussianBlur (k=5, σ=1.0)
|
2. Average HSV per side.
|
||||||
2. Detect the ruler:
|
3. Hue in [35, 85] and saturation ≥ 40 → green.
|
||||||
a. Canny (40, 120) → contours → candidate quads (4 corners, area > 5% of frame)
|
4. Else value ≤ 30 and saturation ≤ 20 → black.
|
||||||
b. Score candidates: high straight-line edge density (Hough, minLineLength=10% of ruler width),
|
5. Else prompt: pick green or black.
|
||||||
regular tick spacing (see 3.3), aspect ratio within 12:0.5..12:2 band
|
|
||||||
c. Take highest score; none above TICK_SPACING_TOL → RULER_FAIL
|
|
||||||
3. Perspective-correct the ruler to a top-down rect using its 4 corners
|
|
||||||
→ rect of W_r × H_r px, long axis horizontal
|
|
||||||
4. Tick detection on the corrected rect:
|
|
||||||
a. Column-wise dark-pixel projection over the central 60% band of the rect
|
|
||||||
b. Local minima with prominence > MIN_TICK_PROMINENCE → tick x-positions
|
|
||||||
c. Cluster ticks; compute gaps; identify the modal gap g_mode
|
|
||||||
d. Tick labels: integer inches where cumulative distance from the first
|
|
||||||
labeled tick ≈ k × g_mode for k = 1..12
|
|
||||||
e. Scale candidate: s = (x_{12th tick} − x_{1st tick}) / 11.0 [px per inch]
|
|
||||||
(11 gaps between tick 1 and tick 12)
|
|
||||||
f. Validation: |g_mode − (s/1)| < TICK_SPACING_TOL and tick count ≥ 12
|
|
||||||
5. Cross-check with the ruler rect edges: s_edge = (W_r − 2·margin) / 12.0
|
|
||||||
where margin is the detected pre-"1" and post-"12" padding (mode tick gap).
|
|
||||||
If |s − s_edge| / s < RULER_CROSS_CHECK_TOL → use s.
|
|
||||||
Else → prefer s (tick-based), log warning.
|
|
||||||
6. Emit: scale_px_per_inch = s, ruler_confidence = f(tick_count, tol residuals)
|
|
||||||
```
|
|
||||||
`RULER_FAIL` → the pipeline proceeds via the manual-length path (§5.3).
|
|
||||||
|
|
||||||
### 3.3 Client light pass (OpenCV.js, preview only)
|
### 3.2 Visual feedback (required)
|
||||||
Canny + largest 4-point contour + tick projection. Emits a *hint* scale for live
|
- Overlay the mask on the photo (what will be removed vs kept).
|
||||||
feedback ("~201 px/in — looks right"). Never stored. If it fails, UI says
|
- Show the slab edge clearly — that edge is the measurement boundary.
|
||||||
"ruler unclear — you can enter length manually."
|
- User confirms the edge before length / area run.
|
||||||
|
|
||||||
### 3.4 SKU OCR (client, Tesseract.js)
|
### 3.3 Persisted settings (reset-to-default available)
|
||||||
- Preprocess: grayscale → contrast stretch → upscale ×2 if ruler-width < 800 px → binarize (Otsu)
|
Factory values live in code. If the user changes them, the new values persist until reset.
|
||||||
- PSM: 7 (single line), character whitelist: `ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-`
|
|
||||||
- Accept if: confidence ≥ OCR_CONF_MIN AND regex `^[A-Z0-9-]{3,24}$`
|
|
||||||
- Else → manual SKU entry (FR3). Server re-runs OCR on upload as a secondary check; mismatch → surface both, user picks.
|
|
||||||
|
|
||||||
## 4. Top-down photo → pixel geometry
|
1. **Sheet** — auto / green / black.
|
||||||
|
2. **Sensitivity** — one slider; tighter vs looser detection (green: HSV range width; black: brightness cutoff).
|
||||||
|
3. **Edge offset** — pull the mask in or out a few pixels so the cut sits on the wood, not in the sheet or into the grain.
|
||||||
|
4. **Feather** — hard cut vs a couple of pixels of alpha blend.
|
||||||
|
|
||||||
The inventory photo used for width sampling must be **top-down** (slab flat on the
|
### 3.4 Algorithms
|
||||||
green sheet, camera above). Selection rule: the first inventory photo whose slab
|
- **Green:** chroma-key in HSV. Border flood-fill so only sheet pixels connected to the image edge are removed (protects green-ish grain inside the slab).
|
||||||
contour aspect ratio matches (L_px : w_px ≈ reported L : expected w) — else the
|
- **Black:** grayscale threshold. Same flood-fill (protects dark grain).
|
||||||
user picks which photo is top-down in the review UI (FR23).
|
- **Fallback:** if the edge is still wrong after adjustment, U2Net matting. Last resort.
|
||||||
|
- Morphological close, then feather per the setting.
|
||||||
|
|
||||||
|
### 3.5 Edge quality
|
||||||
|
- The mask must be one contiguous slab contour.
|
||||||
|
- If the user cannot get a clean edge, retake is the resolution.
|
||||||
|
|
||||||
|
## 4. Length axis, widths, sqft, bdft
|
||||||
|
|
||||||
|
### 4.1 Contour and length axis
|
||||||
|
1. Binary mask M from §3.
|
||||||
|
2. Largest contour → convex hull.
|
||||||
|
3. Minimum-area bounding rectangle on the hull.
|
||||||
|
4. Long side = length axis; short side = width axis.
|
||||||
|
This follows the **prevailing** length, not a diagonal cut on one end.
|
||||||
|
|
||||||
|
### 4.2 Visual feedback (required)
|
||||||
|
- Overlay the bounding rectangle and the length axis.
|
||||||
|
- Width sample lines drawn perpendicular to that axis.
|
||||||
|
- User confirms the axis matches the slab’s prevailing length.
|
||||||
|
- **Rotate axis** if it is skewed; samples and sqft scale update live.
|
||||||
|
- Nearly square slabs: user says which axis is length.
|
||||||
|
|
||||||
|
### 4.3 Scale
|
||||||
|
User length L (inches, 1/8") is authoritative.
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Green-sheet removal (see §7.1) → slab mask M
|
scale = longest_axis_px / L
|
||||||
2. Contour of M → C (largest contour)
|
|
||||||
3. Rotate C so its principal axis is horizontal (PCA)
|
|
||||||
4. Length in px: L_px = max extent along the axis
|
|
||||||
5. If ruler scale available: L_in = L_px / s. Cross-check vs any user-entered
|
|
||||||
length (manual is authoritative; if |L_in − L_manual| / L_manual > LEN_MISMATCH_TOL
|
|
||||||
→ warn in UI, use manual).
|
|
||||||
6. Edge profiles: for x ∈ [0, L_px], y_top(x), y_bot(x) from C.
|
|
||||||
Width profile: w(x) = y_bot(x) − y_top(x)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Dimensions → sqft → bdft
|
### 4.4 Widths (listing only, not bdft)
|
||||||
|
- Sample every **6"** along the confirmed length axis, perpendicular to it.
|
||||||
|
- Report min, max, and average width for the description.
|
||||||
|
- Auto-dimension overlay on the photo is **v1.5**, out of this spec.
|
||||||
|
|
||||||
|
### 4.5 sqft
|
||||||
|
Projected top-down area of the mask. Live-edge thickness and the underside are not in the area.
|
||||||
|
|
||||||
### 5.1 Width sampling at peaks & troughs (Decision 1)
|
|
||||||
```
|
```
|
||||||
1. Smooth w(x) with a moving average (window = L_px / 50)
|
sqft = (mask_pixel_count / scale²) / 144
|
||||||
2. Find local maxima (peaks) and minima (troughs) of w(x) with
|
|
||||||
prominence > WIDTH_FEATURE_PROM
|
|
||||||
3. Candidate sample x-positions:
|
|
||||||
- x = 0.05·L_px and x = 0.95·L_px (near both ends, always)
|
|
||||||
- all peak/trough x-positions (trimmed to interior)
|
|
||||||
4. Select 3–5 points (WIDTH_SAMPLES_MIN..MAX):
|
|
||||||
always keep the two end points; add interior points ranked by prominence
|
|
||||||
until 5, or stop at 3 if fewer interior features exist.
|
|
||||||
5. w_i = w(x_i) in px → inches via scale (or via manual length, §5.3)
|
|
||||||
6. w_avg = (Σ w_i) / n (Decision 18)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 Core math
|
Round to 2 decimals.
|
||||||
|
|
||||||
|
### 4.6 bdft
|
||||||
|
|
||||||
```
|
```
|
||||||
sqft = (L_in × w_avg_in) / 144
|
bdft = sqft × thickness_in
|
||||||
bdft = (L_in × w_avg_in × t_in) / 12 (Decision 18)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.3 Fallbacks (Decisions 2, 19)
|
Round to 2 decimals. Identity: 1 sqft at 1" thick = 1 bdft.
|
||||||
| Available | Scale source | Length | Widths |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Ruler OK | ruler px/in | computed (cross-checked vs manual) | sampled from top-down photo |
|
|
||||||
| Ruler FAIL, manual L, top-down photo OK | L_manual / L_px | manual (authoritative) | sampled from photo via derived scale |
|
|
||||||
| Ruler FAIL, manual L, NO usable top-down photo | — | manual | **manual width entry** (Decision 19; single width = w_avg) |
|
|
||||||
| No length at all (no ruler, user declines) | — | BLOCK publish — length is required | — |
|
|
||||||
|
|
||||||
## 6. Pricing (Decisions 4, 5, 13)
|
## 5. Photo normalization
|
||||||
```
|
|
||||||
rule = pricing_rules[species, character] (seeded from synced Woo categories)
|
|
||||||
recommendation = bdft × rule.price_per_bdft (unrounded bdft)
|
|
||||||
final_price = user override if edited, else recommendation
|
|
||||||
stored_price = round_half_up(final_price, 2)
|
|
||||||
```
|
|
||||||
- No rule for (species, character) → recommendation = null, UI shows "no pricing rule — set price manually". Publish still allowed with a manual price.
|
|
||||||
|
|
||||||
## 7. Photo normalization (Decisions 8, 20)
|
Applies to every inventory photo of the slab (first photo and the rest).
|
||||||
|
|
||||||
### 7.1 Green-sheet background removal (deterministic first)
|
- Crop aspect: **3:4 portrait** (settings default; matches the current PIP).
|
||||||
The sheet is a uniform green; chroma-key beats a general matting model here:
|
- Transparent **PNG only**.
|
||||||
|
- Slab centered. **80% fill at the extremes** (widest and tallest points of the slab). Extra margin where the slab is narrower.
|
||||||
|
- After crop, if the source has enough pixels, shorter side ≥ **1600 px**. If it cannot, warn and ask for a retake — do not invent pixels.
|
||||||
|
- Original + processed kept while draft/retry; both purged after successful publish (server storage; see Data Model).
|
||||||
|
|
||||||
|
## 6. Pricing
|
||||||
|
|
||||||
|
- Settings: sync **species** from Woo; user sets **$/bdft per species**.
|
||||||
|
- Capture: `recommendation = bdft × species.price_per_bdft`, pre-fills price.
|
||||||
|
- User may override. Override sticks for that slab.
|
||||||
|
- No rule for that species → empty price; user types it. Publish still allowed.
|
||||||
|
- Stored price: half-up to 2 decimals.
|
||||||
|
- Figure- or category-weighted price is **v1.5**.
|
||||||
|
|
||||||
|
## 7. Constants
|
||||||
|
|
||||||
|
### 7.1 Code (not in Settings)
|
||||||
```
|
```
|
||||||
1. Convert to HSV (OpenCV 8-bit: H 0–179)
|
FILL_TARGET = 0.80
|
||||||
2. Sheet mask: H ∈ [GREEN_HUE_LO, GREEN_HUE_HI] AND S ≥ GREEN_SAT_MIN AND V ≥ GREEN_VAL_MIN
|
ASPECT_W, ASPECT_H = 3, 4
|
||||||
3. Border flood-fill: keep only sheet pixels connected to the image border
|
OUTPUT_PX_MIN = 1600
|
||||||
(protects greenish wood tones inside the slab)
|
WIDTH_SAMPLE_IN = 6
|
||||||
4. Morphological close (kernel 9) then 1–2px feather on the alpha edge
|
INCH_STEP = 0.125
|
||||||
5. Fallback: if sheet coverage < SHEET_COV_MIN of frame (wrong background),
|
AREA_DECIMALS = 2
|
||||||
escalate to U²Net matting for that photo and flag it in the log
|
BDFT_DECIMALS = 2
|
||||||
|
PRICE_DECIMALS = 2
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 Boundary detection & 1:1 crop (FR20)
|
### 7.2 Factory defaults for persisted BG settings
|
||||||
```
|
```
|
||||||
1. Slab bbox from mask M (after removal)
|
SHEET = auto
|
||||||
2. Target: slab occupies FILL_TARGET of the final 1:1 frame
|
GREEN_HUE_LO/HI = 35, 85
|
||||||
→ frame side (px) = min(bbox_w, bbox_h) / FILL_TARGET
|
GREEN_SAT_MIN = 40
|
||||||
3. Crop window: square of that side, centered on the bbox center
|
GREEN_VAL_MIN = 40
|
||||||
(clamped to source bounds; if clamped, re-center on the clamp-constrained side)
|
BLACK_THRESH = 30
|
||||||
4. Upscale/crop to 1600×1600 (Decision 20; 1000×1000 is the hard minimum —
|
|
||||||
if source can't supply 1000 px of slab in the short dimension, keep source
|
|
||||||
resolution, flag "low-res" in the review UI)
|
|
||||||
5. Output: 1600×1600 PNG (transparency preserved), plus a JPEG (q=85) copy
|
|
||||||
for the WooCommerce upload
|
|
||||||
```
|
```
|
||||||
|
Sensitivity, edge offset, and feather map onto these at runtime. Users do not edit the raw HSV numbers.
|
||||||
|
|
||||||
### 7.3 Merchandising filter (FR22, optional, per-slab toggle)
|
## 8. Test vectors
|
||||||
Deterministic LUT: contrast +12% (linear stretch), warmth +8 (R/B channel shift
|
|
||||||
toward warm), grain = unsharp mask (radius 2, amount 0.4). Fixed constants in §8 —
|
|
||||||
no per-photo tuning. Applied after §7.2, before encode.
|
|
||||||
|
|
||||||
### 7.4 What gets stored (Decision 12)
|
Users think in inches, sqft, and bdft. Pixels are only how the computer gets there.
|
||||||
- While draft / retry: original + normalized, both on disk (Docker volume)
|
|
||||||
- After successful publish: both purged (SQLite rows keep metadata only)
|
|
||||||
|
|
||||||
## 8. Deterministic constants (single source of truth)
|
### TV-1 Green sheet
|
||||||
|
Synthetic: green border, wood in the center. Interior green-ish patch that does not touch the border stays. Coverage enough that U2Net does not run.
|
||||||
|
|
||||||
```python
|
### TV-2 Black sheet
|
||||||
RULER_INCHES = 12.0 # known physical ruler length
|
Same layout, black border, mid-tone wood. Dark interior wood that does not touch the border stays.
|
||||||
CANNY_LO, CANNY_HI = 40, 120
|
|
||||||
MIN_TICK_PROMINENCE = 0.15 # of projection peak amplitude
|
|
||||||
TICK_SPACING_TOL = 0.05 # 5%
|
|
||||||
RULER_CROSS_CHECK_TOL = 0.03 # 3%
|
|
||||||
OCR_CONF_MIN = 70 # Tesseract 0–100
|
|
||||||
LEN_MISMATCH_TOL = 0.05 # 5%
|
|
||||||
WIDTH_FEATURE_PROM = 0.02 # 2% of L_px, in width-profile prominence
|
|
||||||
WIDTH_SAMPLES_MIN, WIDTH_SAMPLES_MAX = 3, 5
|
|
||||||
END_TRIM = 0.05 # sample at 5% and 95% of length
|
|
||||||
GREEN_HUE_LO, GREEN_HUE_HI = 35, 85
|
|
||||||
GREEN_SAT_MIN = 40
|
|
||||||
GREEN_VAL_MIN = 40
|
|
||||||
SHEET_COV_MIN = 0.25 # sheet must cover ≥25% of frame
|
|
||||||
FILL_TARGET = 0.80 # slab fill of final frame
|
|
||||||
OUTPUT_PX = 1600 # 1:1 output size (Decision 20)
|
|
||||||
OUTPUT_PX_MIN = 1000 # hard minimum
|
|
||||||
JPEG_QUALITY = 85
|
|
||||||
FILTER_CONTRAST = 0.12
|
|
||||||
FILTER_WARMTH = 8
|
|
||||||
FILTER_GRAIN_RADIUS = 2
|
|
||||||
FILTER_GRAIN_AMOUNT = 0.4
|
|
||||||
```
|
|
||||||
All of these are importable module-level constants (`pipeline/constants.py` when
|
|
||||||
implemented) and each test vector in §9 pins at least one of them.
|
|
||||||
|
|
||||||
## 9. Test vectors
|
### TV-3 Scale, sqft, bdft
|
||||||
|
Slab **96" long**, **1.5" thick**. Mask measures **0.64 sqft**.
|
||||||
|
bdft = 0.64 × 1.5 = **0.96**.
|
||||||
|
|
||||||
### TV-1: Ruler scale (pure math)
|
### TV-4 Width samples
|
||||||
Ruler rect corrected; ticks detected at px [100, 300, 500, …, 2300] (12 ticks, 200 px apart).
|
96" length axis, stations every 6". Widths perpendicular to the confirmed axis. Listing gets min, max, avg.
|
||||||
- scale = (2300 − 100) / 11 = **200.0 px/in**
|
|
||||||
- s_edge with margin 100 px each side, W_r = 2500: (2500 − 200) / 12 = 191.67 → Δ = 4.2% > 3% → use tick scale, log warning. ✔ expected branch.
|
|
||||||
|
|
||||||
### TV-2: Full happy path
|
### TV-5 Angled end
|
||||||
- scale = 200 px/in (from TV-1)
|
One square end, one 45° cut. Length axis follows prevailing length, not the diagonal. Widths stay perpendicular after user confirm.
|
||||||
- L_px = 19,200 → L = 96.0 in
|
|
||||||
- widths (px) at x = 960, 4800, 9600, 14400, 18240: [3500, 3640, 3800, 3560, 3700]
|
|
||||||
→ inches: [17.5, 18.2, 19.0, 17.8, 18.5] → w_avg = **18.2 in**
|
|
||||||
- t = 1.5 in (manual)
|
|
||||||
- sqft = (96 × 18.2) / 144 = **12.13**
|
|
||||||
- bdft = (96 × 18.2 × 1.5) / 12 = **218.40**
|
|
||||||
- rule: (Black Walnut, Cathedral) = $12.50/bdft
|
|
||||||
- recommendation = 218.40 × 12.50 = **$2,730.00**
|
|
||||||
|
|
||||||
### TV-3: Manual length, derived scale
|
### TV-6 3:4 crop, 80% at extremes
|
||||||
- RULER_FAIL; manual L = 96.0 in; L_px = 19,200 → scale = 200.0 px/in (derived)
|
Slab centered in a 3:4 frame. At the widest and tallest points, fill = 0.80. PNG with alpha. Shorter side ≥ 1600 when the source allows.
|
||||||
- Same width px as TV-2 → identical downstream results (18.2 in avg, 218.40 bdft).
|
|
||||||
- Asserts the fallback chain is value-equivalent when inputs match.
|
|
||||||
|
|
||||||
### TV-4: No top-down photo (Decision 19)
|
### TV-7 Too-small source
|
||||||
- Manual L = 96.0, manual width = 18.0, t = 1.5
|
After 80% crop, shorter side would be below 1600 and the source cannot supply it. Warn. Do not upscale from nothing. That photo is not publishable until retake.
|
||||||
- w_avg = 18.0 → bdft = (96 × 18 × 1.5) / 12 = **216.00**
|
|
||||||
- No photo used in math; publish allowed.
|
|
||||||
|
|
||||||
### TV-5: Pricing edge cases
|
### TV-8 Pricing
|
||||||
- bdft = 218.4, rule $12.34 → rec = 2695.056 → stored **$2,695.06** (half-up)
|
bdft 0.96, species $12.50/bdft → rec **12.00**.
|
||||||
- bdft = 0 (degenerate) → rec = $0.00, UI blocks publish (slab must have positive dims)
|
Override **150.00** stored as 150.00.
|
||||||
- No rule → rec = null; manual price $999.99 stored as-is.
|
No rule → rec empty; manual 99.99 stored as 99.99.
|
||||||
|
|
||||||
### TV-6: Normalization geometry
|
### TV-9 Thickness → Woo range
|
||||||
- Source 4000×3000; slab bbox (px): x 400..3600, y 600..2400 → w=3200, h=1800
|
User pick **1"** stored as 1". Attribute bucket is the Woo range that 1" rounds **up** into (e.g. `1"–1 1/2"`). Exact range labels come from the synced Woo attribute, not this file.
|
||||||
- frame side = 1800 / 0.80 = 2250
|
|
||||||
- crop: 2250×2250 centered on (2000, 1500) → x 875..3125, y 275..2525 (in bounds ✔)
|
|
||||||
- slab fill check: 1800 / 2250 = **0.80** ✔
|
|
||||||
- Output: 1600×1600. Slab in output: 1800·(1600/2250) = 1280 px → 80% of 1600 ✔
|
|
||||||
- Clamp case: bbox center (1000, 1000), side 2250 → clamp x to 0..2250, y to 0..2250; re-center rule applies; expected crop x 0..2250, y 0..2250 with slab offset left/up — fill still 0.80.
|
|
||||||
|
|
||||||
### TV-7: Green-sheet key
|
### TV-10 Rounding
|
||||||
- Synthetic: 1000×1000, H=60 S=120 V=120 background; center 400×200 rect H=20 S=60 V=80 (wood)
|
Length 96.04" → **96"** (nearest 1/8").
|
||||||
- Expected: mask covers background minus the wood rect; border-flood removes any
|
sqft 0.641 → **0.64**.
|
||||||
interior sheet-colored islands; sheet coverage = 60% ≥ 25% ✔ (no U²Net escalation)
|
bdft 11.525 → **11.53**.
|
||||||
- Wood rect with a green-ish tone (H=45 S=50 V=100) touching no border → NOT removed (border flood) ✔
|
Price 12.345 → **12.35**.
|
||||||
|
|
||||||
### TV-8: Width sampler selection
|
## 9. Assumptions
|
||||||
- w(x) profile: ends 100, peak 140 at x=0.3L, trough 90 at x=0.6L, end 105
|
|
||||||
(prominences: peak 40 ≥ 2% of L_px-scale ✔, trough 50 ✔)
|
|
||||||
- Expected sample points: {0.05L, 0.95L, 0.3L(peak), 0.6L(trough)} = 4 points (≤5 ✔)
|
|
||||||
- n=4 → w_avg = (100+105+140+90)/4 = 108.75 (px) → inches via scale.
|
|
||||||
|
|
||||||
### TV-9: OCR acceptance
|
1. The first photo is top-down, lens parallel to the broad face. Tilted shots make sqft/bdft wrong; retake is the fix.
|
||||||
- "BW-0042" conf 85, regex ✔ → accept
|
2. User length is the prevailing length of that face, in 1/8". The bounding-box axis is confirmed in the UI.
|
||||||
- "BW-004?" conf 78 → regex ✘ → manual entry
|
3. Thickness is the declared 1/8" pick, not measured from any photo.
|
||||||
- "BW-0042" conf 65 → conf < 70 → manual entry
|
4. sqft is the projected top-down area of the mask. Live-edge thickness and underside are not in the area.
|
||||||
|
5. bdft = sqft × thickness (inches). Not rounded up to whole board feet.
|
||||||
|
6. Sheet is uniform green or black. Odd colors → user picks sheet mode or retakes.
|
||||||
|
7. 3:4 is the PIP crop. Changing it later is a settings/code change.
|
||||||
|
8. Client does all of this math. Server stores the numbers the client sends.
|
||||||
|
|
||||||
## 10. Assumptions (flagged for Ty)
|
## 10. Out of scope here (covered in other docs)
|
||||||
|
|
||||||
1. **bdft not rounded up** to whole units (§2). Say the word and it's a one-line change.
|
| Topic | Doc |
|
||||||
2. **12-inch ruler** with inch ticks is the standard calibration tool (Decision 3 implies; spec assumes a 1-ft rule with ticks at every inch).
|
|---|---|
|
||||||
3. **Top-down photo selection** = best aspect-ratio match, user-overridable (FR23). No inference used for this.
|
| Call 1 / Call 2 prompts, taxonomy mapping | Content / Woo |
|
||||||
4. **Ruler parallel to long axis** — if the user places it diagonally, tick scale is still valid (it's a linear scale), only the cross-check margin logic degrades; acceptable for v1.
|
| Woo payload, publish, duplicate SKU | Content / Woo, OpenAPI |
|
||||||
5. Green sheet is the standard bright craft green. A different sheet color → U²Net fallback path (§7.1 step 5), not a config item in v1.
|
| SQLite tables, status machine | Data Model |
|
||||||
|
| HTTP contract | OpenAPI |
|
||||||
## 11. Out of scope for this spec (next: OpenAPI contract, deliverable #2)
|
| Client vs server topology | Architecture |
|
||||||
- Endpoint shapes, request/response schemas, error taxonomy
|
| Auto-dimension overlay, figure-weighted price | Impl plan (v1.5) |
|
||||||
- SQLite schema beyond the pricing-rule shape (PRD)
|
| Offline capture | PRD non-goal |
|
||||||
- WooCommerce product payload mapping
|
|
||||||
- Content generation templates (FR16) — deterministic template design is a separate short doc
|
|
||||||
|
|||||||
Reference in New Issue
Block a user