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).
This commit is contained in:
Hermes Agent
2026-08-24 12:27:20 -07:00
parent db659e1947
commit bee3bcc278
10 changed files with 1409 additions and 18 deletions
+174
View File
@@ -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 |
+151
View File
@@ -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": <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).
+166
View File
@@ -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.
+156
View File
@@ -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 <tag>` → 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).
+179
View File
@@ -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 <dir>`) 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.
+186
View File
@@ -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":"<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":"<base64>" , "filename":"slab1_topdown.jpg" } ] }
```
(Photos are base64 in v1 for transport simplicity; total request cap 40 MB.
If that proves heavy, switch to multipart — documented extension.)
→ `202` `{ "status":"processing" }`. Server runs the authoritative pipeline
(ruler→scale→geometry→widths→bdft→species/char→pricing→normalization→content)
asynchronously. Client polls `GET /api/slabs/{id}`.
- `409 revision_conflict` on stale `client_rev`.
- Pipeline failure → slab `failed` (poll returns the error).
### 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.
+271
View File
@@ -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