From bee3bcc2787331d2474018a907edaa528891d2ca Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 12:27:20 -0700 Subject: [PATCH] docs: full implementation plan (PRD decisions locked + specs 1-4 + impl plan) - PRD: 20 locked decisions, open items cleared, fence fixed - docs/TECH-SPEC-PIPELINE.md: deterministic math + 9 test vectors (deliverable #1) - docs/ARCHITECTURE.md, DATA-MODEL.md, OPENAPI.md (deliverable #2), CONTENT-WOO.md, DEPLOYMENT.md (deliverable #4), IMPL-PLAN.md - README rewritten; .gitignore added AGENTS.md pending Ty's approval (protected file write was blocked). --- .gitignore | 37 +++++ README.md | 43 +++++- Slab capture app PRD.txt | 64 ++++++--- docs/ARCHITECTURE.md | 174 ++++++++++++++++++++++++ docs/CONTENT-WOO.md | 151 +++++++++++++++++++++ docs/DATA-MODEL.md | 166 +++++++++++++++++++++++ docs/DEPLOYMENT.md | 156 +++++++++++++++++++++ docs/IMPL-PLAN.md | 179 ++++++++++++++++++++++++ docs/OPENAPI.md | 186 +++++++++++++++++++++++++ docs/TECH-SPEC-PIPELINE.md | 271 +++++++++++++++++++++++++++++++++++++ 10 files changed, 1409 insertions(+), 18 deletions(-) create mode 100644 .gitignore create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONTENT-WOO.md create mode 100644 docs/DATA-MODEL.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/IMPL-PLAN.md create mode 100644 docs/OPENAPI.md create mode 100644 docs/TECH-SPEC-PIPELINE.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc5c1f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ + +# Node +node_modules/ +frontend/build/ +frontend/.svelte-kit/ +frontend/dist/ + +# Env / secrets (NEVER commit) +.env +.env.local +.env.*.local +deploy/.env +*.pem +*.key + +# Data / images +*.db +*.sqlite3 +data/ +images/ + +# OS / editor +.DS_Store +.idea/ +.vscode/ + +# Logs +*.log diff --git a/README.md b/README.md index 924a06e..d34b1e7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ # SlabUploader -this is a woocommerce uploader tool + +Mobile-first, self-hosted app for publishing one-of-a-one wood slabs to WooCommerce. +Field capture (offline) → deterministic measurement/pricing/normalization → publish a +single slab to the store as a pending product. + +Owner: Ty (Whidbey). Store: www.whidbeywoodstore.com. Deploy: .201 (Caddy, Docker). + +## Status +**Planning complete — ready for implementation.** All 20 product decisions locked +(PRD "Decisions log", 2026-08-24). Build plan in `docs/IMPL-PLAN.md` (phases 0–6). + +## Documentation +| File | What | +|---|---| +| `Slab capture app PRD.txt` | Requirements + locked decisions (source of truth for "what") | +| `docs/ARCHITECTURE.md` | Services, client/server split, offline PWA, data flow | +| `docs/TECH-SPEC-PIPELINE.md` | Deterministic math + image pipeline, constants, 9 test vectors | +| `docs/DATA-MODEL.md` | SQLite schema + status state machine | +| `docs/OPENAPI.md` | Client↔server API contract (deliverable #2) | +| `docs/CONTENT-WOO.md` | Content templates + WooCommerce payload/integration | +| `docs/DEPLOYMENT.md` | Compose, Caddy, backup/restore, key rotation (deliverable #4) | +| `docs/IMPL-PLAN.md` | Phased build plan with acceptance criteria + exit gates | +| `AGENTS.md` | Working contract + hard rules for anyone implementing | + +## Quick orientation +- **Deterministic core** (measurements, pricing, normalization, Woo sync) is pure, + network-free, and unit-tested. Inference (species/character + optional content) is + the *only* scoped-LLM surface, and it's toggleable — the app fully works without it. +- **Offline-first PWA**: capture works with no network; drafts + photos persist in + IndexedDB and sync when online. +- **Two containers** (`frontend`, `fastapi`) + Caddy. Single SQLite + image volumes. +- **v1 non-goals**: no versioning, no staff/roles, no bulk publish, no analytics, + no update path (create-only, SKU-deduped). + +## Repo remotes +- Canonical: `github.com/TyTech99/SlabUploader` +- Mirror: Gitea `TyTech_Internal/SlabUploader` (truenas.local:30008) + +## Getting started (implementer) +1. Read `AGENTS.md` (hard rules) and `docs/IMPL-PLAN.md` (phases). +2. Start at Phase 0 — scaffold + the tested deterministic pipeline. +3. Don't skip the exit gates; Ty signs each phase. diff --git a/Slab capture app PRD.txt b/Slab capture app PRD.txt index 3f0e742..6d80d3f 100644 --- a/Slab capture app PRD.txt +++ b/Slab capture app PRD.txt @@ -10,6 +10,36 @@ Build a mobile‑first, self‑hosted web app that streamlines publishing one‑ - No bulk publishing; slab‑by‑slab submission only. - Measurements must be accurate; manual length entry is authoritative and used to extrapolate widths and bdft. - No analytics in v1. +- No pricing tiers in v1 — flat price-per-bdft per species/character. +- No app-level auth in v1 — no JWT/login; app relies on HTTPS + trusted network. +- Single SKU per slab; duplicate SKU publish is blocked. +- Images are purged after successful publish (retained only while draft/retry). +- Deploy target: .201, Docker Compose + Caddy. + +## Decisions log (2026-08-24, Ty) +1. Widths: sample 3–5 widths at peaks/troughs of the slab edge (see Open items for the bdft formula). +2. Fallback chain: ruler shot if readable → manual length entry; from manual length + a top-down photo, derive pixel scale and extrapolate widths. +3. Ruler is always in inches. +4. Species/character list = synced from WooCommerce categories (FR25); user sets price-per-bdft per value. +5. No tiers — price = bdft × price_per_bdft, user override before Woo submission. +6. All inference models must support vision (species/character detection operates on slab photos). +7. v1 target store: www.whidbeywoodstore.com. +8. Aspect ratio: 1:1 (Woo best practice), ≥1000×1000 px, slab centered at ~80% fill. +9. Same SKU = blocked (simple dedupe). +10. No app-level auth; Woo API auth = username (consumer key) + app password. +11. Single FastAPI backend (Node service dropped — Python handles WooCommerce REST). +12. Images not needed after WooCommerce submission; purged on successful publish. +13. Deploy on .201; Docker + Caddy preferred. +14. UATs built after the prototype. +15. The schema in the Security section is the pricing-rule shape — confirmed. +16. (Orphan ```md fence fixed.) +17. Bolt.dev deliverable disregarded — this is a Hermes project. +18. bdft formula: average of sampled widths → bdft = L × w_avg × t / 12 (accepted 2026-08-24). +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-24; see Decisions log items 18–20) ## Goals and success metrics **Goals** @@ -45,7 +75,7 @@ Build a mobile‑first, self‑hosted web app that streamlines publishing one‑ - Fallbacks: If SKU or ruler unreadable, allow manual entry (SKU or length). Manual length is authoritative and used to extrapolate widths and sqft. - Enrich: Species detection (optional inference) or manual selection; character selection (manual or optional inference); thickness manual entry. - Pricing: Apply bdft pricing rules (species + character) to compute price recommendation; allow override. -- Photo normalization: Background removal, auto‑crop to ~80% frame fill, enforce theme aspect ratios, optional merchandising filter. +- 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. - Configuration: Configure OpenAI endpoint, WooCommerce credentials, pricing rules, brand voice, and GEO context. @@ -76,7 +106,7 @@ Build a mobile‑first, self‑hosted web app that streamlines publishing one‑ - **FR12:** User can override species/character at any time prior to publish. ### Pricing engine -- **FR13:** UI to define bdft pricing rules by species and character (price per bdft, optional tiers). +- **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). - **FR14:** Deterministic bdft calculation and application of pricing rules to produce a price recommendation. - **FR15:** Price recommendation editable by user; final price stored as authoritative. @@ -87,13 +117,14 @@ Build a mobile‑first, self‑hosted web app that streamlines publishing one‑ ### Photo normalization and merchandising - **FR19:** Remove green‑sheet background deterministically (RemBG/U²Net or equivalent). -- **FR20:** Detect slab boundaries and auto‑crop so slab fills ~80% of frame; enforce theme aspect ratios. +- **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). - **FR21:** Store both original and normalized images. - **FR22:** Optional merchandising filter (contrast, warmth, grain enhancement) applied deterministically; toggleable per slab. - **FR23:** Provide photo review UI to reorder, delete, or retake images. ### WooCommerce sync and taxonomy -- **FR24:** Authenticate to WooCommerce using App Passwords. +- **FR24:** Authenticate to WooCommerce using consumer key (username) + consumer secret (App Password) against www.whidbeywoodstore.com. +- **FR24a:** Publish is blocked if the slab SKU already exists in WooCommerce (simple dedupe, no update path in v1). - **FR25:** Sync categories, tags, and attributes from WooCommerce and cache locally. - **FR26:** Auto‑assign taxonomy based on species/character mapping; allow manual override. - **FR27:** Create/update single WooCommerce product per slab with SKU, titles, description, price, categories, attributes, and normalized images. @@ -106,7 +137,7 @@ Build a mobile‑first, self‑hosted web app that streamlines publishing one‑ ### Configuration and hosting - **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 must be deployable via Docker Compose on Ty’s servers; support HTTPS via Caddy/Traefik. +- **FR33:** App deploys via Docker Compose on .201 with Caddy for HTTPS (single domain, reverse proxy to frontend + fastapi). - **FR34:** All secrets stored encrypted at rest; all client‑server traffic over HTTPS. ## Non‑functional requirements @@ -127,23 +158,23 @@ Build a mobile‑first, self‑hosted web app that streamlines publishing one‑ - Camera API for capture and immediate preview **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), and content generation wrapper that calls Ty’s OpenAI endpoint. -- Node.js service for WooCommerce REST API integration, taxonomy sync, product creation/update, and logging. +- 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). - SQLite for v1 (slab records, pricing rules, taxonomy cache, sync history). Optional Postgres later. **Inference** -- Configurable OpenAI‑compatible endpoint (user supplies base URL and key). Inference limited to species/character detection and optional content refinement. All inference calls logged and include confidence scores. +- 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. **Deployment** -- Docker Compose with `frontend`, `fastapi`, and `node` services. -- Caddy or Traefik for HTTPS and reverse proxy. -- Systemd or container supervisor for service management. +- Docker Compose with `frontend` and `fastapi` services on .201. +- Caddy for HTTPS and reverse proxy. +- Docker compose as the service supervisor. **Security** - AES encryption for stored secrets. -- JWT for session management (single owner account). +- No app-level auth in v1 (single owner; app protected by HTTPS + network trust). - HTTPS enforced. +### Pricing rule schema (confirmed as the pricing-rule shape — Decision 15) id: uuid species: string character: string @@ -152,7 +183,6 @@ created_at: timestamp updated_at: timestamp -```md ## Key flows and acceptance criteria ### Capture → Enrich → Publish flow @@ -162,7 +192,7 @@ updated_at: timestamp 4. Upload: client uploads draft to server for advanced processing. 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). 6. Review: client receives enriched draft; user edits/overrides any field. -7. Publish: user triggers publish; Node service syncs taxonomy and creates/updates WooCommerce product with normalized images. Status updated and logged. +7. Publish: user triggers publish; FastAPI syncs taxonomy and creates the WooCommerce product with normalized images (SKU-deduped per FR24a). Status updated and logged. ### Acceptance criteria - End‑to‑end slab capture to published product works on modern iPhone and Android browsers. @@ -172,7 +202,7 @@ updated_at: timestamp - Deterministic functions covered by unit tests; inference calls logged and stored for audit. ## Operational and debugging notes -- Local dev: Docker Compose with `frontend`, `fastapi`, `node`. Use ngrok for mobile testing. +- Local dev: Docker Compose with `frontend` and `fastapi`. Mobile testing over LAN/Tailscale against the Caddy-served domain (no public tunnel needed). - Logging: centralized logs per service; store inference request/response payloads 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 OpenAI keys encrypted; rotate keys periodically. @@ -180,8 +210,8 @@ updated_at: timestamp ## Next recommended deliverables 1. Technical spec for the deterministic image pipeline and ruler→inch math (algorithms and test vectors). -2. OpenAPI contract between SvelteKit client and FastAPI/Node services. -3. Bolt.dev project skeleton (optional) for UI prototype, with explicit notes on which endpoints must be replaced with deterministic implementations. +2. OpenAPI contract between SvelteKit client and FastAPI. +3. (Disregarded — this is a Hermes project, not bolt.diy.) 4. Deployment playbook (Docker Compose, Caddy, backup, key rotation). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..cab129e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,174 @@ +# Architecture + +Status: SPEC · 2026-08-24 · SlabUploader +Companion to: PRD (root), TECH-SPEC-PIPELINE.md, DATA-MODEL.md, OPENAPI.md, DEPLOYMENT.md. + +## 1. Service topology (2 containers + 1 reverse proxy) + +Per Decision 11 the original Node service is folded into FastAPI. Final v1 topology: + +``` + ┌──────────────────────────────────────────┐ + mobile (PWA) │ .201 host │ + ┌──────────────┐ HTTPS │ ┌─────────┐ ┌──────────────────┐ │ + │ SvelteKit │───────┼─▶│ Caddy │───────▶│ frontend (nginx │ │ + │ browser │ │ │ :443 │ :5173 │ static + /api │ │ + │ IndexedDB │ │ │ slab.* │ ─────▶│ proxy) │ │ + │ camera API │ │ └─────────┘ └──────────────────┘ │ + └──────────────┘ │ │ /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 + nginx container. Pure client app; talks to `/api` on the same origin. No build-time + secrets. This is the PWA (SW + IndexedDB). +- **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 +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) + +Per TECH-SPEC §1.5: the client runs a *light* pass for instant feedback; the server +runs the *authoritative* pass. Stored and published values are always the server's. + +| Capability | Client (hint) | Server (authoritative) | +|---|---|---| +| SKU OCR | Tesseract.js (accept/manual) | re-run; on mismatch surface both, user picks | +| Ruler scale | OpenCV.js quick check | full tick detection + cross-check | +| 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). + +## 3. Offline-first PWA (Decision: full offline per PRD) + +### 3.1 What lives on the client (IndexedDB) +- **Draft slabs**: full metadata (all review-screen fields) + photo blobs + (originals). Calibration photos included. IndexedDB object stores: + `slabs`, `photos` (blob), `syncQueue`. +- **Service Worker**: precache app shell (SvelteKit build). API is *not* cached — + network-first for API, but the capture flow itself needs no network. + +### 3.2 Capture → queue → sync flow +1. Capture is fully offline: photos → IndexedDB, OCR/ruler hints run locally. +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 + +Everything left of the "inference" and "content" boxes is deterministic and unit- +tested (PRD "Deterministic core"). The two inference-touching paths are: +- species/character detection (vision) — toggleable, returns confidence +- optional content refinement (text LLM) — toggleable + +Both are wrapped so that toggling them OFF leaves a fully functional deterministic +app. The app must pass its entire test suite with inference disabled. + +## 6. Concurrency & jobs + +- 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 | +| Server | Python 3.12, FastAPI, Pydantic v2, OpenCV (cv2), numpy, Pillow, RemBG/U²Net (onnx), httpx | +| Woo | WooCommerce REST v3 via httpx (consumer key/secret, Basic auth) | +| DB | SQLite (stdlib `sqlite3` or SQLAlchemy; see DATA-MODEL) | +| Inference | OpenAI-compatible client (configurable base_url/key/model; must be vision-capable) | +| Proxy/HTTPS | Caddy | +| Packaging | Docker Compose | diff --git a/docs/CONTENT-WOO.md b/docs/CONTENT-WOO.md new file mode 100644 index 0000000..9903c91 --- /dev/null +++ b/docs/CONTENT-WOO.md @@ -0,0 +1,151 @@ +# 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": } ], + "tags": [ { "id": }, … ], + "images": [ + { "src": "", "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=)`. 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). diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md new file mode 100644 index 0000000..175a1f4 --- /dev/null +++ b/docs/DATA-MODEL.md @@ -0,0 +1,166 @@ +# Data Model (SQLite, v1) + +Status: SPEC · 2026-08-24 · SlabUploader +Storage: single SQLite file (volume `slab_db`). Schema kept 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 +- IDs: `TEXT` UUIDv4 (client-generated for slabs so the PWA can reference a slab + before it exists server-side). +- Timestamps: UTC ISO-8601 `TEXT` (`YYYY-MM-DDTHH:MM:SSZ`). +- Soft state via `status` column; no hard deletes for slabs (audit). Images purged + on publish (Decision 12) but the slab row + metadata retained. +- Money stored as float64 USD, rounded half-up to cents at write (TECH-SPEC §2). +- `client_rev` / `server_rev`: monotonic ints for optimistic concurrency (ARCH §3.2). + +## ERD (text) +``` +slabs 1─N slab_photos +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] +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) +``` +- `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 +- Single owner. Optimistic locking via `server_rev`: an update must send the `server_rev` + it last read; server rejects on mismatch (409) → client re-fetches. This is the + server-side counterpart to the client `client_rev` (ARCH §3.2). + +## 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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..d278185 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,156 @@ +# Deployment Playbook (.201, Docker Compose + Caddy) + +Status: SPEC (deliverable #4) · 2026-08-24 · SlabUploader +Target: TyUBUMini at 192.168.1.201. Domain: `slab.tyubumini.local` (LAN/Tailscale +only — Decision). Inference is NOT on .201; it's a remote configurable endpoint +(.202 or elsewhere). + +> Note: exact Caddyfile/ports to be verified against the live .201 Caddy setup at +> implementation time (bolt.diy already runs Caddy TLS on tyubumini.local:8443). +> This playbook is the plan; the first implementation task is a .201 inventory pass. + +## 1. Host inventory (do first — do not assume) + +Before deploying, record the current state (HARD RULE: inventory before execute): +1. `docker ps` — existing containers, published ports (avoid collisions; bolt.diy + owns 8443/8444-class ports). +2. Caddy config location (host file or container) and existing `tyubumini.local` + site blocks. +3. Free port choice for the compose stack (proposal: `18080` http, `18443` https — + verify free). +4. Docker volume dir convention on the host. +5. Tailscale: confirm `slab.tyubumini.local` is reachable from the phone's network + (same LAN or Tailscale). If the domain isn't in DNS yet, add it (Pi-hole/AdGuard + or Tailscale MagicDNS) — one record, A → 192.168.1.201 (LAN) + Tailscale name. + +## 2. Repository layout (at scaffold) + +``` +SlabUploader/ +├── AGENTS.md +├── README.md +├── docs/ # this plan set +├── deploy/ +│ ├── docker-compose.yml +│ ├── Caddyfile.fragment # slab site block (merged into host Caddy) +│ ├── .env.example # SLAB_AES_KEY, port vars (no real secrets) +│ └── backup.sh # sqlite .backup + images tar, with retention +├── frontend/ # SvelteKit app (Phase 1) +└── backend/ # FastAPI app (Phase 0) + ├── app/ + │ ├── main.py + │ ├── pipeline/ # deterministic core (constants.py, ruler, geometry, widths, pricing, normalize) + │ ├── woo/ # Woo client, taxonomy, publish + │ ├── inference/ # vision + content wrappers (toggleable) + │ ├── api/ # routers (slabs, photos, pricing, taxonomy, settings) + │ ├── db/ # schema.sql, models, migrations (alembic) + │ └── security/ # AES-GCM settings encryption + ├── tests/ # pytest — unit (deterministic) + integration (woo) + └── Dockerfile +``` + +## 3. docker-compose.yml (target shape) + +```yaml +services: + frontend: + build: ./frontend + ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"] # Caddy-only; not public + depends_on: [fastapi] + restart: unless-stopped + + fastapi: + build: ./backend + environment: + SLAB_AES_KEY: ${SLAB_AES_KEY} # from .env (32-byte base64) + # no Woo/inference keys here — they live encrypted in SQLite (Settings UI) + volumes: + - slab_db:/data + - slab_images:/images + ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"] # Caddy-only + mem_limit: 2g # env convention: mem_limit preferred + restart: unless-stopped + +volumes: + slab_db: + slab_images: +``` + +Conventions (house rules): +- `mem_limit` on services (not host-level tuning). +- Services bind to 127.0.0.1 only; Caddy is the only public surface. +- No `latest` tags on base images — pin versions. +- `restart: unless-stopped`; compose is the supervisor (no systemd units). + +## 4. Caddy (fragment) + +```caddyfile +slab.tyubumini.local { + tls internal # Caddy internal CA (matches bolt.diy pattern) + reverse_proxy frontend:80 { + header_up X-Real-IP {remote_host} + } + handle /api/* { + reverse_proxy fastapi:8000 + } +} +``` +- HTTPS enforced (http→https redirect automatic). +- If the host Caddy already serves `*.tyubumini.local`, add this as a site block; + verify the internal CA cert is trusted on the phone (same flow as bolt.diy). +- LAN/Tailscale only — no public exposure. + +## 5. Secrets + +| Secret | Where | Notes | +|---|---|---| +| `SLAB_AES_KEY` | `.env` on host (mode 600) → compose env | 32-byte base64; NEVER in the repo | +| Woo consumer key/secret | SQLite `settings` (AES-GCM) via Settings UI | rotate in Woo → re-enter in UI | +| Inference API key | SQLite `settings` (AES-GCM) via Settings UI | usually empty for local llama.cpp | +| `.env.example` | repo | placeholders only, committed | + +Key rotation (AES): `SLAB_AES_KEY` change requires re-encrypting settings — +provide `python -m app.security.rekey NEW_KEY` (reads old key from env +`SLAB_AES_KEY_OLD`, decrypts, re-encrypts, updates). Documented in playbook §7. + +## 6. Backup & restore + +`deploy/backup.sh` (run via cron on .201, e.g. daily 03:00): +``` +1. sqlite3 /data/slab.db ".backup /backups/slab-YYYYMMDD.db" # WAL-safe +2. tar czf /backups/slab-images-YYYYMMDD.tgz -C /images . +3. retain: 7 daily + 4 weekly; prune older +4. log result to /var/log/slab-backup.log; alert (Hermes) on failure +``` +- Backup target: local dir first; mirror to TrueNAS SMB share + (`\\truenas.local\…\backups\slab`) for off-box safety (same pattern as other stacks). +- **Restore**: stop compose → restore DB file + untar images → start compose. + Tested at least once during Phase 6 (UAT) before declaring backup done. + +## 7. Operations runbook + +| Task | Command / how | +|---|---| +| Deploy (first) | inventory §1 → `docker compose -f deploy/docker-compose.yml build` → `up -d` → Caddy reload → smoke §8 | +| Update | `git pull` → `docker compose build fastapi frontend` → `up -d` (DB migrations run on start via alembic) | +| Logs | `docker compose logs -f fastapi` / `frontend` | +| Health | `curl -k https://slab.tyubumini.local/api/health` | +| Rekey AES | §5 | +| Rotate Woo creds | Woo UI → new consumer key/secret → Settings UI → `POST /api/settings/test-woo` | +| Rotate inference key | Settings UI (if external) | +| Rollback image | `git checkout ` → rebuild → up -d; DB down-migrations provided for schema changes | +| Reset one slab | delete its photos dir + `UPDATE slabs SET status='draft'` (admin script) | + +## 8. Smoke test (after every deploy) +1. `GET /api/health` → ok +2. Settings: Woo creds configured + `test-woo` ok +3. Create a slab → upload test photos (fixture set) → poll to `ready` +4. Verify bdft/price match the fixture's expected values (golden test) +5. Publish test slab → `published`, Woo product visible as **pending** in store admin +6. Delete/leave the pending test product as a staging artifact (never published) + +## 9. Scaling path (not v1) +- Postgres when SQLite write contention appears (schema already portable). +- Redis+arq when in-process queue shows lag. +- GPU on .201 only if RemBG latency becomes a bottleneck — otherwise keep it CPU + (normalization budget is 3–5s per PRD; U²Net on modern CPU meets that). diff --git a/docs/IMPL-PLAN.md b/docs/IMPL-PLAN.md new file mode 100644 index 0000000..2e27d7d --- /dev/null +++ b/docs/IMPL-PLAN.md @@ -0,0 +1,179 @@ +# Implementation Plan + +Status: SPEC · 2026-08-24 · SlabUploader +The build plan a professional (or a fresh Hermes session) executes against. Each +phase has: goal, deliverables, acceptance criteria (testable), and exit gate. +Ordering is by dependency — do not skip ahead. Definition of done for EVERY phase: +code + tests + real execution output in the PR/commit message + docs updated. + +Ground rules (from AGENTS.md): +- Deterministic core is unit-tested before any UI consumes it. +- No feature without a passing test and shown output. +- Inference stays toggleable; the full suite must pass with it disabled. +- One phase = a reviewable, shippable increment. + +--- + +## Phase 0 — Skeleton & deterministic core (server) +Goal: repo scaffold + the tested deterministic pipeline, no UI, no Woo. + +Deliverables: +- `backend/` FastAPI skeleton: app factory, health endpoint, config, logging. +- `pipeline/` implementing TECH-SPEC: constants.py, ruler→scale, geometry, width + sampler, bdft math, pricing, green-removal, crop/normalize, merch filter. +- `db/`: schema.sql + alembic migration for all tables (DATA-MODEL). +- `tests/`: port every TECH-SPEC test vector (TV-1…TV-9) to pytest; add property + tests (bdft monotonic in inputs; width sampler returns 3–5 points; crop fill ∈ + [0.75,0.85]). +- Dockerfile for backend; runs `pytest` in CI/local. + +Acceptance: +- `pytest` green, covering all 9 TVs + property tests; coverage of `pipeline/` ≥ 95%. +- `GET /api/health` works on the dev container. +- A fixture photo set (3–5 real slab photos) runs end-to-end through the pipeline + CLI (`python -m app.pipeline.run `) and prints bdft/widths/normalized outputs + matching hand-computed expectations. + +Exit gate: Ty reviews the pipeline CLI output on real photos; math confirmed. + +## Phase 1 — API surface (no Woo, no inference) +Goal: full slab CRUD + photo upload + settings, backed by the Phase-0 core. + +Deliverables: +- Routers per OPENAPI: slabs (CRUD + upload + poll), photos (serve), pricing-rules, + settings (with AES-GCM encryption + test stubs), taxonomy (cache read/write). +- Optimistic concurrency (client_rev/server_rev, 409 paths). +- Status state machine + in-process job queue (processing→ready). +- Integration tests (httpx TestClient): create→upload→poll→ready; revision conflict; + duplicate SKU; settings encryption round-trip (secret never returned). + +Acceptance: +- OPENAPI.md endpoints implemented; a generated `openapi.yaml` matches the doc. +- Integration tests green; secrets never leak into any GET response (asserted). +- `docker compose up` (backend + a throwaway frontend stub) serves the API. + +Exit gate: Ty drives the API with curl through a full draft→ready cycle. + +## Phase 2 — Frontend: capture & offline PWA +Goal: mobile-first capture flow, fully offline, with the local light pass. + +Deliverables: +- SvelteKit + Tailwind app (SPA/SSG), Workbox service worker, IndexedDB stores. +- Capture flow: SKU close-up (Tesseract.js OCR + manual fallback), ruler shot + (OpenCV.js hint + manual length fallback), 1–5 inventory photos, thickness entry. +- Draft autosave to IndexedDB (rev management), offline banner, upload queue. +- Photo review UI (reorder/delete/retake; mark top-down photo). + +Acceptance: +- On a real phone (LAN/Tailscale), the entire capture flow works with airplane mode + on (photos + OCR + draft persist offline). +- On reconnect, queued draft uploads and the enriched draft merges. +- Lighthouse mobile ≥ 90 on the capture screens; one-hand usable (manual check). +- SW precache verified (reload with network off after first load). + +Exit gate: Ty captures a real slab fully offline on his phone; draft survives a +browser restart. + +## Phase 3 — Frontend: enrich, review, publish UI +Goal: the enriched review screen and publish action, wired to Phase-1 API. + +Deliverables: +- Review screen: all fields editable (dimensions, species/character with confidence + + manual override, price with recommendation, content with "Refine with AI" toggle, + normalized photos). +- Species/character inference UI (calls server; shows confidence; manual confirm below + threshold). +- Publish button (online-only), status polling, success/failure states, error detail. +- Settings UI (FR32): Woo creds + test, inference endpoint + test (vision check), + pricing rules editor, brand voice/GEO, templates. + +Acceptance: +- Full round-trip in browser: capture→upload→review→edit→publish→published (against + a Woo sandbox/mock in tests; against real store in Phase 6). +- Inference disabled → review screen fully functional with manual species/character. +- Every field is editable and the override persists (asserted in a test). + +Exit gate: Ty publishes a test slab to the real store as a **pending** product. + +## Phase 4 — WooCommerce integration (real) +Goal: production Woo sync, taxonomy, dedupe, logging (FR24–28). + +Deliverables: +- Woo client (httpx, Basic auth, timeouts), taxonomy sync, auto-assign, media upload, + product create (status=pending default), dedupe, sync_log. +- `POST /api/admin/taxonomy/sync` against the real store. +- Integration tests against a Woo mock (respx) for success + error + duplicate paths. +- Partial-failure handling (orphaned media noted, per CONTENT-WOO §2.6). + +Acceptance: +- Real publish: product appears in store admin as pending, correct SKU/price/images/ + category/tags; `woo_product_id` stored; `sync_log` written with payload_hash. +- Duplicate SKU publish → 409, no second product created. +- Woo error (bad creds / 500) → slab `failed` with detail; retry path works. +- All Woo integration tests green (mock + one live smoke). + +Exit gate: Ty verifies a real pending product in the store is correct. + +## Phase 5 — Inference (vision + optional content) +Goal: scoped, toggleable inference with guardrails. + +Deliverables: +- Vision wrapper: species/character detection from the top-down photo via the + configurable OpenAI-compatible endpoint; returns confidence; logs request/response. +- Content refinement (optional LLM) with the numeric-accuracy guardrail (CONTENT-WOO + §1.2). +- `test-inference` endpoint (vision capability check). +- All inference code behind feature flags; suite passes with everything OFF. + +Acceptance: +- With a local vision model configured: species/character suggestion appears with + confidence; low confidence → manual confirm required. +- With inference OFF: full app works, tests green (explicit test asserts no network + calls to the inference endpoint). +- LLM refinement rejects output that alters a measurement (guardrail test). +- Inference payloads stored for audit (asserted present after a call). + +Exit gate: Ty runs species detection on 3 real slabs; results acceptable or manually +corrected (that's the design). + +## Phase 6 — Deployment, UAT, hardening +Goal: production on .201 + user acceptance tests (PRD deliverable: UATs after prototype). + +Deliverables: +- .201 inventory pass (DEPLOYMENT §1) → final compose + Caddyfile. +- Deploy to `slab.tyubumini.local`; backup/restore runbook executed once (restore tested). +- **UAT suite** built from PRD acceptance criteria: a scripted set of real slabs + (≥3 species, ≥1 cathedral, ≥1 manual-width fallback, ≥1 offline capture, ≥1 + duplicate-SKU attempt) with expected bdft/price/image-fill asserted. +- Performance check: capture OCR 1–2s, server normalization 3–5s per slab (PRD NFR). +- Docs: README, runbook, key-rotation verified. + +Acceptance (PRD success metrics, measured): +- E2E capture→publish on iPhone AND Android browsers works. +- Manual length override recalculates widths/sqft/bdft correctly (UAT asserts). +- Normalization: slab ~80% frame in ≥95% of UAT photos. +- Woo product created with normalized images + correct taxonomy. +- Deterministic functions unit-tested; inference calls logged for audit. +- Backup restore demonstrated once. + +Exit gate: Ty signs off on UAT results; v1 declared done. + +--- + +## Risks & mitigations +| Risk | Impact | Mitigation | +|---|---|---| +| Ruler detection unreliable in field lighting | bad scale → bad widths | manual length is authoritative fallback (always available); ruler confidence surfaced; UAT gates it | +| Green-sheet color varies / U²Net CPU latency > budget | slow/failed normalization | chroma-key primary (fast); U²Net fallback; `output_px` can drop to 1000 min; GPU path documented | +| No vision model available at deploy | species inference off | inference is toggleable; manual species/character fully functional — v1 not blocked | +| Woo partial publish (media up, product POST fails) | orphaned images, confusing state | sync_log + retained images for retry; manual cleanup note; no auto-delete | +| Local llama.cpp vision endpoint not up on .202 | inference off | configurable endpoint; deploy-time config; app works without it | +| SQLite contention under concurrent polls | slowdown | single owner, low volume; Postgres path documented | +| .201 port/Caddy collision with bolt.diy | deploy conflict | Phase-0/6 inventory pass before any port bind | + +## Definition of Done (v1, project-level) +- All 6 phases exit-gated by Ty. +- PRD acceptance criteria met (measured in Phase 6 UAT). +- `pytest` + integration + UAT green; inference-off path green. +- Deployed on .201, HTTPS, backed up, restore tested, runbook + key-rotation verified. +- Repo mirrored to Gitea (GitHub canonical); docs current. diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md new file mode 100644 index 0000000..a4f84fe --- /dev/null +++ b/docs/OPENAPI.md @@ -0,0 +1,186 @@ +# OpenAPI Contract — SvelteKit Client ↔ FastAPI + +Status: SPEC (deliverable #2) · 2026-08-24 · SlabUploader +This is the binding API contract. The machine-readable `openapi.yaml` is generated +from the FastAPI app at implementation time and must match this doc. Base path: +`/api`. All requests/responses JSON unless noted. All timestamps UTC ISO-8601. + +## 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) + +### SlabCreate +```jsonc +{ + "id": "uuid (client-generated)", + "sku": "string 3..24 [A-Z0-9-]", + "length_in": 0, // optional at create; required before publish + "length_source": "manual|ruler|null", + "thickness_in": 0, // optional at create; required before publish + "species": "string|null", + "character": "string|null", + "title": "string|null", "short_title": "string|null", "description": "string|null", + "price": 0, "price_source": "recommendation|override|null", + "client_rev": 1 +} +``` + +### Slab (read model) +All of `SlabCreate` fields (resolved) plus: +`status`, `width_avg_in`, `width_source`, `sqft`, `bdft`, `price_per_bdft`, +`species_confidence`, `character_confidence`, `normalized_fill_ok`, +`woo_product_id`, `published_at`, `server_rev`, `created_at`, `updated_at`, +`photos: [PhotoRead]`. + +### PhotoRead +```jsonc +{ "id":"uuid","kind":"calibration_sku|calibration_ruler|inventory", + "role":"topdown|extra|null","seq":1, + "original_url":"/api/slabs/{id}/photos/{pid}/original", + "normalized_url":"/api/slabs/{id}/photos/{pid}/normalized|null", + "is_customer_facing":0|1 } +``` + +### PriceRule +`{ id, species, character, price_per_bdft, created_at, updated_at }` + +### SettingsView (read; secrets masked) +```jsonc +{ "woo_base_url":"https://www.whidbeywoodstore.com", + "woo_credentials_configured": true, + "inference_base_url":"http://192.168.1.202:8080", + "inference_model":"", "inference_enabled": true, + "content_llm_enabled": false, + "brand_voice":"…", "geo_context":"…", + "aspect_ratio":"1:1","output_px":1600,"output_px_min":1000,"fill_target":0.80 } +``` +Secrets (keys) are never returned; only a `*_configured` boolean. + +--- + +## Endpoints + +### Health & meta +- `GET /api/health` → `{ "status":"ok","version":"…","woo_reachable":bool }` + (light probe; used by PWA connectivity check, ARCH §3.3) +- `GET /api/meta` → `{ "version":"…","templates_configured":bool }` + +### Slabs +- `POST /api/slabs` body `SlabCreate` → `201` `Slab` (status `draft`). + - `409 duplicate_sku` if SKU exists. +- `GET /api/slabs` → `200` `[Slab]` (all, most-recent first). +- `GET /api/slabs/{id}` → `200` `Slab` / `404`. +- `PUT /api/slabs/{id}` body `SlabUpdate` (any subset of editable fields + `client_rev`) + → `200` `Slab`. Re-runs derived math (sqft/bdft/price recommendation) server-side when + inputs change. `409 revision_conflict`, `409 duplicate_sku`. +- `DELETE /api/slabs/{id}` → `204` (soft: only allowed while unpublished; published + slabs are retained for audit). + +### Photo upload (offline queue, ARCH §3.2) +- `POST /api/slabs/{id}/upload` body: JSON manifest + ```jsonc + { "client_rev":2, + "photos":[ { "id":"uuid","kind":"inventory","role":"topdown","seq":1, + "blob":"" , "filename":"slab1_topdown.jpg" } ] } + ``` + (Photos are base64 in v1 for transport simplicity; total request cap 40 MB. + If that proves heavy, switch to multipart — documented extension.) + → `202` `{ "status":"processing" }`. Server runs the authoritative pipeline + (ruler→scale→geometry→widths→bdft→species/char→pricing→normalization→content) + asynchronously. Client polls `GET /api/slabs/{id}`. + - `409 revision_conflict` on stale `client_rev`. + - Pipeline failure → slab `failed` (poll returns the error). + +### Publish +- `POST /api/slabs/{id}/publish` → `202` `{ "status":"publishing" }`. + Pre-conditions (else `422 unpublishable`): status in `ready`/`review`, length+width+ + thickness present, price present, ≥1 inventory photo, species/character confirmed. + - Dedupe: if SKU already has a published slab → `409 duplicate_sku` (FR24a). + - Woo failure → slab `failed`, `sync_log` written, poll returns `502 woo_error`. + - Success → slab `published`, `woo_product_id` set, images purged (Decision 12). + +### Status poll +- `GET /api/slabs/{id}` (same as above) is the poll endpoint. Long-running ops set + `status` to `processing`/`publishing`; client backs off (1s,2s,4s… cap 10s). + +### Photos (serve) +- `GET /api/slabs/{id}/photos/{pid}/original` → `image/*` (original, while retained) +- `GET /api/slabs/{id}/photos/{pid}/normalized` → `image/*` (normalized, while retained) + - After publish, both → `410 Gone` (images purged, Decision 12). + +### Taxonomy (FR25/FR26) +- `POST /api/admin/taxonomy/sync` → `200` `{ "categories":n,"tags":n,"attributes":n,"synced_at":"…" }` + Pulls from Woo, rebuilds `woo_taxonomy` cache. `502 woo_error` on failure. +- `GET /api/admin/taxonomy` → `200` the cached taxonomy (grouped by kind). + This is the controlled list for species (Decision 4) and auto-assign (FR26). + +### Pricing rules (FR13/14/15) +- `GET /api/pricing-rules` → `[PriceRule]` +- `POST /api/pricing-rules` body `{ species, character, price_per_bdft }` → `201` `PriceRule` + (`409` if pair exists → use PUT). +- `PUT /api/pricing-rules/{id}` body `{ price_per_bdft }` → `200` `PriceRule` +- `DELETE /api/pricing-rules/{id}` → `204` +- `GET /api/pricing-rules/recommend`?species=…&character=…&bdft=… → `200` + `{ "price_per_bdft":n|null, "recommendation":n|null }` (FR14; null if no rule). + +### Settings (FR32) +- `GET /api/settings` → `200` `SettingsView` +- `PUT /api/settings` body: partial `{ key: value }` for non-secret fields, and + secret fields by name (values re-encrypted server-side, ARCH §7). + → `200` `SettingsView`. +- `POST /api/settings/test-woo` → `200` `{ "ok":bool,"detail":"…" }` (validates Woo creds). +- `POST /api/settings/test-inference` → `200` `{ "ok":bool,"vision_capable":bool,"detail":"…" }` + (sends a tiny image; `vision_capable` false → UI warns, species inference disabled). + +--- + +## Request/response examples + +### Create a slab +``` +POST /api/slabs +{ "id":"9f1c…","sku":"BW-0042","length_source":null,"client_rev":1 } +→ 201 +{ "id":"9f1c…","sku":"BW-0042","status":"draft","server_rev":1, "photos":[], … } +``` + +### Upload photos (triggers pipeline) +``` +POST /api/slabs/9f1c…/upload +{ "client_rev":1, "photos":[ …base64… ] } +→ 202 { "status":"processing" } +GET /api/slabs/9f1c… (poll) +→ 200 { "status":"ready","bdft":218.4,"width_avg_in":18.2,"price":2730.0, + "species":"Black Walnut","character":"Cathedral","server_rev":2, … } +``` + +### Publish +``` +POST /api/slabs/9f1c…/publish +→ 202 { "status":"publishing" } +GET /api/slabs/9f1c… (poll) +→ 200 { "status":"published","woo_product_id":1042,"published_at":"…", … } +``` + +## Versioning +URL is `/api/v1` at implementation (this doc omits the prefix for brevity). Breaking +changes bump to `/api/v2`; the PWA pins the version it was built against. + +## Out of contract (v1) +- Bulk endpoints, staff/role endpoints, webhook push (client polls), analytics. diff --git a/docs/TECH-SPEC-PIPELINE.md b/docs/TECH-SPEC-PIPELINE.md new file mode 100644 index 0000000..67cf01d --- /dev/null +++ b/docs/TECH-SPEC-PIPELINE.md @@ -0,0 +1,271 @@ +# Technical Spec: Deterministic Image Pipeline & Ruler→Inch Math + +Status: SPEC (pre-implementation) · 2026-08-24 · TyTech / SlabUploader +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. +Every threshold/constant lives in §8 (single source of truth) and is unit-tested. + +--- + +## 1. Invariants + +1. **Determinism** — same inputs → bit-identical outputs. No randomness, no timestamps in math, no network calls. +2. **Manual entry is authoritative** — a user-entered value (length, width, thickness, SKU, price) always wins over any computed value. +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. +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. + +## 2. Units & rounding + +| Quantity | Internal | Display | Stored | +|---|---|---|---| +| Length, widths | float64 inches | 1 decimal | full precision | +| Thickness | float64 inches (manual) | 2 decimals (eighths OK in UI) | full precision | +| sqft | float64 | 1 decimal | full precision | +| bdft | float64 | 1 decimal | full precision | +| Price | float64 USD | 2 decimals | 2 decimals (round half-up) | + +- bdft is **never rounded up to whole units** (ASSUMPTION — flag to Ty: lumber-trade practice sometimes rounds up; v1 does not). +- Price computed from unrounded bdft, then rounded half-up to cents. +- Pixel→inch scale is float64 px/inch, never pre-rounded. + +## 3. Ruler → inch scale + +### 3.1 Inputs +- `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) +``` +1. Grayscale, GaussianBlur (k=5, σ=1.0) +2. Detect the ruler: + a. Canny (40, 120) → contours → candidate quads (4 corners, area > 5% of frame) + b. Score candidates: high straight-line edge density (Hough, minLineLength=10% of ruler width), + 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) +Canny + largest 4-point contour + tick projection. Emits a *hint* scale for live +feedback ("~201 px/in — looks right"). Never stored. If it fails, UI says +"ruler unclear — you can enter length manually." + +### 3.4 SKU OCR (client, Tesseract.js) +- Preprocess: grayscale → contrast stretch → upscale ×2 if ruler-width < 800 px → binarize (Otsu) +- 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 + +The inventory photo used for width sampling must be **top-down** (slab flat on the +green sheet, camera above). Selection rule: the first inventory photo whose slab +contour aspect ratio matches (L_px : w_px ≈ reported L : expected w) — else the +user picks which photo is top-down in the review UI (FR23). + +``` +1. Green-sheet removal (see §7.1) → slab mask M +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 + +### 5.1 Width sampling at peaks & troughs (Decision 1) +``` +1. Smooth w(x) with a moving average (window = L_px / 50) +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 +``` +sqft = (L_in × w_avg_in) / 144 +bdft = (L_in × w_avg_in × t_in) / 12 (Decision 18) +``` + +### 5.3 Fallbacks (Decisions 2, 19) +| 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) +``` +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) + +### 7.1 Green-sheet background removal (deterministic first) +The sheet is a uniform green; chroma-key beats a general matting model here: +``` +1. Convert to HSV (OpenCV 8-bit: H 0–179) +2. Sheet mask: H ∈ [GREEN_HUE_LO, GREEN_HUE_HI] AND S ≥ GREEN_SAT_MIN AND V ≥ GREEN_VAL_MIN +3. Border flood-fill: keep only sheet pixels connected to the image border + (protects greenish wood tones inside the slab) +4. Morphological close (kernel 9) then 1–2px feather on the alpha edge +5. Fallback: if sheet coverage < SHEET_COV_MIN of frame (wrong background), + escalate to U²Net matting for that photo and flag it in the log +``` + +### 7.2 Boundary detection & 1:1 crop (FR20) +``` +1. Slab bbox from mask M (after removal) +2. Target: slab occupies FILL_TARGET of the final 1:1 frame + → frame side (px) = min(bbox_w, bbox_h) / FILL_TARGET +3. Crop window: square of that side, centered on the bbox center + (clamped to source bounds; if clamped, re-center on the clamp-constrained side) +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 +``` + +### 7.3 Merchandising filter (FR22, optional, per-slab toggle) +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) +- 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) + +```python +RULER_INCHES = 12.0 # known physical ruler length +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-1: Ruler scale (pure math) +Ruler rect corrected; ticks detected at px [100, 300, 500, …, 2300] (12 ticks, 200 px apart). +- 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 +- scale = 200 px/in (from TV-1) +- 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 +- RULER_FAIL; manual L = 96.0 in; L_px = 19,200 → scale = 200.0 px/in (derived) +- 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) +- Manual L = 96.0, manual width = 18.0, t = 1.5 +- w_avg = 18.0 → bdft = (96 × 18 × 1.5) / 12 = **216.00** +- No photo used in math; publish allowed. + +### TV-5: Pricing edge cases +- bdft = 218.4, rule $12.34 → rec = 2695.056 → stored **$2,695.06** (half-up) +- bdft = 0 (degenerate) → rec = $0.00, UI blocks publish (slab must have positive dims) +- No rule → rec = null; manual price $999.99 stored as-is. + +### TV-6: Normalization geometry +- Source 4000×3000; slab bbox (px): x 400..3600, y 600..2400 → w=3200, h=1800 +- 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 +- Synthetic: 1000×1000, H=60 S=120 V=120 background; center 400×200 rect H=20 S=60 V=80 (wood) +- Expected: mask covers background minus the wood rect; border-flood removes any + interior sheet-colored islands; sheet coverage = 60% ≥ 25% ✔ (no U²Net escalation) +- Wood rect with a green-ish tone (H=45 S=50 V=100) touching no border → NOT removed (border flood) ✔ + +### TV-8: Width sampler selection +- 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 +- "BW-0042" conf 85, regex ✔ → accept +- "BW-004?" conf 78 → regex ✘ → manual entry +- "BW-0042" conf 65 → conf < 70 → manual entry + +## 10. Assumptions (flagged for Ty) + +1. **bdft not rounded up** to whole units (§2). Say the word and it's a one-line change. +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. +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. +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. + +## 11. Out of scope for this spec (next: OpenAPI contract, deliverable #2) +- Endpoint shapes, request/response schemas, error taxonomy +- SQLite schema beyond the pricing-rule shape (PRD) +- WooCommerce product payload mapping +- Content generation templates (FR16) — deterministic template design is a separate short doc