15 Commits

Author SHA1 Message Date
grokbot_ty 67775d6c8c fix: HTTP-only Caddy for Gate B LAN phone hang
Root cause: Caddy on :80 returned 308 → HTTPS, and TLS handshake failed
(Go/OpenSSL compat). Phones hung on HTTPS redirect loop.

Fix: Caddyfile serves all HTTP on :80 without TLS/redirect. Docker
compose drops port 443 and cert mount. Interim — post-POC restores
proper TLS.
2026-09-07 16:39:35 +00:00
grokbot_ty 6c62b506d4 docs: update INVENTORY smoke results with LAN bind fix 2026-09-07 16:30:46 +00:00
grokbot_ty b4a702318d fix: bind 0.0.0.0 for LAN reachability (Gate B) 2026-09-07 16:29:58 +00:00
grokbot_ty c62f0329f6 gate-b: smoke results, INVENTORY complete 2026-09-07 16:19:31 +00:00
grokbot_ty f966fbe690 gate-b: Caddy TLS fix, self-signed RSA cert, SLAB_WOO_BASE_URL, absolute DB path 2026-09-07 16:13:29 +00:00
grokbot_ty 68d236961a feat(#30): Gate B [10/10]: Deploy compose + WOO_BASE_URL (OPS-1, GC-5)
- deploy/.env.example: placeholders only (no real URLs, proper comments)
- frontend/Dockerfile: Node 20 stub server for compose build
- deploy/tests/test-compose-health.sh: OPS-1 compose health smoke test
- deploy/tests/test-woo-base-url.sh: GC-4/5/FUND-25 validation suite
- backend/app/api/settings.py: PUT rejects woo_base_url (422), consumer keys (422),
  text() wrapping for SQLAlchemy 2.x, key primary key fix
- backend/tests/contract/test_woo_base_url.py: 8 contract tests for GC-4/GC-5

Tests: 39/39 backend tests pass (8 new, 31 existing green)
Deploy: 5/5 shell tests pass (Docker unavailable for compose-up path)
2026-09-07 08:18:25 +00:00
grokbot_ty 5019b10584 Gate B [09/10]: Draft store stub D-1 — POST /api/v1/slabs (#29)
POST /api/v1/slabs accepts multipart/form-data with:
- data: JSON-encoded SlabCreate payload
- files[]: original image files (1-5 photos)
- meta[]: JSON-encoded photo metadata objects

Returns 201 with status: calibrated and server_rev: 1.

Server stores client-sent numbers only (sqft, bdft, widths) without
server-side recompute. Client is authoritative for deterministic math.

Includes:
- SlabCreateData Pydantic model mirroring OPENAPI.md SlabCreate schema
- PhotoMeta model for photo metadata validation
- SlabRead/PhotoRead response models
- File storage with configurable images directory (temp fallback for tests)
- Duplicate SKU detection (409)
- python-multipart added to requirements.txt

D-1 test coverage (8 tests, all green):
- 201 response on valid multipart upload
- status: calibrated in response
- server_rev: 1 in response
- Full response shape assertion
- Multiple photos handling
- Missing data field returns 400
- Duplicate SKU returns 409
- TestClient lifespan management (FUND-9)

Evidence: pytest tests/ -v → 31 passed, 0 failed
2026-09-07 08:08:39 +00:00
grokbot_ty 82219fe5ad feat(#28): Gate B [08/10]: AES-GCM settings round-trip (AES-1, AES-2)
- AES-GCM encrypt/decrypt module (app/security/aes.py):
  - get_aes_key: decode base64 AES master key to 32 bytes
  - encrypt_setting: AES-256-GCM with fresh 12-byte nonce per call, returns nonce||ciphertext
  - decrypt_setting: decrypts ciphertext, raises AuthenticationError on tamper/wrong key
- Settings endpoint (app/api/settings.py):
  - GET /api/v1/settings: returns SettingsView with decrypted values, secrets masked
    (woo_app_password never returned, only woo_credentials_configured boolean)
  - PUT /api/v1/settings: encrypts all setting values AES-GCM, stores in settings table
  - Rejects woo_consumer_key/woo_consumer_secret fields
  - Validates woo_create_status (draft|publish)
- App factory (app/main.py): stores settings on app.state for dependency injection
- Unit tests (tests/unit/test_aes.py): 18 tests covering AES-1 (round-trip), AES-2
  (missing key refusal), FUND-17 (fresh nonce, tamper rejection), FUND-19
  (no plaintext fallback)

Test results: 23/23 passed (18 new AES + 5 existing health tests)
2026-09-07 08:00:29 +00:00
grokbot_ty e5514fe3bf feat(#27): Gate B [07/10]: FastAPI health endpoint + app factory (H-1)
- App factory pattern (FUND-8): create_app() produces fresh app per test
- Logging config (structured, no secrets)
- Config validation: SLAB_AES_KEY required, WOO_BASE_URL HTTPS-only
- GET /api/health returns 200 {status: ok, version, woo_reachable: false}
- SQLite + alembic skeleton: full DATA-MODEL schema, initial migration
- API contract tests via httpx TestClient: 5 cases all green
  - test_returns_200, test_returns_ok_status, test_woo_reachable_false
  - test_response_shape, test_with_test_client_lifespan (FUND-9/10)
2026-09-07 07:53:36 +00:00
grokbot_ty d5d8c49e04 Gate B #26: shared validation module (V-1, V-2, V-3)
- V-1: SKU charset [A-Z0-9-]{3,24}, accepted/rejected with specific error codes
- V-2: title/short_title/description UTF-8 within WooCommerce maxLength,
  enforced at field-exit and submit (200/200/2500 chars, Unicode code-point counting)
- V-3: validation module version hash, client caches, submit carries hash,
  mismatch returns 412 with fresh module (not 409)

25 unit tests green, zero browser/DOM/network dependencies.
Re-exports through pipeline/index.ts for shared use.
2026-09-07 07:43:50 +00:00
grokbot_ty 34dbfa5d7d feat(#25): Gate B pricing and thickness band (TV-8, TV-9)
TV-8 pricing:
- computeRecommendedPrice(bdft, pricePerBdft) returns bdft × price/bdft rounded half-up to 2 decimals
  0.96 bdft × 12.50/bdft → 12.00
- computeSlabPrice handles three paths:
  1. Has rule, no override → recommended price, source 'recommendation'
  2. Has rule, override → override stored (150.00), source 'override'
  3. No rule, manual → manual stored (99.99), recommendedPrice null

TV-9 thickness band:
- findThicknessBand(thicknessIn, ranges) rounds to 1/8" then matches [min, max) half-open ranges
- 1" → 1"–1 1/2" (rounds up, label from synced Woo attribute, not hardcoded)

Test output (all 29 tests green, 6 new):
- tv8-pricing.test.ts: 6 passed (recommended, override, no-rule, rounding)
- tv9-thickness-band.test.ts: 6 passed (1"→1"–1 1/2", 0.75"→3/4"–1", 0.875"→3/4"–1", 1.5"→1 1/2"–2", null for out-of-range, rounding to 1/8")
2026-09-07 06:33:24 +00:00
grokbot_ty f7040f5947 feat(#24): Gate B 3:4 crop + no upscale (TV-6, TV-7)
TV-6: 3:4 portrait crop, 80% fill at extremes, transparent PNG, shorter side >= 1600px when source allows.
TV-7: undersized source warning, no upscaling, photo not publishable until retake.

Real fixture output:
- TV-6 large source (4000x3200, slab 1281x3000): crop target 1601x2135, shorter 1601 >= 1600, fill at limiting dim 0.80
- TV-7 small source (800x640, slab 400x300): crop target 281x375, shorter 281 < 1600, undersized
- No upscale property: output never exceeds source dimensions

New files:
- frontend/src/lib/pipeline/crop.ts: cropTo34(), findSlabBounds(), computeCropTarget(), checkSourceSize()
- frontend/src/__tests__/fixtures/crop.ts: tv6LargeSourceFixture, tv7SmallSourceFixture, simpleAxis
- frontend/src/__tests__/tv6-tv7-crop.test.ts: 7 tests (2 TV-6, 2 TV-7, 3 property)
2026-09-07 06:24:00 +00:00
grokbot_ty 08c463b8d8 feat(#23): Gate B length axis and 6" widths (TV-4, TV-5)
Pure client axis module: min-area length axis, rotate/confirm session,
scale, and perpendicular width samples every 6".
2026-09-07 06:07:07 +00:00
grokbot_ty 55d66b9644 feat(#22): Gate B BDFT identity and rounding (TV-3, TV-10)
Pure units module: bdft = sqft × thickness_in (no /12), half-up
rounding for sqft/bdft/price, length to nearest 1/8". Property
checks for monotonicity in thickness and sqft.

TDD: red evidence in test-results/tv3-tv10-red.txt, then minimum impl.

Results:
TV-3 | pass | test-results/tv3-tv10-green.txt
TV-10 | pass | test-results/tv3-tv10-green.txt
2026-09-07 06:02:08 +00:00
grokbot_ty 3c2f8871a8 feat(#21): Gate B green/black sheet mask (TV-1, TV-2)
Pure client modules for TECH-SPEC sheet detect, chroma-key / black
threshold with border flood-fill, four knobs (live re-run), and mask
confirm hooks. Synthetic fixtures. U2Net never invoked.

TDD: red evidence in test-results/tv1-tv2-red.txt, then minimum impl.

Results:
TV-1 | pass | test-results/tv1-tv2-green.txt
TV-2 | pass | test-results/tv1-tv2-green.txt
2026-09-07 05:53:43 +00:00
308 changed files with 9709 additions and 29099 deletions
@@ -1,76 +0,0 @@
# Coverage gaps — serial worklist
- Date: 2026-09-18
- Branch: `docs/clean-main`
- Status: open worklist. Companion to `.audit/coverage/` and `tools/check_coverage.py`.
- Rule: work one entry at a time, top to bottom. Each decision is a ruling; record it, then
update the matching `gap` rows in the ledger to their resolution and re-run the gate.
The pilot slice (settings / auth / health) produced 12 `gap` atoms that collapse into the
seven distinct holes below. Each is a requirement the source material carries and
`docs/REQUIREMENTS.md` does not.
## GAP-1 — test-woo failure semantics
- Atoms: `tag:TEST-SPEC API-10`, `tag:OPENAPI test-woo-shape`, `prd decision-17`,
`tag:TEST-SPEC GC-3` (partial).
- Hole: the PoR says `test-woo` verifies the connection and resyncs (`SETTINGS-40`), but
nothing defines its result shape (`ok`, `detail`), that it fails closed on missing
credentials or non-HTTPS, or how an auth rejection differs from an unreachable store.
- Proposed home: `SETTINGS`.
- Status: open.
## GAP-2 — health endpoint semantics
- Atoms: `tag:TEST-SPEC H-1`, `tag:TEST-SPEC INS-1`, `tag:OPENAPI health`, `issue:#6`.
- Hole: `APP-30` names `/api/health`; nothing defines the body (`status`, `version`,
`woo_reachable`) or that health goes non-200 on DB/volume failure.
- Proposed home: `APP`.
- Status: open.
## GAP-3 — PUT /settings rejects a WOO_BASE_URL write
- Atoms: `tag:TEST-SPEC GC-4`, `tag:OPENAPI settings-PUT-reject`.
- Hole: `SETTINGS-5` makes the URL env-only and read-only, but nothing requires the settings
API to refuse an attempt to write it.
- Proposed home: `SETTINGS`.
- Status: open.
## GAP-4 — AES credential round-trip
- Atom: `tag:TEST-SPEC AES-1` (round-trip half).
- Hole: `SETTINGS-75` covers redaction; nothing asserts that an encrypted credential
decrypts back to its value.
- Proposed home: `QUALITY` (test obligation) over `SETTINGS-20/30`.
- Status: open.
## GAP-5 — upstream HTTP errors mapped to the envelope
- Atom: `tag:TEST-SPEC FUND-21`.
- Hole: publish field errors are mapped; there is no general rule that a 401/400/404 from
Woo is turned into the uniform envelope rather than passed through.
- Proposed home: `APP` (alongside `APP-35`).
- Status: open.
## GAP-6 — no app-level auth
- Atom: `prd no-app-auth`.
- Hole: no row states that the app has no login of its own and relies on network plus HTTPS.
- Proposed home: `OPS`.
- Status: open.
## GAP-7 — httpx timeout values
- Atom: `cur:CONTENT-WOO httpx-timeouts`.
- Hole: connect 10s / read 60s exist only in the contract draft.
- Proposed home: contract detail, not a requirement: record in `docs/contracts/` in step 3
and mark the ledger atom `dropped` with rationale.
- Status: open.
## GAP-8 — prompt storage
- Atom: `tag:DATA-MODEL prompt-files` (PROMPTS source still to walk).
- Hole: the source docs put prompts in `backend/app/prompts/`, bind-mounted read-only, read fresh
per call. Ruling 2026-09-18: prompts live in the DB with all revisions. No PoR row yet.
- Proposed home: `METADATA` (prompt management) or `SETTINGS`; resolve during the PROMPTS walk.
- Status: resolved 2026-09-18 — `METADATA-105` (prompts stored in the DB with all revisions).
@@ -1,183 +0,0 @@
# Migration TODOs — source docs
- Date: 2026-09-18
- Branch: `docs/clean-main`
- Purpose: edits deferred until Phase 2 step 3, when the capability and contract docs are
migrated into `docs/capabilities/` and `docs/contracts/`, made branch-neutral, and
converted to cite requirement IDs.
- Rule: these docs are not edited now. `REQUIREMENTS.md` is the only normative text and is
already correct for everything below.
## CAPTURE.md
1. Line 16, "Length of the slab is aligned with the long side of the picture" — ruled dead.
Remove.
2. Lines 128–129, the `kind=inventory` role label — discarded. The "first inventory photo is
the measurement reference" half is `CAPTURE-115`; keep it as a citation, drop the label.
3. Line 140, editor/storefront alpha presentation — discarded.
4. The sensitivity mapping (line 69) and display/source-pixels rule (lines 58–62) are now
`CAPTURE-41` and `CAPTURE-141`; cite them instead of restating.
5. Line 82 busy mark ("more than a fraction of a second"; as-built is 300 ms) — ruled a
nit, not a requirement. Leave as design prose or drop.
## CREATE.md
1. Grade cardinality: change "1 or more" to 1 in all three places (attributes table,
ready set, data shape). Ruling: Grade is exactly one.
2. `fig-*` cardinality: change "1 or more / required" to optional in the tags table and the
ready set. Ruling: tags optional; the Figure attribute is at least one.
3. Ready-set images: change "at least one inventory PNG" to two (clean calibration plus its
dimension-overlay copy). Ruling: `REVIEW-5` is the PoR.
4. Remove "cap at 5 images" from the create sequence.
5. Remove "user typed widths/sqft/bdft wins"; the calculated fields are read-only.
6. Fix the Call 2 line so the model writes the title/description and the deterministic
template is the fallback, not the assembler.
## CONTENT-WOO.md
1. Em dash in the title template and example → hyphen.
2. "Templates (always on)" and "templates inject those numbers after the prose comes back" →
template is the fallback only; the model writes when Call 2 runs.
3. `fig-*` "1+ required" and the ready set including `fig-*` tags → tags optional.
4. "≤5 inventory photos" → no cap; drop the "only `kind=inventory`" clause.
5. The empty `attributes` element shape is the known deferred decision; keep the placeholder.
## PHOTOS.md
No changes. Overlay legibility and arrow/text drawing detail were ruled design, not
requirements.
## TEST-SPEC.md (→ `docs/contracts/TEST-SPEC.md`)
1. Migrate and cite requirement IDs; drop the pre-ID source-doc references.
2. Purge superseded cases: UI-1, UIE-9, UIE-10 (four knobs); V-3, API-8 (validation module);
C2-2 (`previous_response_id` / thread); C1-1 (image source); C2-4 (prompt files); TV-6, TV-7,
E2E-5 (80 percent / 1600 px); UIE-1, E2E-4 (computed override).
3. Add cases for the two new rows: `APP-60` (keyboard, focus ring, 320 px reflow) and `APP-65`
(no secret material in logs).
## IMPL-PLAN.md (retired)
1. Retire `docs/IMPL-PLAN.md`. Phases and Gates A/B/C are superseded by `MILESTONES.md` plus the
PoR; acceptance and test obligations are QUALITY rows.
2. Superseded: four knobs; "1–5 photos"; 80 percent / 1600 px; calculated-field override; portable
context and the stateful probe; prompt files.
## ARCHITECTURE.md
1. §1, §2, §4 "four knobs" including Edge offset and Feather — controls are exactly Sheet and
Sensitivity (`CAPTURE-40`).
2. §2 "1–5 photos" — one or more (`CAPTURE-25`).
3. §6.3 ready set requires 1+ `fig-*` tag and 1+ grade — tags are optional and grade is exactly
one (`METADATA-30`, `METADATA-15`).
4. §8/§9 shared validation module, `validation_hash`, 412, stateful/stateless mode, and
per-slab thread — ruled cut 2026-09-18; thread work is out of the MVP and test-inference no
longer returns mode.
5. Retire `docs/ARCHITECTURE.md`: its testable rules are APP/CAPTURE/SETTINGS rows, and the
topology is covered by those plus `deploy/`.
6. §3.4 Call 1 image input "all originals downscaled to 1024" → clean processed photos
(`METADATA-35`).
## UX.md (→ `docs/contracts/UX.md`) and UX-DESIGN.md (retired)
1. Migrate `docs/UX.md` to `docs/contracts/UX.md`: branch-neutral, cite requirement IDs, do not
restate rules.
2. Retire `docs/UX-DESIGN.md` (non-normative). Fold the "AI" badge, first-run card, separate
Title/Description screen, and error-state copy into the UX contract.
3. Superseded in UX.md: four knobs → two; "1 to 5 photos" → no cap; "no calibration photos" → the
calibration photo exists; 80 percent / 1600 px → 90 percent / 800 px; calculated-field override
path → read-only; grade 1+ → exactly one; `fig-*` 1+ → optional; "templates always run" →
template fallback; "title/short/description not required to reach ready" → required
(`REVIEW-10`); `short_title` → `short_description`.
## TECH-SPEC-PIPELINE.md (retired)
1. Retire `docs/TECH-SPEC-PIPELINE.md`. Its deterministic rules are CAPTURE; its ten vectors are
QUALITY. Fold any wanted algorithm detail into the CAPTURE capability doc.
2. Superseded: 80 percent fill and the 1600 px floor → 90 percent and 800 px; four knobs → two;
the auto-dimension overlay is no longer v1.5 (it shipped).
## PROMPTS.md (→ `docs/contracts/PROMPTS.md`)
1. Prompts move from server text files to the database with all revisions (`METADATA-105`): drop
the read-only mount, host-editing, and reset-to-default script.
2. Remove `previous_response_id`, the portable-context machinery, and the shared-validation rules
from the Call 2 inputs.
3. "1–5 inventory photos" → no cap (`CAPTURE-25`).
4. Example title uses an em dash and a multiplication sign → hyphen separator; snap `short_title`
to `short_description`.
## DEPLOYMENT.md (retired)
1. Retire `docs/DEPLOYMENT.md`. Its testable rules are OPS/SETTINGS; its operational facts live in
`deploy/` (`INVENTORY.md`, `docker-compose.yml`, `Caddyfile.fragment`, `RELEASE-PROCESS.md`).
2. Fold any still-useful prose into `deploy/` (the deploy README is the natural home).
3. Repoint the UAT-PRACTICE references from `docs/DEPLOYMENT.md` §1/§3/§5/§8 to `deploy/`.
4. Superseded: `output_px_min 1600` → 800 px floor (`CAPTURE-105`); "prompt files live in git and
need no backup" → prompts live in the DB with all revisions (GAP-8).
## DATA-MODEL.md (→ `docs/contracts/DATA-MODEL.md`)
1. Grade "1+" → exactly one; `fig_tag_ids` "1+" → optional.
2. `output_px_min 1600` and `fill_target 0.80` → 800 px floor and 90 percent fill.
3. "Only inventory photos. Calibration photos do not exist." → the calibration photo exists; other
batch photos are visual inventory only (`CAPTURE-115`).
4. "No hard deletes for slabs (audit)" → the local slab row is deleted on successful create
(`PUBLISH-25`).
5. Remove `inference_thread_id`; thread work is out of the MVP.
6. Prompt files: replace the bind-mounted text files with prompts stored in the DB, all revisions
(PoR row pending the PROMPTS walk).
7. `short_title` → `short_description` (Woo canonical).
## OPENAPI.md (→ `docs/contracts/API.md`)
1. Field name `short_title` → `short_description` (Woo canonical). Applies to the schema, read
model, Call 2 result, and examples.
2. `fill_target 0.80` and `output_px_min 1600` → 90 percent fill and an 800 px floor
(`CAPTURE-100`, `CAPTURE-105`).
3. Remove the validation module endpoint, `validation_hash`, and the 412 path (ruled cut).
4. Remove `previous_response_id`, the per-slab thread, and `mode` from test-inference; Call 2 is
a fresh full-context call.
5. Grade cardinality "1+" → exactly one; `fig-*` "1+" → optional.
6. The taxonomy-sync example key `synced_at` should be `taxonomy_last_sync_at`.
7. Call 1 image input for infer-taxon: original photos → clean processed photos (`METADATA-35`).
## PRODUCT.md
1. Line 19, "One or more `fig-*` tags" — tags are optional per `METADATA-30`. Change to
optional.
2. Line 23, "Measured values feed title and description templates" — template is the fallback;
the model writes when Call 2 runs. Cite `METADATA-60`, `METADATA-70`, `METADATA-80`. Same
conflict in the decision-rights table, line 37 ("Templates from listing facts").
3. Line 20, "short title" — stale wording; the field is `short_description` (`REVIEW-10`).
4. Branch-neutral: line 1 "Milestone status on `m3-ds`", and line 66 "behavior ... taken from
`main`". Remove branch names.
5. Line 25, umbrella term "Woo attributes" → "Woo Slab Taxonomy" (see cross-cutting rename).
## README.md
1. Branch-neutral: line 1 title "Docs map (branch `m3-ds`)" and line 44 "copied into
`docs/CREATE.md` from `main`". Remove branch names.
2. Line 39, umbrella "Woo attributes" → "Woo Slab Taxonomy" (see cross-cutting rename).
## UAT-PRACTICE.md
1. Line 100, "four knobs: Sheet, Sensitivity, Edge offset, Feather" — controls are exactly Sheet
and Sensitivity; Edge offset and Feather are removed (`CAPTURE-40`).
2. Line 97, "1 to 5 photos" — no cap; `CAPTURE-25` is one or more.
3. Line 122, "at least one figure, at least one grade" — grade is exactly one (`METADATA-15`).
4. Line 126, "`fig-*` tags. Pick at least one." — tags are optional (`METADATA-30`).
5. Branch-neutral: line 14 "On branch `m3-ds`". Stale refs to `docs/DEPLOYMENT.md`, `docs/UX.md`,
`docs/TECH-SPEC-PIPELINE.md`, `docs/IMPL-PLAN.md` in the "where each rule lives" table.
## Cross-cutting: "Woo Slab Taxonomy" rename
Ruling 2026-09-18: the umbrella package term is **Woo Slab Taxonomy**; canonical Woo
attributes (the five) keep the name.
- Rename umbrella occurrences: `AGENTS.md:48`, `PRODUCT.md:25`, `CREATE.md:7`, `CREATE.md:34`,
`CREATE.md:50` (heading), `CREATE.md:261`, `CREATE.md:397`, `README.md:39`.
- Keep "Woo attributes" where it means the canonical five: `CREATE.md:61`,
`CONTENT-WOO.md:121`.
- Give the term one glossary home in the step-3 contract docs; make PRODUCT/AGENTS reference
it instead of redefining.
@@ -1,211 +0,0 @@
# Requirements reconciliation — decision record
- Date: 2026-09-18
- Branch: `docs/clean-main`
- Participants: Ty + opencode
- Status: working record. Gets distilled into `docs/REQUIREMENTS.md`, then retired.
This captures the decisions made while consolidating the scattered requirements, so
context is not lost between sessions. Loose ends are collected in section 16.
## 0. Resume here (for a new session)
**SUPERSEDED 2026-09-18. Read `.audit/2026-09-18-session-handoff.md` instead.**
This section is kept only so the history below still reads correctly. The current state,
locked rulings, gate output, and next steps all live in the handoff. Everything below this
point is the decision log from the original reconciliation, not the current resume point.
## 1. Why this exists
Requirements are scattered across layers of docs (old `main` specs, current branch docs,
`archive/`), with duplicates and contradictions. Goal: one clean `main` holding all shipped
code plus one reconciled requirement set, with M3 scoped as a subset of it. Coding is paused
until that exists.
## 2. Git state established
1. `pre-simplification` tag created at `13bfa792bb3ed968b333675c85bcec0c451e2635` (old main tip; retains the deleted specs: ARCHITECTURE, IMPL-PLAN, OPENAPI, DATA-MODEL, TEST-SPEC, UX, UX-DESIGN, TECH-SPEC-PIPELINE, DEPLOYMENT, PROMPTS, Build-release-standard).
2. `main` fast-forwarded to `d754cac` (the M1>M2>M3 code line). `main` now holds all shipped code plus current docs.
3. Local `m3-ds` created, tracking `origin/m3-ds`.
4. Phase 1 scaffold committed as `b42e3cb` on branch `docs/clean-main`.
5. Nothing pushed. `origin/main` still at the old tip until we decide to push.
## 3. Process rules adopted
1. Coding is paused until the requirement set is reconciled.
2. Requirements live only in `docs/REQUIREMENTS.md`, one row per requirement, with stable IDs.
3. Milestones are expressed as explicit subsets of requirement IDs (`docs/MILESTONES.md`).
4. Conflict resolution rule: current/later docs plus the code that actually runs beat the tag and `archive/`. "Typically later docs prevail."
5. The structure is enforced by `tools/check_requirements.py` (registered docs, unique IDs, traceability).
6. Verification discipline: no "done" without raw evidence; CI is the only mechanical gate.
## 4. Namespaces (owner jobs, not tech layers)
1. `CAPTURE` — ecommerce-ready pics: intake, pick calibration photo, BG removal, crop, axis, length/width, calculations, inventory PNG, dimension overlay, lineup.
2. `METADATA` — building the listing: attributes and tags, title, short/deep description, the two inference calls.
3. `REVIEW` — confirm and correct: confidence display, field gating, tweaks, per-slab price suggestion and override, ready gate.
4. `PUBLISH` — WooCommerce create, draft/live, duplicate SKU, cleanup.
5. `SETTINGS` — configured before the session: Woo connection, inference config, species `$/bdft` rates, brand/geo.
6. `APP` — foundation: device draft, slab status, resume, server seams, secret handling.
7. `OPS` — foundation: deploy, hosts, release, UAT.
8. `QUALITY` — foundation: test obligations, vectors, anti-metrics.
## 5. Capture decisions
1. Mask controls are exactly two: `Sheet` (auto/green/black) and `Sensitivity` (0–100). Edge offset and Feather are dropped. The Sheet toggle itself is a candidate for removal (auto-detect stays).
2. Sensitivity changes are debounced 2000 ms; a Sheet change runs an immediate pass.
3. The owner uploads all photos of the slab, then selects one as the calibration photo. That photo drives every downstream step, is duplicated with dimensions added, and becomes the first showcase image.
4. Crop is 90% fill, shorter side at least 800 px when the source allows (shipped values beat the old 80%/1600).
5. Length and thickness are the only user-set numbers. Widths, sqft, and bdft are computed from the approved mask and are immutable in production (no typed override).
6. Widths are sampled every 6 inches along the approved axis, dropping the first and last 6 inches.
7. Thickness band uses the round-up model: measured `1"` lands in the `1–1.5"` store band.
8. Woo image order is locked: clean calibration PNG, then dimension-overlay copy, then remaining batch photos in the order added. Minimum two images per listing; no upper cap.
## 6. Ready set (submission-ready)
Required:
1. At least two images — clean first pic plus its dimension-overlay copy.
2. `sku` matching `[A-Z0-9-]{3,24}`.
3. `length_in` and `thickness_in`.
4. Widths / sqft / bdft (computed, immutable).
5. `categories` — exactly one species leaf.
6. `categories` — at least one wood-category leaf.
7. `attributes[Edge Type]` — one term.
8. `attributes[Figure]` — at least one term.
9. `attributes[Grade]` — exactly one term.
10. `attributes[Thickness]` — one term (round-up band).
11. `attributes[Moisture]` — one term, default kiln-dried.
12. `regular_price`.
13. `name`, `short_description`, `description`.
Optional: all Woo product tags (figure family and features family).
Woo schema field mapping lives in section 6 of this record and will become the `METADATA` requirements.
Note: title / short description / description are required on the product. Because deterministic templates are the inference-off fallback, they are guaranteed non-empty at create.
## 7. Taxonomy and categories
1. Categories sync from Woo; the product is assigned to leaf categories. Parents are never assigned but matter in context: the site shows them (breadcrumbs/filters) and Call 2 receives the parent path as context.
2. Species is exactly one synced leaf. Wood category is one or more synced leaves.
3. In review, the owner designates a primary wood category and any secondary ones. Default primary is the first category selected. How the owner corrects it, and how they stay aware of it, is a UX problem deferred to design.
4. Primary drives the title. Primary plus secondary drives the description prose.
5. The primary leaf selects a specialized Call 2 prompt. This is the "common pattern."
6. Wood-category leaves: Knife blanks, Specialty cuts, Dimensional wood, Live edge wood, Bowl blanks, logs and caps, Pen blanks, Pepper mill blanks. All are settled prose/prompt work items except `Specialty cuts`, which is parked.
7. All Woo product tags are optional. Tags come from the synced Woo set; no hardcoded names or prefixes. Wrinkle: one figure tag is bare `fig` (Figured), so a `fig-*` glob would drop it — the filter must be data-driven.
8. Current tags. Features: `feat-mincr` (Crack/Check minor), `feat-resin` (Resin Ready). Figure: `fig` (Figured), `fig-brdseye`, `fig-chatoy`, `fig-crotch`, `fig-curl`, `fig-fback`, `fig-flame`, `fig-hfig`, `fig-marbled`, `fig-quilted`, `fig-splt`.
## 8. Locked attributes
1. `attributes[Edge Type]` — 1 term.
2. `attributes[Figure]` — at least 1 term; includes `plain` for basic slabs; never left empty.
3. `attributes[Grade]` — exactly 1 term.
4. `attributes[Thickness]` — 1 term, round-up band.
5. `attributes[Moisture]` — 1 term, default kiln-dried, never inferred.
6. Missing attributes or terms are skipped, never created. v1 does not invent taxonomy.
## 9. Text generation (Call 2)
1. Call 2 produces `name`, `short_description`, and `description`.
2. The template is a consistency/style guide in the prompt; the model has creative license. It is not assembled deterministically in code.
3. With inference off, the deterministic template is the fallback that fills those three fields.
4. Call 2 receives all metadata confirmed or changed in review — not the raw Call 1 suggestions.
5. Dimensions are supplied as a literal fact block (species, figure term, length, thickness, average width, width min–max, board feet). The model uses those exact strings and invents no numbers.
6. There is no `L × W × T` rectangle assumption; slabs are irregular and width varies.
7. The dimension-overlay image is not sent to the model.
8. A post-check for number drift is parked as `later`; if it is ever added it is plain deterministic comparison, not an AI call.
9. The title separator is a hyphen: ` - `.
## 10. Inference transport
1. Calls are asynchronous: the request returns `202`, the client polls and shows phase feedback.
2. Phases: uploading, analyzing, still working. Polling is the heartbeat that distinguishes dead connectivity from a working model.
3. Tunable defaults: ack 15 s, upload-stall 20 s, overall inference deadline 120 s after ack, poll every 2 s backing off to 10 s.
4. On timeout or failure, fail gracefully with a plain message and a Retry action. Retry is offered only if `test-inference` previously passed. A retry replaces any in-flight job (idempotent).
5. Inference input is the clean processed image (masked, cropped), composited on soft white, no alpha, downscaled to at most 1024 px long edge, JPEG quality 80.
6. Only the pixel cap is exposed in Settings. JPEG quality 80 lives in a server config file.
7. Call 1 and Call 2 both receive the images. Call 2 additionally receives the literal fact block.
8. Provider threads are dropped; always full resend to the provider.
9. Inference never blocks create and never auto-publishes.
## 11. Prompt management
1. Prompts are stored in SQLite as immutable revisions, seeded from bundled repo defaults on first run.
2. Saving appends a revision; an active pointer selects which revision is live.
3. Reset to default re-activates the bundled default; reset to a revision re-activates that revision.
4. Each inference call records the revision id/hash it used.
5. A runtime debug flag gates both the Advanced settings UI and the prompt-write endpoints. Single build, not two.
6. This is post-M3 (`later`).
## 12. Pricing
1. A flat `$/bdft` rate per species is configured in Settings, matched to a synced species leaf. No tiers in v1.
2. The per-slab suggestion is `bdft × rate`; the owner may override and the override sticks. No rule means an empty price the owner types.
3. The price field and its suggestion live in the review screen.
## 13. Publish
1. Create sequence is the documented one: validate the ready set, look up the SKU (stop if it exists), refresh taxonomy if older than one hour, resolve categories/tags/attributes, upload inventory PNGs, `POST /products`, then finalize.
2. On success the local slab row is deleted. WooCommerce is the only source of truth; `sync_log` is retained; photos are purged; the device draft is cleared.
3. `SLAB-UAT-*` always creates a Woo draft.
4. A duplicate SKU blocks create; there is no update path.
5. A stale value on submit is a field-level error telling the owner to pick from the current list.
## 14. Settings
1. Compose env: `WOO_BASE_URL` (https only), `SLAB_AES_KEY`.
2. Encrypted in SQLite: `woo_wp_username`, `woo_app_password`, `inference_base_url`, `inference_api_key`.
3. Plain settings: `inference_model`, `inference_enabled`, `content_llm_enabled`, `woo_create_status` (default draft), `brand_voice`, `geo_context`.
4. Derived: `taxonomy_last_sync_at`, `taxonomy_anchor`.
5. Auth is a WordPress application password for a dedicated low-privilege user; not consumer keys, not the login password.
## 15. Scope mapping
Shipped: M1 Capture, M2 Photos, capture knob prefs.
M3: store-connection and AI-connection settings; taxonomy sync; the five attributes; Call 1; Call 2; title/description generation; review screen with field gating; pricing; publish; local draft cleanup.
Later: prompt-management UI, U2Net, offline capture, OCR/ruler, batch create, number-drift check, Specialty cuts prose, primary-category correction UX.
## 16. Open / parked items
1. `attributes` element shape on `POST /products` is open — needs a live Whidbey product sample or the official Woo REST fields. Do not invent.
2. Server persistence approach is open — full OpenAPI slab CRUD vs a thinner FastAPI path.
3. `Specialty cuts` leaf prose is parked.
4. Primary-category correction and awareness is a UX problem, deferred.
5. Number-drift check is `later`.
6. Prompt management is post-M3.
7. The QUALITY/OPS extraction re-run is still pending (the first pass was truncated).
8. "Later docs prevail" is the rule, but the tag/archive still hold material not yet reconciled (e.g. U2Net, browser floor, TES vectors, deploy details).
## 17. Work items
1. ~~Re-run the QUALITY/OPS extraction.~~ Done 2026-09-18 (TEST-SPEC ~110 rows; plan/deploy/release ~120 rows).
2. Write `docs/REQUIREMENTS.md` namespace by namespace, starting with `CAPTURE`.
3. Draft the per-leaf title/prose work once the `METADATA` section is being written.
4. Migrate capability/contract docs into their homes, make docs branch-neutral, rebuild the structure check, wire CI.
## 18. Rulings deferred to a final cleanup pass
These surfaced during the QUALITY/OPS extraction. They do not block writing `REQUIREMENTS.md`; park them and reconcile in the cleanup pass.
1. Test directory layout: old TEST-SPEC mandates Vitest and `frontend/src/__tests__/`; shipped code uses the Node test runner (`node --import tsx --test`) and `frontend/tests/`. Contradiction.
2. Test coverage target (~95% for deterministic modules) — adopt or drop.
3. "The Test Bar" role and who authors reports versus Ty's sign-off.
4. `docs/checks/check-structure.sh` gate timing and criteria — now replaced by `tools/check_requirements.py`.
5. Deploy ports: proposal `18080/18443` versus UAT `:10881`, and `APP_HOSTNAME` binding.
6. Backup retention "7 daily + 4 weekly" has no verification test.
7. `SLAB_AES_KEY_OLD` rekey env placement (host vs compose).
8. Smoke step 5: local `published` versus Woo `draft` mismatch — intended or not.
9. Release process: `Build-release-standard` points at GitHub while the repo is Gitea.
10. Taxonomy sync endpoint path divergence across docs.
11. Ownership of `deploy/INVENTORY.md` across Gate B vs Phase 6.
12. Scope labels: do shipped/M3/later map to phases or to release milestones.
13. `C1`/`C2` (Call 1 / Call 2) namespace ownership ambiguity.
14. Whether `UAT-PRACTICE.md` change control is a gate.
15. Definition of "real fixtures" acceptance for TV-3, TV-6, TV-7, TV-10.
16. `INF-1` ownership: SETTINGS toggle versus QUALITY suite obligation.
17. Server-side API surface (`revision_conflict`, validation module, slab CRUD) belongs to the open server-persistence decision.
18. `output_px_min` "never below 1600" from old DEPLOYMENT/IMPL-PLAN versus shipped 800 floor.
@@ -1,102 +0,0 @@
# Session handoff — requirements reconciliation
- Date: 2026-09-18
- Branch: `docs/clean-main`
- HEAD: `84d7681`. The last pushed tip was `1603467`.
- Working tree: clean. This session's work is committed as `84d7681`; not pushed yet.
- This file supersedes the stale "Resume here" section 0 of
`2026-09-18-reconciliation-decisions.md`.
## Read first
1. `docs/STRUCTURE.md` — structure contract, ID namespaces, the step-5 ID stride rule.
2. `docs/REQUIREMENTS.md` — the PoR. 136 requirements.
3. `.audit/2026-09-18-migration-todos.md` — deferred source-doc edits for step 3.
4. `.audit/2026-09-18-coverage-gaps.md` — the serial gap worklist (GAP-1..GAP-7).
5. `.audit/coverage/README.md` — the coverage ledger schema.
## Gates
```
python tools/check_requirements.py -> PASS, 12 docs, 8 namespaces, 136 requirements
python tools/check_coverage.py -> PASS, 40 atoms, 12 gaps, 20/136 IDs claimed
python tools/check_coverage.py --strict -> FAIL by design until coverage is complete
```
## What this session did
1. Renumbered every requirement in steps of 5 per namespace (see STRUCTURE.md). Nothing
cites IDs yet, so this was free. First IDs: `CAPTURE-5`, `METADATA-5`, etc.
2. Applied owner corrections: `PUBLISH-8` (no image cap), `METADATA-16` (template is the
fallback only), `REVIEW-8` (calculated fields are not mutable), `PUBLISH-30`
(attempt-level failure wording), `PUBLISH-35` and `PUBLISH-36/37/38` (error triage).
3. Walked `CAPTURE.md`, `PHOTOS.md`, `CREATE.md`, `CONTENT-WOO.md` section by section.
Added five rows: `CAPTURE-41`, `CAPTURE-141`, `METADATA-66`, `PUBLISH-31`,
`SETTINGS-51`.
4. Built the coverage method: `tools/check_coverage.py`, `.audit/coverage/` (schema plus a
settings/auth/health pilot ledger), and the gap worklist.
## Rulings locked this session (do not relitigate)
1. `METADATA-1`, `-2`, `-3` stay as written.
2. Audit decisions beat older specs. Title separator is the hyphen ` - `.
3. Grade is exactly 1. `fig-*` tags are optional. The Figure attribute is at least 1.
Ready set is two images; `REVIEW-5` is the PoR.
4. Tag scope is important: sync keeps only the figure and feature tag families so non-wood
Woo metadata does not pollute the workflow (`SETTINGS-51`).
5. Content-generation logging stays (`METADATA-66`), it is the trace/debug/tuning record.
6. Partial-publish orphaned media: log it, surface a manual cleanup note, no auto-delete
(`PUBLISH-31`).
7. Order of the coverage walk: current docs, then tag docs, then the archived PRD, then the
Gitea main design issues (#1–#20). TEST-SPEC is done LAST, reconciled against a finished
PoR.
## Open work
1. Continue the coverage walk doc by doc, section by section. Remaining current docs:
`PRODUCT`, `README`, `UAT-PRACTICE`. Then the tag docs, the archived PRD, the main
issues, and finally TEST-SPEC.
2. The pilot ledger `coverage/settings-auth-health.md` mixes sources. When a walked source
collides with it, split the ledger into per-source files first (this write was started
and reverted once; get explicit approval).
3. Resolve the coverage gaps in one pass, then step 3 migration
(`docs/capabilities/`, `docs/contracts/`, branch-neutral, cite IDs), then retire the
working records.
## Known gaps
1. `.audit/2026-09-18-coverage-gaps.md` has GAP-1..GAP-7, all open. GAP-1 (`test-woo`
failure semantics) and GAP-2 (health/`woo_reachable`) are the ones with the most
downstream content.
2. The coverage ledger currently reports 12 `gap` atoms from the pilot slice.
3. Still-open decisions: the `attributes` element shape on `POST /products`, and server
persistence (full CRUD vs a thinner path).
4. Discussed but unresolved: Woo credentials are stored server-side, so two phones share one
credential row and a wrong password locks both out. A "last-known-good plus auth-gated
promotion" rule was proposed; no requirement row yet.
## Process rules
1. This is planning work. Walk in plan mode (read-only); flip to build only for edits the
owner has explicitly approved.
2. No file gets created, edited, or deleted without the owner saying go for that specific
change.
3. Where a doc and the shipped code disagree, the as-built code is what the PoR documents.
4. Raw evidence for any "done": gate output, diff, or command output.
## Environment
1. Subagents `general` and `explore` are routed to the local SWIFT model
(`deepblu5090-8084/Swift-Qwen3.8-27B-Q5_K_M.gguf`) in the global config
`~/.config/opencode/opencode.jsonc`. Config loads at startup; restart after edits.
2. SWIFT is suitable for mechanical extraction only (read and list). Dispositions and
rulings stay with the owner. It never writes.
3. `.audit/docs-structure-pass.tsv` is finished history from 2026-09-09. Leave it.
## Commit
- `84d7681` — coverage gate, migration TODOs, session handoff. Working tree clean.
- Touches: `docs/REQUIREMENTS.md` (+5 rows), `docs/README.md`, `AGENTS.md`,
`.audit/2026-09-18-reconciliation-decisions.md`, and the new `.audit` files plus
`tools/check_coverage.py`.
- Not pushed to origin as of this handoff.
-7
View File
@@ -1,7 +0,0 @@
ts phase decision why evidence result
2026-09-09T19:43:56Z harness ran check-structure.sh on untouched main tip capture red baseline before structure edits docs/checks/check-structure.sh FAILED as expected (red baseline)
2026-09-09T19:43:56Z frame done predicate is check-structure.sh exit 0 with lock phrases intact falsifiable gate for structure pass .audit/docs-structure-pass.tsv open
2026-09-09T19:45:25Z unit AGENTS ownership complete; Gates table removed; UX-DESIGN demoted; IMPL-PLAN owns gate meanings subtract calendar restatement and close ownership gap AGENTS.md,docs/UX-DESIGN.md,docs/IMPL-PLAN.md check-structure PASSED
2026-09-09T19:45:25Z unit TEST-SPEC and deploy docs label missing tree as Phase 0 targets stop agents inventing harness paths that are not on main docs/TEST-SPEC.md,docs/DEPLOYMENT.md,deploy/docker-compose.yml,README.md check-structure PASSED
2026-09-09T19:45:25Z verify ran docs/checks/check-structure.sh after all edits prove done predicate on real artifact docs/checks/check-structure.sh exit 0 VERIFIED
2026-09-09T19:47:17Z attention-fix restored Gate B meaning to original AGENTS wording; hardened check-structure for both deploy files and fuller math lock phrase Attention review flagged Gate B parenthetical as lock drift and weak deploy grep docs/IMPL-PLAN.md,docs/checks/check-structure.sh check-structure PASSED
1 ts phase decision why evidence result
2 2026-09-09T19:43:56Z harness ran check-structure.sh on untouched main tip capture red baseline before structure edits docs/checks/check-structure.sh FAILED as expected (red baseline)
3 2026-09-09T19:43:56Z frame done predicate is check-structure.sh exit 0 with lock phrases intact falsifiable gate for structure pass .audit/docs-structure-pass.tsv open
4 2026-09-09T19:45:25Z unit AGENTS ownership complete; Gates table removed; UX-DESIGN demoted; IMPL-PLAN owns gate meanings subtract calendar restatement and close ownership gap AGENTS.md,docs/UX-DESIGN.md,docs/IMPL-PLAN.md check-structure PASSED
5 2026-09-09T19:45:25Z unit TEST-SPEC and deploy docs label missing tree as Phase 0 targets stop agents inventing harness paths that are not on main docs/TEST-SPEC.md,docs/DEPLOYMENT.md,deploy/docker-compose.yml,README.md check-structure PASSED
6 2026-09-09T19:45:25Z verify ran docs/checks/check-structure.sh after all edits prove done predicate on real artifact docs/checks/check-structure.sh exit 0 VERIFIED
7 2026-09-09T19:47:17Z attention-fix restored Gate B meaning to original AGENTS wording; hardened check-structure for both deploy files and fuller math lock phrase Attention review flagged Gate B parenthetical as lock drift and weak deploy grep docs/IMPL-PLAN.md,docs/checks/check-structure.sh check-structure PASSED
-57
View File
@@ -1,57 +0,0 @@
# Coverage ledger
Provenance for every requirement in `docs/REQUIREMENTS.md`. One row per source atom.
Kept under `.audit/` on purpose, so it is not part of the `docs/` structure contract.
`tools/check_coverage.py` reads every `*.md` in this directory except this file.
## Row format
```
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
```
- **Source** — where the atom came from, prefixed by kind: `tag:TEST-SPEC`, `tag:OPENAPI`,
`prd`, `cur:CREATE`, `issue:#5`, `audit:14`.
- **Atom** — the stable key inside that source, e.g. `H-1`, `settings-PUT-reject`.
- **Requirement** — a short restatement, enough to recognise it.
- **Disposition** — one of the values below.
- **PoR ID** — the `docs/REQUIREMENTS.md` ID(s), comma-separated. `—` when none.
- **Rationale** — required for everything that is not plain `ported`.
## Dispositions
| Value | Meaning | PoR ID | Rationale |
|---|---|---|---|
| `ported` | fully represented by the cited row(s) | required | optional |
| `duplicate` | same rule as another atom | optional | required |
| `new` | PoR row with no source atom; a reconciliation decision | required | required |
| `superseded` | replaced by a later source or decision | optional | required |
| `deferred` | intentionally out of current scope | optional | required |
| `dropped` | deliberately discarded | `—` | required |
| `gap` | not represented, not deliberately dropped: an open hole | `—` | required |
`gap` is the finding. It is the thing this ledger exists to surface.
## Checks
Forward (always):
1. Every row has six well-formed columns and a known disposition.
2. A `(Source, Atom)` pair is unique across the ledger.
3. `ported` and `new` cite at least one defined requirement ID; every cited ID resolves.
4. `dropped` and `gap` carry no ID and a non-empty rationale.
5. Every non-`ported` row carries a non-empty rationale.
Reverse (`--strict` only, once coverage is complete):
6. Every ID in `docs/REQUIREMENTS.md` is claimed by at least one atom, or is `new`.
7. No `gap` rows remain.
## Run
```
python tools/check_coverage.py
python tools/check_coverage.py --strict
python tools/check_coverage.py --ledger-dir <path> # for negative tests
```
-8
View File
@@ -1,8 +0,0 @@
# Coverage ledger — cur:CONTENT-WOO
Source `cur:CONTENT-WOO` (`docs/CONTENT-WOO.md`, current branch). Seeded by the
settings/auth/health pilot 2026-09-18.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| cur:CONTENT-WOO | httpx-timeouts | httpx connect 10s, read 60s | dropped | — | connection detail; recorded in docs/contracts in step 3 |
-9
View File
@@ -1,9 +0,0 @@
# Coverage ledger — cur:CREATE
Source `cur:CREATE` (`docs/CREATE.md`, current branch). Seeded by the settings/auth/health
pilot 2026-09-18.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| cur:CREATE | settings-url | WOO_BASE_URL from env, read-only; change means edit .env and recreate | ported | SETTINGS-5 | — |
| cur:CREATE | test-inference-shape | test-inference returns ok, mode, vision_capable, detail | ported | SETTINGS-45 | mode removed by ruling; SETTINGS-45 now returns ok, vision_capable, detail |
-19
View File
@@ -1,19 +0,0 @@
# Coverage ledger — README.md (docs map)
Source `cur:README` (`docs/README.md`, role: index). Walked 2026-09-18. Navigation-heavy;
only the testable statements carry PoR IDs.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| cur:README | high-level-goal | one slab is one unit and one listing; capture flow | dropped | — | intent restatement; listing invariant covered by PUBLISH-60 |
| cur:README | implemented-m1-m2 | M1/M2 shipped, UAT on `:10881` | ported | OPS-40 | — |
| cur:README | no-woo-create-yet | no Woo create, backend stub, inference unwired | dropped | — | historical branch state, not a requirement |
| cur:README | m3-exit-criteria | ready set plus a real `SLAB-UAT-*` draft with inference off | ported | QUALITY-110, REVIEW-5, METADATA-70 | — |
| cur:README | app-password-server-side | WordPress application password, server-side only | ported | SETTINGS-15, SETTINGS-20, APP-25 | — |
| cur:README | sync-taxonomy | sync and use the synced taxonomy | ported | METADATA-5 | — |
| cur:README | pricing-species-rate | price from species `$/bdft` with user override | ported | REVIEW-25, SETTINGS-35 | — |
| cur:README | inference-settings-calls | inference Settings and Call 1 / Call 2 rules | ported | SETTINGS-30, SETTINGS-45, METADATA-35, METADATA-55 | — |
| cur:README | review-ready-create | review to the ready set, then create, force draft | ported | REVIEW-5, PUBLISH-10 | — |
| cur:README | copied-from-main | technical detail copied from `main` | dropped | — | branch-neutral migration, not a requirement |
| cur:README | folder-map | how to read the folder table | dropped | — | navigation |
| cur:README | deploy-reminders | UAT `:10881` not `:10880`; env vs encrypted settings | ported | OPS-40, OPS-10, SETTINGS-5, SETTINGS-20, SETTINGS-30 | — |
-11
View File
@@ -1,11 +0,0 @@
# Coverage ledger — issues
Source `issue:#n` (Gitea `Ty_Tech/SlabUploader` design issues). Seeded by the
settings/auth/health pilot 2026-09-18. The issue Source column distinguishes each issue.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| issue:#5 | app-password | app password, dedicated user, AES-GCM, browser never holds it, sole caller | ported | SETTINGS-15, SETTINGS-20, APP-25 | — |
| issue:#11 | url-compose | WOO_BASE_URL declared in compose, not user-editable | ported | SETTINGS-5 | — |
| issue:#14 | taxonomy-refresh | taxonomy refresh triggers and anchor | ported | SETTINGS-50, SETTINGS-55, SETTINGS-60 | — |
| issue:#6 | instrument-health | health checks and instrumentation | ported | APP-45 | — |
-185
View File
@@ -1,185 +0,0 @@
# Coverage matrix — ARCHITECTURE.md
Source: `docs/ARCHITECTURE.md` at tag `pre-simplification`.
Method: subagent extracted every surface entry; PoR home assigned here.
Dispositions: `ported`, `superseded`, `dropped`, `ui`, `missing`.
## SETTINGS KEYS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| inference_enabled | SETTINGS-30, SETTINGS-90 | ported | default off added |
| Species $/bdft | SETTINGS-35 | ported | — |
| prompts (as settings) | METADATA-105 | superseded | prompts live in the DB, not settings |
| Inference endpoint base URL | SETTINGS-30 | ported | — |
| Inference API key | SETTINGS-30 | ported | — |
| Woo credentials | SETTINGS-15, SETTINGS-20 | ported | — |
| publish status | SETTINGS-65 | ported | — |
| Store URL env, read-only | SETTINGS-5 | ported | — |
| SLAB_AES_KEY | SETTINGS-25 | ported | — |
| WOO_BASE_URL | SETTINGS-5, SETTINGS-10 | ported | — |
| sheet mode | CAPTURE-40, CAPTURE-145 | ported | — |
| sensitivity | CAPTURE-41, CAPTURE-145 | ported | — |
| edge offset | CAPTURE-40 | superseded | removed control |
| feather | CAPTURE-40 | superseded | removed control |
| reset-to-default | CAPTURE-145 | ported | — |
| Species list with $/bdft | SETTINGS-35 | ported | — |
| Taxonomy snapshot | SETTINGS-50 | ported | — |
## CONTROLS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| New Wood 1–5 photos | CAPTURE-25 | superseded | no cap |
| Four mask knobs | CAPTURE-40 | superseded | two knobs |
| Mask edge OK | CAPTURE-50 | ported | — |
| Retune knobs / Retake | CAPTURE-50, CAPTURE-155 | ported | — |
| Length axis rotate/confirm | CAPTURE-60, CAPTURE-65 | ported | — |
| User length/thickness/SKU | CAPTURE-70 | ported | — |
| Call 1 pre-fill / manual taxonomy | METADATA-45, METADATA-50 | ported | — |
| Generate text button | METADATA-55, METADATA-110 | ported | availability added |
| Publish | PUBLISH-5 | ported | — |
| Settings "Refresh taxonomy" control | SETTINGS-60 | ui | optional; not required for POC |
| Inline nudges | REVIEW-20 | ported | — |
## LISTING FIELDS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Photos 1–5 | CAPTURE-25 | superseded | no cap |
| SKU | REVIEW-45 | ported | — |
| length / thickness | CAPTURE-70 | ported | — |
| sqft / bdft / 6in widths | CAPTURE-80, CAPTURE-85, CAPTURE-90 | ported | — |
| 3:4 PNG | CAPTURE-95, CAPTURE-100 | ported | — |
| title / short_title / description | REVIEW-10 | ported | short_title naming migrated |
| species | METADATA-10 | ported | — |
| wood categories | METADATA-10 | ported | — |
| edge / figure | METADATA-15 | ported | — |
| grade | METADATA-15 | ported | cardinality superseded |
| feat-* | METADATA-30 | ported | — |
| fig-* | METADATA-30 | ported | cardinality superseded |
| price | REVIEW-25 | ported | — |
| moisture | METADATA-15 | ported | — |
| originals | PUBLISH-25, PUBLISH-40 | ported | purge after publish |
| woo_product_id | PUBLISH-25 | ported | — |
## STATUSES
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| draft | APP-5 | ported | — |
| calibrated | APP-5 | ported | — |
| ready | APP-5, REVIEW-5 | ported | fig/grade cardinality superseded |
| publishing | APP-5 | ported | — |
| published | APP-5, PUBLISH-25 | ported | — |
| failed | APP-5, PUBLISH-30 | ported | — |
| transitions draft→...→published/failed | APP-5 | ported | — |
## ENDPOINTS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| /wp-json/wc/v3/ | SETTINGS-15 | ported | — |
| /api/v1/settings | SETTINGS-20, SETTINGS-30 | ported | — |
| /api/v1/admin/taxonomy | SETTINGS-50 | ported | — |
| POST /api/v1/settings/test-woo | SETTINGS-40, SETTINGS-80 | ported | — |
| GET /api/v1/validation/module | — | dropped | validation module ruled cut |
| POST /api/v1/settings/test-inference | SETTINGS-45 | ported | — |
## ERROR CODES
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| 409 duplicate SKU only | PUBLISH-15 | ported | — |
| 422 stale value | PUBLISH-20 | ported | — |
| 412 validation hash | — | dropped | validation module ruled cut |
| "Never show 412" | PUBLISH-35 | dropped | moot after cut |
## USER-FACING ERROR / EMPTY STATES
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Call 1 runtime failure → retry or message | METADATA-115 | ported | recoverable retries, unrecoverable explains |
| Below 0.7 fields stay empty | METADATA-45 | ported | — |
| Duplicate SKU field error | PUBLISH-15 | ported | — |
| Stale value field error | PUBLISH-20 | ported | — |
| Review gate empty subset | REVIEW-5, REVIEW-20 | ported | — |
| Inline nudges, not submit-only | REVIEW-20 | ported | — |
| UI "Updating rules" | — | dropped | validation module cut |
| failed error detail stored, retry | PUBLISH-30 | ported | — |
## INFERENCE
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Call 1 auto-run only if inference_enabled | METADATA-35, SETTINGS-90 | ported | — |
| Call 1 skipped when disabled | METADATA-70 | ported | — |
| Call 1 input: all originals downscaled to 1024 | METADATA-35 | superseded | PoR sends the clean processed photos |
| Call 1 input: synced taxonomy only | METADATA-35, METADATA-40 | ported | — |
| Call 1 input: SKU/length/thickness | METADATA-35 | ported | — |
| Call 1 timeout 45s | METADATA-115 | ported | runtime failure triage |
| Threshold ≥0.7 pre-populate | METADATA-45 | ported | — |
| Below 0.7 empty | METADATA-45 | ported | — |
| Manual entry authoritative | METADATA-50 | ported | — |
| Call 2 no auto-fire | METADATA-55 | ported | — |
| Call 2 portable context / full resend | — | superseded | thread work out of MVP |
| previous_response_id | — | superseded | thread work out of MVP |
| Call 2 confirmed vs inferred | METADATA-60 | ported | — |
| per-slab thread, delete on submit/abandon | — | superseded | thread work out of MVP |
| test-inference mode stateful/stateless/fail | SETTINGS-45 | ported | — |
| fail blocks Call 2 only | SETTINGS-45 | ported | — |
| Call 1/2 assist-only, do not gate publish | METADATA-75 | ported | — |
| low-confidence does not auto-fill | METADATA-45 | ported | — |
## PUBLISH PAYLOAD
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Draft upload contents | CAPTURE-135 | ported | — |
| Originals needed for retry | PUBLISH-40 | ported | — |
| Post-publish deletion | PUBLISH-25 | ported | — |
| Duplicate SKU handling | PUBLISH-15 | ported | — |
| Stale value handling | PUBLISH-20 | ported | — |
| Product status per setting | SETTINGS-65, PUBLISH-10 | ported | — |
| Title/desc typed, generated, or templated | REVIEW-10, METADATA-70 | ported | templates are fallback |
| Publish poll | PUBLISH-5 | ported | — |
| woo_product_id stored | PUBLISH-25 | ported | — |
| Images purged | PUBLISH-25 | ported | — |
| Publish resync > 1 hour | SETTINGS-60 | ported | — |
| Submit carries validation_hash / 412 retry | — | dropped | validation module cut |
| Woo-shaped 422 store rejection | APP-50, PUBLISH-20 | ported | — |
| Taxonomy: categories/attributes/fig-* synced | SETTINGS-50, SETTINGS-51 | ported | scope narrowed to fig/feat |
## CONSTANTS / NUMBERS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| 1–5 photos | CAPTURE-25 | superseded | no cap |
| downscale 1024 | METADATA-35 | ported | source conflict noted |
| Call 1 timeout 45s | METADATA-115 | ported | runtime failure triage |
| confidence 0.7 | METADATA-45 | ported | — |
| 3:4 | CAPTURE-100 | ported | — |
| 80% fill | CAPTURE-100 | superseded | 90 percent |
| 6in widths | CAPTURE-80 | ported | — |
| SKU charset 3..24 | REVIEW-45 | ported | case widened |
| taxonomy staleness 1 hour | SETTINGS-60 | ported | — |
| ready cardinalities (PNG/species/wood/edge/figure/fig/grade/moisture/feat) | REVIEW-5 | ported | fig/grade superseded |
| five attributes | METADATA-15 | ported | — |
## BROWSER / API SUPPORT
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Browser does not call Woo or vision | APP-25 | ported | — |
| no CORS to store | APP-25 | ported | — |
| localStorage persistence | CAPTURE-145 | ported | — |
| client BG removal APIs | CAPTURE-140, CAPTURE-35 | ported | — |
| camera | CAPTURE-25, CAPTURE-20 | ported | — |
| no U2Net control (POC) | CAPTURE-165 | ported | — |
| U2Net deferred, not deleted | CAPTURE-165 | ported | — |
| title/desc Woo maxLength | REVIEW-50 | ported | Woo v3 documents no max; caps in `docs/contracts/WOO-API.md` §11 |
## Findings
None open. Runtime inference failure is `METADATA-115`; the Call 1 image source is
`METADATA-35`; title/description length caps are inherent Woo limits.
-158
View File
@@ -1,158 +0,0 @@
# Coverage matrix — CREATE.md (current branch)
Source: `docs/CREATE.md` (current working tree).
Method: every surface entry dispositioned to PoR IDs or a recorded drop.
## Findings
None new. Two open decisions in the doc are already settled elsewhere: the `attributes` element
shape is resolved in `docs/contracts/WOO-API.md` §4, and the crop fill/floor (90% / 800) is the
PoR. The client stack note ("Vite + Svelte SPA") is implementation, not a requirement.
## Superseded (queued in migration TODOs)
- ready set "at least one inventory PNG" → REVIEW-5 (two images)
- grade 1+ → METADATA-15 (exactly one)
- `fig-*` 1+ required → METADATA-30 (optional)
- `short_title` → REVIEW-10 (`short_description`)
- "user typed number wins if overridden" for widths/sqft/bdft → REVIEW-30, REVIEW-40 (read-only)
- "title/short/desc not required to reach ready" → REVIEW-10
- "templates always available" and "server assembles from templates" → METADATA-70, METADATA-80
(model writes when Call 2 runs; template is fallback)
- "Cap at 5 images" / "Cap ≤5" → PUBLISH-40 (no cap)
- Call 1 input "inventory photos downscaled to 1024" → METADATA-35 (clean processed photos)
## SETTINGS KEYS
- `WOO_BASE_URL` env, read-only → SETTINGS-5; HTTPS → SETTINGS-10
- `SLAB_AES_KEY` env → SETTINGS-25
- `woo_wp_username` / `woo_app_password` encrypted → SETTINGS-20
- `woo_create_status` default draft → SETTINGS-65
- `inference_base_url`, `inference_api_key`, `inference_model`, `inference_enabled` → SETTINGS-30
- `content_llm_enabled` → SETTINGS-30, SETTINGS-90
- `taxonomy_last_sync_at`, `taxonomy_anchor` → SETTINGS-55
- `brand_voice`, `geo_context` → SETTINGS-30
## CONTROLS
- store URL read-only → SETTINGS-5
- credential entry → SETTINGS-20
- Generate text disabled when endpoint down → METADATA-110, METADATA-75
- Call 2 blocked when test-inference `fail` → SETTINGS-45
- inline nudges → REVIEW-20
- species picker synced only → METADATA-5, METADATA-10
- attribute pickers synced only → METADATA-15
- stale field / vanished id → PUBLISH-20, REVIEW-35
- duplicate SKU actions → PUBLISH-15
- create/publish trigger → PUBLISH-5
- draft upload trigger → CAPTURE-135
## LISTING FIELDS
- `sku` → REVIEW-45; `status` enum → APP-5
- `length_in`, `thickness_in` 1/8 → CAPTURE-70
- `sqft`, `bdft`, widths → CAPTURE-80, CAPTURE-85, CAPTURE-90
- `species_id` 1 leaf → METADATA-10; `wood_category_ids` 1+ → METADATA-10
- `edge_type_term_id` 1 → METADATA-15; `figure_term_ids` 1+ → METADATA-15
- `thickness_term_id` round-up → METADATA-15; `moisture_term_id` default kiln-dried → METADATA-15
- `feat_tag_ids` 0+ → METADATA-30
- `price`, `price_per_bdft`, `price_source` → REVIEW-25
- templates (title, short, description, `{character}`, `{geo_sentence}`) → contract detail in
`docs/contracts/CONTENT-WOO.md`; fallback only
- species/character/length/free-text thickness not attributes → METADATA-25
## STATUSES
- draft/calibrated/ready/publishing/published/failed → APP-5
- `woo_create_status` draft/publish → SETTINGS-65
- test-inference mode stateful/stateless/fail → SETTINGS-45
- device draft is not a Woo draft → APP-10
## ENDPOINTS
- `/api/health` → APP-30, APP-45
- slabs CRUD, photos, infer-taxon, infer-content, publish, taxonomy, pricing, settings →
`docs/contracts/API.md`
- Woo endpoints (`/wc/v3/products`, categories, tags, attributes, media) →
`docs/contracts/WOO-API.md`
## ERROR CODES
- 502 `woo_error` → SETTINGS-50, PUBLISH-30
- 422 `unpublishable` → REVIEW-5
- 409 `duplicate_sku` → PUBLISH-15
- 202 publish → contract
## USER-FACING ERROR / EMPTY STATES
- sync failure keeps previous cache → SETTINGS-50
- create blocked until ready → REVIEW-5, REVIEW-20
- duplicate SKU block → PUBLISH-15
- stale field, current options → PUBLISH-20
- vanished id not silently swapped → REVIEW-35
- failure: log, mark failed, keep photos, field-level error, no raw code → PUBLISH-30, PUBLISH-35
- low-confidence not shown as defaults → METADATA-45
- Generate text down → METADATA-110
- no pricing rule → price empty, owner types → REVIEW-25
## INFERENCE
- Call 1 auto only when enabled → METADATA-35
- Call 1 input (photos, metadata, taxonomy) → METADATA-35
- Call 1 output + confidence → METADATA-40
- threshold 0.7 pre-fill / below empty → METADATA-45
- moisture never inferred → METADATA-15
- typed wins → METADATA-50
- never blocks create / auto-publish → METADATA-75
- Call 2 user-triggered; requires test-inference ok → METADATA-55, SETTINGS-45
- model prose only, no dimensions → METADATA-65
- templates fallback → METADATA-70
- test-inference shape, fail blocks Call 2 only → SETTINGS-45
- templates math from deterministic pipeline → METADATA-90
- inference-off create → QUALITY-20, METADATA-70
- FastAPI proxies inference → APP-25
## PUBLISH PAYLOAD / FLOW
- payload fields (`name`, `type`, `status`, `sku`, `regular_price`, `description`,
`short_description`, `categories`, `tags`, `images`, `attributes`, `virtual`, `downloadable`,
`catalog_visibility`) → `docs/contracts/WOO-API.md`, PUBLISH-55
- non-null only → PUBLISH-55
- `SLAB-UAT-*` force draft → PUBLISH-10
- images upload keep alpha, lineup order → PUBLISH-40, PUBLISH-45
- published meaning (not live) → PUBLISH-50
- success behavior (product id, purge, delete row, clear device draft) → PUBLISH-25, APP-15
- failure behavior → PUBLISH-30
- create sequence steps 1–4 → PUBLISH-5
- `images alt` text → contract detail
- attributes element shape → resolved, `docs/contracts/WOO-API.md` §4
## CONSTANTS / NUMBERS
- `per_page=100` → SETTINGS-50, WOO-API §7
- connect 10s / read 60s → contract detail
- taxonomy staleness 1 hour → SETTINGS-60
- SKU `[A-Z0-9-]{3,24}` → REVIEW-45
- downscale 1024 → METADATA-35
- confidence 0.7 → METADATA-45
- bdft and price formulas → CAPTURE-90, REVIEW-25
- 1/8 step → CAPTURE-70
- five attributes → METADATA-15
- UAT port `:10881`, do not touch `:10880` → OPS-40
- fill 90% / 800px floor → CAPTURE-100, CAPTURE-105
## BROWSER / API SUPPORT
- FastAPI only Woo caller; browser no creds → APP-25
- app-password Basic auth, not consumer keys → SETTINGS-15
- HTTPS only → SETTINGS-10
- proxy inference, no key in browser → APP-25, SETTINGS-30
- device draft IndexedDB → APP-10, CAPTURE-140
- Woo source of truth, cache ids → METADATA-5
- v1 no term creation → METADATA-20
- taxonomy sync order, transactional, anchor, re-pull, triggers → SETTINGS-50, SETTINGS-55,
SETTINGS-60
- pricing rules flat per species, seed, override sticks, manual allowed → SETTINGS-35, REVIEW-25
- practice rules (`SLAB-UAT-*`, hidden, inference off) → PUBLISH-10, QUALITY-20
- dishonest proxies → QUALITY-105
- tag/leaf filters and feat mapping → SETTINGS-51, METADATA-10, METADATA-30, METADATA-100
-83
View File
@@ -1,83 +0,0 @@
# Coverage matrix — CONTENT-WOO, CAPTURE, PHOTOS (current branch)
Sources: `docs/CONTENT-WOO.md`, `docs/CAPTURE.md`, `docs/PHOTOS.md` (current working tree).
## Findings
None new. Two notes: the copy-template storage location ("templates live in Settings") is
contract detail for `docs/contracts/CONTENT-WOO.md`, and the case-insensitive name-matching rule
is legacy — the review flow selects from pickers, so it is superseded by `METADATA-5`.
## CONTENT-WOO.md
Superseded: `short_title` → REVIEW-10; `fig-*` 1+ required → METADATA-30; "≤5 inventory photos" →
PUBLISH-40; "Only kind=inventory photos / calibration photos do not exist" → calibration exists
(CAPTURE-25, CAPTURE-30); "check against the shared rules (current copy)" → validation module cut
(REVIEW-5); case-insensitive name matching → METADATA-5.
Ported:
- `content_llm_enabled`, `content_source` → SETTINGS-30, METADATA-66
- `geo_sentence` from Settings, default none → SETTINGS-30
- `woo_create_status` default draft → SETTINGS-65
- `WOO_BASE_URL` env HTTPS; `SLAB_AES_KEY` → SETTINGS-5, SETTINGS-10, SETTINGS-25
- templates title/short/description and `{character}` → contract detail
`docs/contracts/CONTENT-WOO.md`
- categories species 1 + wood 1+, tags fig/feat, five attributes → METADATA-10, METADATA-15,
METADATA-30
- ready set → REVIEW-5 (fig/grade cardinality superseded)
- payload fields → `docs/contracts/WOO-API.md`, PUBLISH-55
- `type simple`, `virtual false`, `downloadable false`, `catalog_visibility visible` → PUBLISH-55
- image bytes keep alpha, `POST /media` → PUBLISH-40, WOO-API §5
- `SLAB-UAT-*` force draft → PUBLISH-10
- status `draft`/`publish`, published meaning → SETTINGS-65, PUBLISH-50
- create sequence steps 1–7 → PUBLISH-5
- dedupe every time → PUBLISH-15
- post-create delete originals + processed → PUBLISH-25
- failure: field message, mark failed, keep photos → PUBLISH-30, PUBLISH-35
- partial-failure orphaned media manual cleanup → PUBLISH-31
- taxonomy sync order, `per_page=100`, 1-hour reconcile, anchor → SETTINGS-50, SETTINGS-55,
SETTINGS-60
- vision 0.7 fills; vision off typed; typed wins → METADATA-45, METADATA-50, METADATA-70
- model writes prose only, synced choices only, no invented dimensions → METADATA-40, METADATA-65
- httpx timeouts 10s/60s → contract detail
- wire `{field, code, message, actions[]}`, codes never shown → PUBLISH-35, API contract
## CAPTURE.md
Ported:
- Sheet control, Sensitivity 0–100 → CAPTURE-40, CAPTURE-41
- immediate value update, 2.0 s recompute, last good overlay → CAPTURE-45
- approve blocked with no slab pixels → CAPTURE-50
- length axis rotate/approve → CAPTURE-60
- length/thickness 1/8, Calculate → CAPTURE-70
- scale `longest_axis_px / L` → CAPTURE-75
- widths every 6 in, drop first/last 6, `L ≤ 12` all zero → CAPTURE-80
- `sqft = mask_pixels / scale² / 144`, 2 dp → CAPTURE-85
- `bdft = sqft × thickness_in`, 2 dp → CAPTURE-90
- 3:4, 90% fill, shorter side ≥ 800, no upscale → CAPTURE-100, CAPTURE-105
- PNG 8-bit RGBA, alpha kept → CAPTURE-95
- browsers and hard stop → CAPTURE-10
- client APIs and no OpenCV/ONNX/U2Net → CAPTURE-140
- preview display-only, source pixels → CAPTURE-141
- device draft, Resume/Continue/Start fresh → APP-10
- 1/8 steps → CAPTURE-70; sensitivity maps green/black → CAPTURE-41
Dropped: busy-mark "fraction of a second" (nit, migration TODO); "other backgrounds out of M1"
(scope); M1 single reference photo superseded by batch (CAPTURE-25).
## PHOTOS.md
Ported:
- batch one or more, continue at least one → CAPTURE-25
- pick calibration photo → CAPTURE-25
- M1 states on the calibration photo only → CAPTURE-30
- Photos ready lineup order → CAPTURE-130
- per-photo empty mask / size floor excluded, rest proceed → CAPTURE-110
- single-photo batch works → CAPTURE-25
- crop rules reuse (3:4, 90%, PNG, floor) → CAPTURE-100, CAPTURE-105
- overlay black; stations every 12 in from 6 in, while ≤ L−6; EXIF bottom → CAPTURE-120,
CAPTURE-125
- 1–N photos → CAPTURE-25
- no inference / Woo create on this path → APP-25
No findings.
-39
View File
@@ -1,39 +0,0 @@
# Coverage matrix — Gitea design issues #1–#20
Source: `gitea.vps1.afterthedemo.com/Ty_Tech/SlabUploader` issues #1–#20 (design issues).
Method: every issue dispositioned to PoR IDs or a recorded drop.
## Findings
None open. Issue #6's structured logging, request id, and `LOG_LEVEL` are now `APP-55`.
## Issue map
| Issue | Atom | Disposition | PoR |
|---|---|---|---|
| #1 | portable multi-turn context (closed, superseded by #2) | superseded | thread work out; Call 2 fresh full context METADATA-60 |
| #2 | portable multi-turn context | ported | METADATA-60, SETTINGS-45 |
| #2 | per-slab thread, delete on submit/abandon | superseded | thread work out |
| #2 | confirmed vs inferred labels | ported | METADATA-60 |
| #3 | duplicate SKU means "any status" | ported | PUBLISH-15 |
| #4 | browser compatibility floor + hard stop + load-bearing APIs | ported | CAPTURE-10, CAPTURE-140 |
| #5 | Woo auth via application passwords, server-side, AES-GCM | ported | SETTINGS-15, SETTINGS-20, APP-25 |
| #6 | health checks DB + image-volume writability | ported | APP-45 |
| #6 | sync_log + inference_log written and queryable | ported | PUBLISH-30, METADATA-66 |
| #6 | structured logging, request id phone→FastAPI→Woo, LOG_LEVEL | ported | APP-55 |
| #7 | Prometheus metrics, alerting, log retention (V1) | dropped | out of M3 scope |
| #8 | Call 1 pre-populates review fields when enabled | ported | METADATA-45 |
| #9 | 0.7 threshold prefill; suite passes with inference off | ported | METADATA-45, QUALITY-20 |
| #10 | per-field inline nudges; UX presentation pass | ported | REVIEW-20; docs/contracts/UX.md |
| #11 | WOO_BASE_URL compose-only, read-only, HTTPS, not user-editable | ported | SETTINGS-5, SETTINGS-10, SETTINGS-85 |
| #12 | only synced species selectable | ported | METADATA-5 |
| #13 | store-admin creates a species leaf (V1) | dropped | out of M3 scope |
| #14 | taxonomy anchor, client re-pull, triggers | ported | SETTINGS-50, SETTINGS-55, SETTINGS-60 |
| #15 | BG knobs inline, live re-run, mask on source photo | ported | CAPTURE-45, CAPTURE-150 |
| #15 | four knobs | superseded | CAPTURE-40 |
| #15 | client-only POC; U2Net deferred | ported | CAPTURE-165 |
| #16 | V1 instrumentation | dropped | out of M3 scope |
| #17 | shared validation module, versioned, submit-checked | dropped | validation module cut |
| #18 | 409/422 mapped to field-level recovery, no top banners | ported | PUBLISH-15, PUBLISH-20, PUBLISH-35 |
| #19 | POC acceptance test cases | duplicate | TEST-SPEC owns the test plan |
| #20 | TEST-SPEC review feedback | dropped | process; TEST-SPEC reconciled last |
-152
View File
@@ -1,152 +0,0 @@
# Coverage matrix — OPENAPI.md
Source: `docs/OPENAPI.md` at tag `pre-simplification`.
Method: subagent extracted every surface entry; PoR home assigned here.
Dispositions: `ported`, `superseded`, `dropped` (contract detail), `ui`, `missing`.
## Findings
None. Three items are contract detail (photo 40 MB request cap, `GET /meta`, `inference_error`
field), and the schema conflicts are already resolved in the PoR.
## SETTINGS KEYS
- `woo_base_url` read-only → SETTINGS-5
- `woo_base_url` not writable, reject write → SETTINGS-85
- `woo_wp_username` / `woo_app_password` write-only, AES-GCM → SETTINGS-20
- reject `woo_consumer_key` / `woo_consumer_secret` → SETTINGS-15
- `woo_credentials_configured` boolean → SETTINGS-75
- secrets masked, never echoed → SETTINGS-75
- `inference_base_url`, `inference_model`, `inference_enabled`, `content_llm_enabled`,
`brand_voice`, `geo_context`, `woo_create_status` → SETTINGS-30, SETTINGS-65
- `aspect_ratio` 3:4 → CAPTURE-100
- `output_px` / `output_px_min` 1600, `fill_target` 0.80 → superseded CAPTURE-105
- client prefs not in SettingsView → CAPTURE-145
- PUT partial body, re-encrypt, no echo → SETTINGS-20, SETTINGS-30
- `inference_enabled` default `true` in the example → superseded SETTINGS-90 (default off)
## CONTROLS
- pickers limited to synced taxonomy → METADATA-5
- duplicate SKU actions `edit_sku` / `open_existing` → PUBLISH-15
- stale field actions/options → PUBLISH-20
## LISTING FIELDS (SlabCreate, read model, results)
- `id`, `client_rev` → APP-20
- `sku` 3..24 uppercase → REVIEW-45 (case widened; Woo hard cap in WOO-API §9)
- `length_in`, `thickness_in` 1/8 → CAPTURE-70
- `sqft`, `bdft`, widths → CAPTURE-80, CAPTURE-85, CAPTURE-90
- `species_id`, `wood_category_ids` → METADATA-10
- `edge_type_term_id`, `figure_term_ids`, `thickness_term_id`, `moisture_term_id` → METADATA-15
- `grade_term_ids` (plural, 1+) → superseded METADATA-15 (exactly one, singular)
- `fig_tag_ids` (1+) → superseded METADATA-30 (optional)
- `feat_tag_ids` (0+) → METADATA-30
- `price`, `price_source` → REVIEW-25
- `price_per_bdft` on slab → contract detail (pricing rules SETTINGS-35)
- `title`, `description` → REVIEW-10
- `short_title` → superseded REVIEW-10 (`short_description` canonical)
- `content_source` → METADATA-66
- read model `status`, `inference_status`, `woo_product_id`, `published_at`, revisions → APP-5,
METADATA-35, PUBLISH-25, APP-20
- `inference_error` → contract detail
- PhotoRead `kind` label → dropped (label discarded); urls → contract; 410 → PUBLISH-25
- Call1 result ids/confidences → METADATA-40; free-text → METADATA-100
- Call2 result → METADATA-55/60
- PriceRule shape → SETTINGS-35
## STATUSES
- `calibrated` on create → APP-5, CAPTURE-135
- `ready` preconditions → REVIEW-5 (fig/grade cardinality superseded)
- `publishing` / `failed` / `published` → APP-5, PUBLISH-25, PUBLISH-30
- `inference_status` enum → METADATA-35
- duplicate check across any Woo status → PUBLISH-15
## ENDPOINTS
- `GET /api/health` body → APP-45
- `GET /api/v1/meta` → contract detail
- `POST/GET/PUT/DELETE /api/v1/slabs` → docs/contracts/API.md
- `POST .../u2net/{pid}` reserved → CAPTURE-165
- `GET .../photos/...` → PUBLISH-25
- `POST .../infer-taxon` → METADATA-35
- `POST .../infer-content` → METADATA-55
- `POST .../publish` → PUBLISH-5
- taxonomy GET/sync → SETTINGS-50, SETTINGS-55, SETTINGS-60
- pricing CRUD → SETTINGS-35
- settings GET/PUT, test-woo, test-inference → SETTINGS-20, SETTINGS-30, SETTINGS-40, SETTINGS-45,
SETTINGS-75, SETTINGS-85
- `GET /api/v1/validation/module` → dropped (module cut)
## ERROR CODES
- 400, 404, 500 → APP-35
- 409 `revision_conflict` → APP-20
- 409 `duplicate_sku` → PUBLISH-15
- 422 `unpublishable` → REVIEW-5, REVIEW-20
- 422 `stale_field` → PUBLISH-20
- 422 test-inference gate → SETTINGS-45
- 422 `woo_base_url` write → SETTINGS-85
- 502 `woo_error` → PUBLISH-30, SETTINGS-50
- 504 timeout → METADATA-115
- 410 photos → PUBLISH-25
- 412 validation module → dropped (module cut)
## USER-FACING ERROR / EMPTY STATES
- never show the code, no raw code → PUBLISH-35
- unpublishable UI mapping → REVIEW-20
- test-woo fails closed → SETTINGS-80
- test-inference `fail` blocks Call 2 only → SETTINGS-45, METADATA-75
- low-confidence no prefill → METADATA-45
- Woo failure surfaces as failed + log → PUBLISH-30
- taxonomy sync failure keeps previous cache → SETTINGS-50
## INFERENCE
- Call 1 auto only if enabled → METADATA-35
- Call 1 inputs "original photos downscaled to 1024" → superseded METADATA-35 (clean processed
photos)
- Call 1 timeout 45s → METADATA-115
- confidence persist ≥0.7 → METADATA-45
- free-text → feat-* mapping → METADATA-100
- Call 2 default resend full context → METADATA-60 (no thread)
- `previous_response_id`, per-slab thread → superseded (thread work out)
- Call 2 requires test-inference ok → SETTINGS-45
- validation module shared with Call 2 → dropped (module cut)
## PUBLISH PAYLOAD / FLOW
- preconditions → REVIEW-5
- dedupe → PUBLISH-15
- stale → PUBLISH-20
- success (product id, purge, delete row) → PUBLISH-25
- failure (failed, sync_log, poll 502) → PUBLISH-30
- create-time Woo status → SETTINGS-65, PUBLISH-10
## CONSTANTS / NUMBERS
- base path `/api/v1`, version bump → APP-30
- timestamps UTC ISO-8601 → APP-20
- photo request cap 40 MB → contract detail
- SKU pattern `[A-Z0-9-]{3,24}` → REVIEW-45
- 1/8 step → CAPTURE-70
- price 2 decimals → REVIEW-25
- poll backoff 1/2/4/10s → contract detail
- 1024 downscale → METADATA-35
- 0.7 threshold → METADATA-45
- 45s timeout → METADATA-115
- 3:4 → CAPTURE-100
- 1600 / 0.80 → superseded CAPTURE-105
- publish cardinalities → REVIEW-5
## BROWSER / API SUPPORT
- client pins version → APP-30
- no client auth → OPS-55
- Woo HTTPS Basic Auth, application password, not consumer keys → SETTINGS-10, SETTINGS-15
- u2net post-POC not exposed → CAPTURE-165
- out of contract (bulk, staff, webhook, analytics) → dropped
- test-woo always resyncs → SETTINGS-40
- anchor re-pull / bump rules → SETTINGS-55, SETTINGS-60
-115
View File
@@ -1,115 +0,0 @@
# Coverage matrix — PRD-2026-08.txt
Source: `docs/archive/PRD-2026-08.txt` (frozen history). Method: every labeled atom dispositioned.
## Findings
None new. The two potential holes from this doc are already covered: inference timeout/error →
`METADATA-115`, and no app-level auth → `OPS-55`. The archive's own banner already flags FR17,
FR19, FR40, FR42, and the NFR server timing as non-authoritative.
## Archive banner
- Flags FR17, FR19, FR40, FR42, NFR server timing, and "Known stale" → each of those lines is
superseded by a current row (below).
## Decisions log (2026-08-31)
- Doc contradiction freeze → dropped (process).
- bdft identity, secrets AES-GCM, online-only, `woo_create_status` default draft, `SLAB-UAT-*`
force draft, hybrid happy path, inference off first → ported CAPTURE-90, SETTINGS-20/25,
CAPTURE-15, SETTINGS-65, PUBLISH-10, CAPTURE-5, QUALITY-20.
- Review package not in repo → dropped.
## Decisions log (2026-08-27)
| # | Atom | Disposition | PoR |
|---|---|---|---|
| 1 | no calibration photos | superseded | CAPTURE-25, CAPTURE-30 |
| 1 | manual SKU/length/thickness | ported | CAPTURE-70 |
| 2 | first photo is the measurement photo, top-down | ported | CAPTURE-30, CAPTURE-20 |
| 3 | all photos 80% crop | superseded | CAPTURE-100 |
| 4 | transparent PNG min 1600, 3:4 | superseded | CAPTURE-95, CAPTURE-105 |
| 5 | green/black auto-detect from borders | ported | CAPTURE-35 |
| 6 | species 1 leaf, wood 1+ leaf | ported | METADATA-10 |
| 7 | edge 1, figure 1+, thickness round-up | ported | METADATA-15 |
| 7 | grade 1+ | superseded | METADATA-15 |
| 8 | tags fig/feat 1+ | superseded | METADATA-30 |
| 9 | two-call inference | ported | METADATA-35, METADATA-55 |
| 10 | Call 1 auto only if enabled; review before Call 2 | ported | METADATA-35, METADATA-55 |
| 11 | timeout/non-200 error + retry | ported | METADATA-115 |
| 12 | create status default draft; SLAB-UAT force draft | ported | SETTINGS-65, PUBLISH-10 |
| 13 | prompts as server text files | superseded | METADATA-105 |
| 14 | brand voice + GEO into Call 2 | ported | METADATA-60, SETTINGS-30 |
| 15 | Woo creds server-side AES-GCM | ported | SETTINGS-15, SETTINGS-20 |
| 16 | inference endpoint configurable | ported | SETTINGS-30 |
| 17 | test connection text + vision | ported | SETTINGS-45, SETTINGS-80 |
| 18 | offline not required | ported | CAPTURE-15 |
Open items: none → dropped.
## Functional requirements
- FR1 (1–5 photos) superseded → CAPTURE-25; first top-down ported → CAPTURE-20
- FR2 manual SKU/length/thickness → CAPTURE-70
- FR3 border BG detect → CAPTURE-35
- FR4 scale/sqft/bdft, old divide-by-12 wrong → CAPTURE-75, CAPTURE-85, CAPTURE-90
- FR5 80% crop / 1600 / 3:4 → superseded CAPTURE-100, CAPTURE-105
- FR6 retake/abort → CAPTURE-155
- FR7 deterministic compute → CAPTURE-85, CAPTURE-90
- FR8 thickness manual authoritative → CAPTURE-70, METADATA-50
- FR9 deterministic unit-tested → QUALITY-5, QUALITY-15
- FR10 sync categories/attributes/tags → SETTINGS-50
- FR11 species 1 leaf → METADATA-10
- FR12 wood 1+ leaf → METADATA-10
- FR13 edge 1 → METADATA-15
- FR14 figure 1+ → METADATA-15
- FR15 grade 1+ → superseded METADATA-15
- FR16 thickness round-up → METADATA-15
- FR17 fig-* 1+ → superseded METADATA-30
- FR18 auto-assign + manual override → METADATA-40, METADATA-45, METADATA-50
- FR19 category-weighted pricing → superseded SETTINGS-35
- FR20 bdft pricing → REVIEW-25
- FR21 price editable, stored authoritative → REVIEW-25
- FR22 Call 1 vision with confidences → METADATA-35, METADATA-40
- FR22a threshold 0.7 → METADATA-45
- FR22b species bias full list → METADATA-40
- FR22c free-text → feat-* → METADATA-100
- FR23 Call 2 user-triggered; prose only; server assembles templates → METADATA-55, METADATA-65;
server-assembles superseded → METADATA-70, METADATA-80
- FR24 prompts text files + reset script → superseded METADATA-105
- FR25 progress indicator → dropped (presentation)
- FR26 timeout/non-200 error + retry → METADATA-115
- FR27 results editable, no auto publish → METADATA-50, METADATA-75
- FR28 endpoint configurable + test text/vision → SETTINGS-30, SETTINGS-45, SETTINGS-80
- FR29 chroma-key, U2Net fallback → CAPTURE-35, CAPTURE-165
- FR30 80% / 3:4 / 1600 → superseded CAPTURE-100, CAPTURE-105
- FR31 transparent PNG → CAPTURE-95
- FR32 original + processed, purge after publish → PUBLISH-25, PUBLISH-40
- FR33 consumer keys + app password → superseded SETTINGS-15
- FR34 publish blocked on existing SKU → PUBLISH-15
- FR35 create product fields → PUBLISH-55
- FR36 default draft; SLAB-UAT draft → SETTINGS-65, PUBLISH-10
- FR37 log sync, surface errors, store product id → PUBLISH-25, PUBLISH-30
- FR38 draft slab state → APP-10
- FR39 single review screen, all editable → REVIEW-40
- FR40 server-side processing → superseded CAPTURE-135, APP-40
- FR41 settings UI → SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35
- FR42 compose on `.201` Caddy → superseded OPS-5, OPS-15, OPS-40
- FR43 secrets encrypted, HTTPS → SETTINGS-20, SETTINGS-25, OPS-5
## Non-functional requirements
- performance budgets (1–2s, 3–5s, 45s) → dropped (server timing non-authoritative)
- reliability: unit-tested → QUALITY-5, QUALITY-15
- security: encrypted/HTTPS → SETTINGS-20, SETTINGS-25, OPS-5
- maintainability separation → dropped
- usability one-hand → dropped
## Acceptance criteria
- E2E capture→publish on iPhone/Android → QUALITY-115
- area accuracy +/- 0.25 in² → dropped (sqin banned)
- normalization 80% / PNG / 1600 → superseded CAPTURE-100, CAPTURE-105
- Woo product images/taxonomy/content → PUBLISH-40, PUBLISH-45, PUBLISH-55
- deterministic unit tests + inference logged → QUALITY-5, METADATA-66
@@ -1,84 +0,0 @@
# Coverage matrix — DATA-MODEL, PROMPTS, TECH-SPEC-PIPELINE, DEPLOYMENT, IMPL-PLAN
Sources: the five tag docs at `pre-simplification`.
## Findings
None new. The remaining entries are already ported, superseded by prior rulings, or process
detail. No new requirement is missing.
## Superseded (common to several)
- four knobs including edge offset and feather → CAPTURE-40
- "1–5 photos" / "1–N photos" cap → CAPTURE-25
- 80% fill / 1600 px floor → CAPTURE-100, CAPTURE-105
- grade "1+" / `grade_term_ids` plural → METADATA-15 (exactly one, singular)
- `fig-*` 1+ → METADATA-30 (optional)
- `inference_thread_id`, portable context, `previous_response_id`, per-slab thread → thread work
out
- prompt files on disk, reset script, "prompts are server files" → METADATA-105 (DB with revisions)
- `output_px` / `output_px_min` 1600, `fill_target` 0.80 → CAPTURE-100, CAPTURE-105
- validation module rules in Call 2 → module cut
- em dash title example → hyphen separator
## DATA-MODEL.md
- settings keys (`woo_wp_username`, `woo_app_password`, inference keys/flags, brand/GEO,
`woo_create_status` draft, taxonomy stamps) → SETTINGS-20, SETTINGS-30, SETTINGS-55, SETTINGS-65
- `WOO_BASE_URL` not a settings row → SETTINGS-5; `SLAB_AES_KEY` env, `value_enc` nonce||ciphertext
→ SETTINGS-25
- taxonomy columns and cardinalities → METADATA-10, METADATA-15, METADATA-30
- `sku`, `price`, `price_source`, `content_source` → REVIEW-45, REVIEW-25, METADATA-66
- status machine, transitions, `sync_log.status` → APP-5, PUBLISH-30
- published deletes row + photos, keeps sync_log → PUBLISH-25
- IDs/timestamps/rounding conventions → APP-20, CAPTURE-160
- `sync_log.payload_hash`, `woo_product_id`, `error`, `http_status` → contract detail
- `inference_log` append-only, `call` 1|2 → METADATA-66
- `client_rev`/`server_rev` default 1 → APP-20
## PROMPTS.md
- `inference_enabled`, `content_llm_enabled` gates → SETTINGS-90, METADATA-35, METADATA-70
- Call 1 purpose, inputs, synced-only constraint, schema, threshold, prefill, manual-wins,
ready-not-defined, inference-off → METADATA-5, METADATA-35, METADATA-40, METADATA-45,
METADATA-50, METADATA-75
- free-text → feat-* mapping → METADATA-100
- Call 2 purpose, prose-only, no dimensions, `content_llm_enabled` false → METADATA-55,
METADATA-65, METADATA-70
- brand/GEO + templates → SETTINGS-30, contract detail
- fail closed → SETTINGS-45, METADATA-115
- re-run Call 1 endpoint → API contract
## TECH-SPEC-PIPELINE.md
- sheet/sensitivity knobs, persistence, immediate re-run, confirm, rotate, nearly square →
CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-60, CAPTURE-65, CAPTURE-145, CAPTURE-150
- mask single contour, retake → CAPTURE-55
- scale/sqft/bdft/widths/price formulas → CAPTURE-75, CAPTURE-80, CAPTURE-85, CAPTURE-90,
REVIEW-25
- no-rule empty price, publish allowed → REVIEW-25
- unsupported client hard stop, client APIs → CAPTURE-10, CAPTURE-140
- factory constants (hue/sat/val, black threshold, border strip, inch step, decimals) → contract
detail (client constants), CAPTURE-35, CAPTURE-41
- no inference in scope → CAPTURE-5
## DEPLOYMENT.md
- `SLAB_AES_KEY`, `WOO_BASE_URL` env, creds encrypted → SETTINGS-25, SETTINGS-5, SETTINGS-20
- `woo_create_status` draft, UAT draft → SETTINGS-65, PUBLISH-10
- health, test-woo on rotate, inference remote → APP-45, SETTINGS-40, OPS-45
- host inventory, ports, LAN/Tailscale, Caddy HTTPS, env mode → OPS-15, OPS-5, OPS-20
- backup 7 daily / 4 weekly → OPS-30
- probes/ports proposal, rekey command, backup schedule → dropped (operational detail)
- `output_px_min` floor line → superseded CAPTURE-105
## IMPL-PLAN.md
- ground rules, inference-toggle suite, status machine → QUALITY-5, QUALITY-10, QUALITY-20, APP-5
- phase deliverables restate CAPTURE/REVIEW/METADATA/PUBLISH/SETTINGS rows
- `woo_create_status`, dedupe, stale recovery, sync_log/purge, partial failure → PUBLISH-10,
PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-31
- Woo client basic auth, no consumer keys → SETTINGS-15
- E2E browsers, no OCR/ruler/offline → QUALITY-115, CAPTURE-15
- coverage ≥95%, specific UAT slab set, LoRA, "no processing status" → dropped (process/quality
targets, not product requirements)
-109
View File
@@ -1,109 +0,0 @@
# Coverage matrix — TEST-SPEC.md
Source: `docs/TEST-SPEC.md` at tag `pre-simplification` (107 cases). Reconciled last, against the
finished PoR.
## Findings
Two behaviors were tested but not required; both are now PoR rows:
- FUND-3, FUND-5, FUND-6, FUND-7 (keyboard tab order, visible focus ring, 320 px reflow) → `APP-60`
- FUND-24 (no secret material in logs) → `APP-65`
## Superseded cases to purge on migration
- UI-1, UIE-9, UIE-10 — four knobs (edge offset, feather) → two controls
- V-3, API-8 — shared validation module, version hash, 412
- C2-2 — `previous_response_id` / per-slab thread
- C1-1 — "inventory photos at 1024" → clean processed photos
- C2-4 — prompt file edits without restart → prompts live in the DB
- TV-6, TV-7, E2E-5 — 80 percent fill / 1600 px → 90 percent / 800 px
- UIE-1, E2E-4 — manual override of computed fields → read-only
## Case map
FUND
- FUND-1..4, 8..11 — test conventions → dropped (test practice, not requirements)
- FUND-3, 5, 6, 7 — accessibility → APP-60
- FUND-12, 13, 16 — SQLite constraints/migrations → dropped (engineering practice)
- FUND-14 — WAL stale reads → dropped (engineering practice)
- FUND-15 — illegal status transitions rejected → APP-5
- FUND-17 — fresh nonce, tamper fails → SETTINGS-25
- FUND-18 — ciphertext absent from GET → SETTINGS-75
- FUND-19 — missing key refuses startup → SETTINGS-25
- FUND-20 — Basic auth app passwords, HTTPS → SETTINGS-15
- FUND-21 — 401/400/404 mapped → APP-50
- FUND-22 — pre-create SKU lookup, race → PUBLISH-15
- FUND-23 — timeouts/network errors graceful, no stack → PUBLISH-30, PUBLISH-35
- FUND-24 — secrets redacted in logs → APP-65
- FUND-25 — HTTPS, health via proxy, port collision → OPS-5, OPS-15, OPS-20
TV
- TV-1..TV-10 → QUALITY-55..QUALITY-100 (TV-6/TV-7 numeric values superseded)
Validation
- V-1 — SKU charset → REVIEW-45
- V-2 — title/description limits → REVIEW-50
- V-3 — module hash / 412 → dropped
AES / health / draft
- AES-1 → QUALITY-125, SETTINGS-75
- AES-2 → SETTINGS-25
- H-1 → APP-45
- D-1 → API contract, APP-5
Gate C / Woo
- GC-1 → PUBLISH-10; GC-2 → PUBLISH-15; GC-3 → SETTINGS-40; GC-4 → SETTINGS-85; GC-5 → SETTINGS-10
- W-1 → PUBLISH-25; W-2 → PUBLISH-15; W-3 → SETTINGS-55; W-4 → SETTINGS-40; W-5 → PUBLISH-30
Inference
- INF-1 → METADATA-75, QUALITY-20
- INF-2, INF-3 → SETTINGS-45 (mode is kept)
- INF-4 → SETTINGS-45
UI (capture)
- UI-1 → superseded CAPTURE-40
- UI-2 → CAPTURE-50, CAPTURE-55, CAPTURE-165
- UI-3 → CAPTURE-60, CAPTURE-65
- UI-4 → CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90
- UI-5 → REVIEW-5, REVIEW-20
Ops
- OPS-1 → APP-45; OPS-2 → OPS-15; OPS-3 → SETTINGS-40, SETTINGS-65, APP-45; OPS-4 → OPS-30;
OPS-5 → OPS-35
API
- API-1 → APP-20; API-2 → APP-40; API-3 → SETTINGS-75; API-4 → METADATA-35, METADATA-115;
API-5 → METADATA-45; API-6 → REVIEW-5; API-7 → SETTINGS-50, SETTINGS-55; API-8 → dropped;
API-9 → API contract; API-10 → SETTINGS-80
- TAX-1 → SETTINGS-60; TAX-2 → METADATA-5; TAX-3 → SETTINGS-60
UI end-to-end
- UIE-1 → superseded REVIEW-30, REVIEW-40
- UIE-2 → METADATA-45, REVIEW-20
- UIE-3 → PUBLISH-15; UIE-4 → PUBLISH-20; UIE-5 → PUBLISH-25; UIE-6 → PUBLISH-30
- UIE-7 → METADATA-70, METADATA-110
- UIE-8 → SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65
- UIE-9, UIE-10 → superseded CAPTURE-40, CAPTURE-45, CAPTURE-145
Calls
- C1-1 → superseded METADATA-35 (clean processed photos)
- C1-2, C1-3 → METADATA-45, REVIEW-5
- C2-1 → METADATA-55, METADATA-60
- C2-2 → superseded (thread work out)
- C2-3 → METADATA-65
- C2-4 → METADATA-66, METADATA-105
- OFF-1 → QUALITY-20; OFF-2 → SETTINGS-45
Instrumentation
- INS-1 → APP-45; INS-2 → APP-55; INS-3 → METADATA-66; INS-4 → PUBLISH-30
E2E
- E2E-1..3 → QUALITY-115
- E2E-4 → superseded REVIEW-30, REVIEW-40
- E2E-5 → superseded CAPTURE-100, CAPTURE-105
## Test harness (contract detail)
Harness layout, allowed result values, evidence rules, and the Gitea-issue bug log are engineering
practice. They belong in `docs/contracts/TEST-SPEC.md`, not the PoR.
-217
View File
@@ -1,217 +0,0 @@
# Coverage matrix — UX-DESIGN.md (pilot)
Purpose: prove every entry on a reference surface in the source doc has a home in the PoR.
Source: `docs/UX-DESIGN.md` at tag `pre-simplification` (non-normative wireframes).
Method: subagent extracted every entry mechanically; this file assigns the PoR home and disposition.
Dispositions: `ported`, `superseded`, `dropped`, `ui` (presentation-only, not a requirement),
`missing` (a finding).
Pilot proof: the three entries fixed in SETTINGS-45 / SETTINGS-90 / METADATA-110 are marked
`ported` below. Before that fix they had no home, which is exactly what this matrix surfaces.
## SETTINGS KEYS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Store URL field | SETTINGS-5 | ported | env, read-only |
| WP Username field | SETTINGS-20 | ported | — |
| Application Password field | SETTINGS-20 | ported | — |
| Inference Endpoint field | SETTINGS-30 | ported | — |
| Inference API Key field | SETTINGS-30 | ported | — |
| Enable Vision toggle, default off | SETTINGS-90 | ported | fixed |
| Enable Content toggle, default off | SETTINGS-90 | ported | fixed |
| Pricing Rules | SETTINGS-35 | ported | — |
| Publish Status dropdown, default Draft | SETTINGS-65 | ported | — |
| Brand Voice field | SETTINGS-30 | ported | — |
| GEO Context field | SETTINGS-30 | ported | — |
| Sheet dropdown | CAPTURE-40 | ported | — |
| Edge Offset control | CAPTURE-40 | superseded | removed |
| Feather control | CAPTURE-40 | superseded | removed |
| Knob persistence localStorage | CAPTURE-145 | ported | — |
## CONTROLS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Test Connection (WP) | SETTINGS-40, SETTINGS-80 | ported | resync and failure |
| Test Connection (Inference) two-turn probe, stateful/stateless/fail | SETTINGS-45 | ported | fixed |
| Enable Vision / Enable Content buttons | SETTINGS-90 | ported | — |
| Add Rule / Edit (pricing) | SETTINGS-35 | ported | — |
| Reset Capture Knobs | CAPTURE-145 | ported | — |
| Refresh Store Lists | SETTINGS-60 | ported | — |
| Got it (first-run card) | — | ui | presentation |
| R retake shortcut | CAPTURE-50 | ported | — |
| Sheet dropdown | CAPTURE-40 | ported | — |
| Sensitivity slider | CAPTURE-41, CAPTURE-45 | ported | — |
| Edge Offset slider | CAPTURE-40 | superseded | removed |
| Feather slider | CAPTURE-40 | superseded | removed |
| Retake (capture) | CAPTURE-50, CAPTURE-155 | ported | — |
| Confirm (capture) | CAPTURE-50 | ported | — |
| Rotate (axis) | CAPTURE-60, CAPTURE-65 | ported | — |
| Confirm (axis) | CAPTURE-60 | ported | — |
| SKU input | REVIEW-45 | ported | — |
| Length input | CAPTURE-70 | ported | — |
| Thickness input | CAPTURE-70 | ported | — |
| Done (type values) | APP-5 | ported | status calibrated |
| X exit review | APP-10 | ported | — |
| Species dropdown | METADATA-10 | ported | — |
| Wood Cat dropdown | METADATA-10 | ported | — |
| Edge Type dropdown | METADATA-15 | ported | — |
| Figure dropdown | METADATA-15 | ported | — |
| Grade dropdown | METADATA-15 | ported | cardinality superseded |
| Fig Tags dropdown | METADATA-30 | ported | cardinality superseded |
| Feat Tags dropdown | METADATA-30 | ported | — |
| Thickness Band dropdown | METADATA-15 | ported | — |
| Moisture dropdown | METADATA-15 | ported | — |
| Price input | REVIEW-25 | ported | — |
| Review Title / Desc button | REVIEW-10 | ported | — |
| Publish button disabled until ready | REVIEW-20 | ported | — |
| Title input | REVIEW-10 | ported | — |
| Short Title input | REVIEW-10 | ported | naming migrated |
| Description input | REVIEW-10 | ported | — |
| Generate Text button, availability | METADATA-110 | ported | fixed |
| Done (title/desc) | REVIEW-40 | ported | — |
| Open in Store | — | ui | candidate: store link is presentation |
| Done (publish success) | PUBLISH-25 | ported | — |
| Retry (publish failed) | PUBLISH-30, PUBLISH-37 | ported | — |
| Back to Review | PUBLISH-30 | ported | retry from same screen |
| Retake (mask failure) | CAPTURE-50, CAPTURE-55 | ported | — |
| Rotate (axis check) | CAPTURE-60 | ported | — |
| Retake (source too small) | CAPTURE-105 | ported | — |
| Edit SKU | PUBLISH-15 | ported | — |
| Open Existing | PUBLISH-15 | ported | — |
| Refresh Store Lists (stale) | PUBLISH-20 | ported | — |
| Retry / Open Settings (unreachable) | PUBLISH-30, PUBLISH-36, PUBLISH-38 | ported | — |
| Update Browser | CAPTURE-10 | ported | — |
## LISTING FIELDS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| SKU | REVIEW-45, PUBLISH-15 | ported | — |
| Length | CAPTURE-70 | ported | — |
| Thickness | CAPTURE-70 | ported | — |
| sqft (computed) | CAPTURE-85 | ported | — |
| bdft (computed) | CAPTURE-90 | ported | — |
| widths (computed) | CAPTURE-80 | ported | — |
| Photos, "at least one inventory PNG" | REVIEW-5 | superseded | ready set is two images |
| Species exactly one | METADATA-10 | ported | — |
| Wood category 1+ | METADATA-10 | ported | — |
| Edge type one | METADATA-15 | ported | — |
| Figure 1+ | METADATA-15 | ported | — |
| Fig tags 1+ | METADATA-30 | superseded | tags optional |
| Feat tags 0+ | METADATA-30 | ported | — |
| Grade 1+ | METADATA-15 | superseded | grade exactly one |
| Thickness Band | METADATA-15 | ported | — |
| Moisture | METADATA-15 | ported | — |
| Price | REVIEW-25 | ported | — |
| Title | REVIEW-10 | ported | — |
| Short Title | REVIEW-10 | ported | naming migrated |
| Description | REVIEW-10 | ported | — |
| Store product ID | PUBLISH-25 | ported | display is presentation |
## STATUSES
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Publish Status Draft | SETTINGS-65 | ported | — |
| calibrated | APP-5 | ported | — |
| publishing | APP-5 | ported | — |
| published | APP-5, PUBLISH-50 | ported | — |
| failed | APP-5 | ported | — |
| Woo product draft | PUBLISH-10 | ported | — |
| SKU "any status" | PUBLISH-15 | ported | — |
| probe stateful/stateless/fail | SETTINGS-45 | ported | fixed |
## ENDPOINTS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| WP REST v3 auth | SETTINGS-15, SETTINGS-80 | ported | — |
| Inference endpoint | SETTINGS-30 | ported | — |
| Inference endpoint reachability | METADATA-110, METADATA-75 | ported | fixed |
## ERROR CODES
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| green check / red error | SETTINGS-80 | ported | — |
| stateful/stateless/fail | SETTINGS-45 | ported | fixed |
## USER-FACING ERROR / EMPTY STATES
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Mask failure "Cannot isolate the slab edge..." | CAPTURE-55 | ported | — |
| Axis misalignment "Rotate the axis..." | CAPTURE-60 | ported | — |
| Source too small "Photo will be too small..." | CAPTURE-105 | ported | — |
| SKU duplicate "This SKU already exists..." | PUBLISH-15 | ported | — |
| Stale taxonomy "This value is no longer valid..." | PUBLISH-20, REVIEW-35 | ported | — |
| Store unreachable "Cannot reach the store..." | PUBLISH-30, PUBLISH-36 | ported | — |
| Publish failed "The store rejected the listing..." | PUBLISH-30, PUBLISH-35 | ported | — |
| Publishing in progress | PUBLISH-5 | ported | — |
| Publish success | PUBLISH-25 | ported | — |
| Browser gate "update Safari or Chrome" | CAPTURE-10 | superseded | matrix includes PC |
| Species/Figure/Wood/Edge nudges | REVIEW-20, METADATA-10, METADATA-15 | ported | — |
| Generic inline nudge | REVIEW-20 | ported | — |
| First-run guidance text | — | ui | presentation |
| Vision-off empty taxonomy | METADATA-45, METADATA-70 | ported | — |
## INFERENCE
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Call 1 flag default off | SETTINGS-90, METADATA-35 | ported | fixed |
| Call 2 flag default off | SETTINGS-90, METADATA-55 | ported | fixed |
| Generate Text triggers Call 2 | METADATA-55 | ported | — |
| Pre-populate threshold 0.7 | METADATA-45 | ported | — |
| Below-threshold leaves empty | METADATA-45 | ported | — |
| Below-threshold manual fill to ready | REVIEW-5, REVIEW-20 | ported | — |
| Two-turn probe stateful/stateless/fail | SETTINGS-45 | ported | fixed |
| AI badge on Call 1 fields | — | ui | presentation |
| Generate Text availability | METADATA-110 | ported | fixed |
| Call 2 prose only, templates numbers | METADATA-65, METADATA-70 | ported | — |
| Vision off state | METADATA-70 | ported | — |
## PUBLISH PAYLOAD / FLOW
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Publish action, poll | PUBLISH-5 | ported | — |
| Draft creation | PUBLISH-10 | ported | — |
| SLAB-UAT-* always draft | PUBLISH-10 | ported | — |
| Publish Status default draft | SETTINGS-65 | ported | — |
| Duplicate SKU behavior | PUBLISH-15 | ported | — |
| Stale taxonomy behavior | PUBLISH-20 | ported | — |
| Success result, open in store | PUBLISH-25 | ported | link is presentation |
| Failure behavior, keep draft, retry | PUBLISH-30 | ported | — |
| Product ID stored, images purged | PUBLISH-25 | ported | — |
| Publish button gating | REVIEW-20 | ported | — |
| Submit-time validation after all fields | REVIEW-20 | superseded | per-field nudges, no submit list |
| Taxonomy freshness on test | SETTINGS-40 | ported | — |
## CONSTANTS / NUMBERS
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| SKU pattern `[A-Z0-9-]{3,24}` | REVIEW-45 | ported | case widened |
| Length / thickness 1/8 step | CAPTURE-70 | ported | — |
| Confidence threshold 0.7 | METADATA-45 | ported | — |
| Width sample every 6 inches | CAPTURE-80 | ported | — |
| Wireframe width 375px | — | ui | sketch detail |
| Sample values (sqft 0.64, bdft 0.96, widths, price, id) | — | ui | examples |
| Mandatory counts list | REVIEW-5 | ported | grade/fig superseded |
| Optional feat-* zero fine | METADATA-30 | ported | — |
| Photo thumbnail count 5 | CAPTURE-25 | superseded | no cap |
| SLAB-UAT-* pattern | PUBLISH-10 | ported | — |
## BROWSER / API SUPPORT
| Entry | PoR | Disposition | Note |
|---|---|---|---|
| Gate on missing APIs | CAPTURE-10 | ported | — |
| Supported browsers | CAPTURE-10 | ported | matrix worded phone-only, superseded |
| Hard-stop screen | CAPTURE-10 | ported | — |
| Client-side mask re-run | CAPTURE-140, CAPTURE-150 | ported | — |
| Camera capture | CAPTURE-20, CAPTURE-25 | ported | — |
| localStorage | CAPTURE-145 | ported | — |
-12
View File
@@ -1,12 +0,0 @@
# Coverage ledger — prd
Source `prd` (`docs/archive/PRD-2026-08.txt`, archived). Seeded by the settings/auth/health
pilot 2026-09-18. The archived PRD is walked in full later in the order.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| prd | decision-15 | Woo credentials are server-side only, never in the browser | ported | SETTINGS-15, SETTINGS-20 | — |
| prd | decision-16 | inference endpoint is configurable: base URL, key, model | ported | SETTINGS-30 | — |
| prd | decision-17 | test connection verifies text and vision support | ported | SETTINGS-80, SETTINGS-45 | — |
| prd | no-app-auth | no app-level auth; HTTPS plus trusted network | ported | OPS-55 | — |
| prd | FR41 | Settings UI for inference, Woo creds, pricing, brand/GEO, prompts | ported | SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35 | prompts are post-M3, audit section 11 |
-32
View File
@@ -1,32 +0,0 @@
# Coverage ledger — PRODUCT.md
Source `cur:PRODUCT` (`docs/PRODUCT.md`, role: intent). Walked 2026-09-18 section by section
against the PoR. Term ruling: the umbrella "Woo attributes" package is renamed **Woo Slab
Taxonomy**; canonical Woo attributes (the five) keep their name.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| cur:PRODUCT | one-slab-one-listing | one slab is one unit and one listing | ported | PUBLISH-60 | — |
| cur:PRODUCT | photos-metadata-inventory | photos and metadata are the inventory record | dropped | — | intent framing; realized by CAPTURE/PHOTOS/CREATE, not a discrete rule |
| cur:PRODUCT | green-black-sheet | photographs taken on a green or black sheet | ported | CAPTURE-20 | — |
| cur:PRODUCT | inventory-png-3-4-transparent | inventory photo(s), uniform 3:4 transparent PNG | ported | CAPTURE-95, CAPTURE-100 | — |
| cur:PRODUCT | listing-fields-user-owned | SKU, length, thickness, price | ported | CAPTURE-70, REVIEW-25 | SKU has no standalone row; folded into PUBLISH-5/PUBLISH-15 |
| cur:PRODUCT | taxon-wood-attributes | species, wood category, edge, figure, grade | ported | METADATA-10, METADATA-15 | — |
| cur:PRODUCT | thickness-band-moisture | thickness band, moisture | ported | METADATA-15 | — |
| cur:PRODUCT | fig-tags-cardinality | one or more `fig-*` tags | superseded | METADATA-30 | source says one or more; ruled optional, Figure attribute is at least one |
| cur:PRODUCT | title-shorttitle-description | title, short title, description | ported | REVIEW-10 | "short title" is stale wording for short_description |
| cur:PRODUCT | feat-tags-optional | zero or more `feat-*` tags | ported | METADATA-30 | — |
| cur:PRODUCT | woo-slab-taxonomy-term | "Woo attributes" locked package name | ported | METADATA-5 | renamed Woo Slab Taxonomy; umbrella rule lives in METADATA-5 |
| cur:PRODUCT | measured-values-templates | measured values feed title and description templates | superseded | METADATA-60, METADATA-70, METADATA-80 | template is the fallback; the model writes when Call 2 runs |
| cur:PRODUCT | decision-rights-table | decision rights for mask/crop, measures, taxonomy, price, prose | ported | CAPTURE-50, CAPTURE-80, CAPTURE-85, CAPTURE-90, CAPTURE-100, METADATA-40, METADATA-45, REVIEW-25, REVIEW-40 | row-for-row mapping; L37 carries the template conflict |
| cur:PRODUCT | override-precedence | user entries override computed or inferred values | ported | METADATA-50 | — |
| cur:PRODUCT | milestones-1-2-browser-only | M1/M2 browser-only, no backend or inference URL | ported | APP-25, OPS-45 | — |
| cur:PRODUCT | m1-handoff-device-draft | M1 handoff is the device draft contents | ported | APP-10 | — |
| cur:PRODUCT | m2-extends-draft | M2 batch, calibration, per-photo PNGs, overlay copy | ported | CAPTURE-25, CAPTURE-110, CAPTURE-115, CAPTURE-130 | — |
| cur:PRODUCT | m3-fastapi-client | M3 FastAPI Woo client and inference proxy, secrets server-side | ported | APP-25, SETTINGS-15, SETTINGS-20, SETTINGS-30 | — |
| cur:PRODUCT | slab-uat-forces-draft | `SLAB-UAT-*` creates a Woo draft | ported | PUBLISH-10 | — |
| cur:PRODUCT | duplicate-sku-blocks | duplicate SKU blocks create | ported | PUBLISH-15 | — |
| cur:PRODUCT | create-inference-off | a listing can be created with inference off | ported | METADATA-70, METADATA-75, QUALITY-20 | — |
| cur:PRODUCT | device-draft-not-woo-draft | device draft is separate from a WooCommerce draft | ported | APP-10 | — |
| cur:PRODUCT | milestone-status-table | milestone status table, UAT on `:10881` | ported | OPS-40 | only host/port is testable; rest navigation; branch-neutral edit queued |
| cur:PRODUCT | out-of-m3-scope | anything beyond CREATE is out of M3 | dropped | — | scope prose, not a requirement; MILESTONES.md owns scope |
-62
View File
@@ -1,62 +0,0 @@
# Coverage ledger — tag:ARCHITECTURE
Source `tag:ARCHITECTURE` (`docs/ARCHITECTURE.md` at tag `pre-simplification`). Seeded by the
settings/auth/health pilot 2026-09-18.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:ARCHITECTURE | s5-server-secrets | Woo creds, inference key, AES key, WOO_BASE_URL are server-side only | ported | SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30 | — |
| tag:ARCHITECTURE | s5-client-prefs | sheet and sensitivity preferences persist in localStorage | ported | CAPTURE-145 | — |
| tag:ARCHITECTURE | s7-taxonomy | last_sync_at, anchor, and sync triggers | ported | SETTINGS-50, SETTINGS-55, SETTINGS-60 | — |
| tag:ARCHITECTURE | s3-woo-only-fastapi | Woo is called only by FastAPI | ported | APP-25 | — |
| tag:ARCHITECTURE | topology-hybrid | phone happy-path; server stores/proxies; reverse proxy HTTPS | ported | CAPTURE-5, APP-25, OPS-5 | — |
| tag:ARCHITECTURE | u2net-deferred | server U2Net deferred from POC, not deleted | ported | CAPTURE-165 | — |
| tag:ARCHITECTURE | keys-server-side | Woo and LLM keys stay on the server; browser never calls Woo or vision | ported | APP-25, SETTINGS-15, SETTINGS-20, SETTINGS-30 | — |
| tag:ARCHITECTURE | new-wood-1-5-photos | new wood: 1-5 photos | superseded | CAPTURE-25 | PoR is one or more; no cap in code |
| tag:ARCHITECTURE | bg-four-knobs | sheet / sensitivity / edge / feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
| tag:ARCHITECTURE | length-axis-confirm | length axis overlay, rotate/confirm | ported | CAPTURE-60, CAPTURE-65 | — |
| tag:ARCHITECTURE | user-length-thickness-sku | user length, thickness, SKU | ported | CAPTURE-70 | — |
| tag:ARCHITECTURE | client-measure-png | sqft, bdft, 6in widths, 3:4 PNGs | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90, CAPTURE-95, CAPTURE-100 | — |
| tag:ARCHITECTURE | call1-prefill | review: Call 1 pre-fill or manual taxonomy | ported | METADATA-35, METADATA-40, METADATA-45 | — |
| tag:ARCHITECTURE | generate-text-tap | tap Generate text | ported | METADATA-55 | — |
| tag:ARCHITECTURE | call2-proxy-text | Call 2 proxy title, description, short description | ported | METADATA-55 | — |
| tag:ARCHITECTURE | happy-path-phone | happy path stays on phone through PNG and numbers; FastAPI idle until confirmed mask | ported | CAPTURE-5, CAPTURE-135 | — |
| tag:ARCHITECTURE | mask-knobs-client | mask knobs on capture; each change re-runs client BG removal; U2Net deferred | ported | CAPTURE-45, CAPTURE-150, CAPTURE-165 | — |
| tag:ARCHITECTURE | draft-upload-delete | draft upload sends originals, PNGs, numbers; delete after publish | ported | CAPTURE-135, PUBLISH-25, PUBLISH-40 | — |
| tag:ARCHITECTURE | call1-auto-body | Call 1 server-side, auto when enabled, 1024 + synced taxonomy, 45s, 0.7 | ported | METADATA-35, METADATA-40, METADATA-45 | — |
| tag:ARCHITECTURE | call2-no-auto | Call 2 does not auto-fire; user taps Generate text | ported | METADATA-55 | — |
| tag:ARCHITECTURE | call2-thread-context | portable context, previous_response_id, per-slab thread | superseded | — | thread ids pushed out of MVP; Call 2 is a fresh call |
| tag:ARCHITECTURE | woo-only-409-422 | Woo only via FastAPI; duplicate SKU 409 on field; stale value 422 on field; draft stays | ported | APP-25, PUBLISH-15, PUBLISH-20, PUBLISH-30 | — |
| tag:ARCHITECTURE | settings-server | settings on the server; store URL env, read-only | ported | SETTINGS-5, SETTINGS-30, SETTINGS-65 | — |
| tag:ARCHITECTURE | what-runs-where-table | what runs where | duplicate | CAPTURE-5, APP-25 | restates the topology and swimlane rows |
| tag:ARCHITECTURE | s5s1-basic-auth | WordPress Basic auth, app password, AES-GCM, not consumer keys | ported | SETTINGS-15, SETTINGS-20 | — |
| tag:ARCHITECTURE | s5s2-inference-key | inference base URL and key encrypted; server proxies | ported | SETTINGS-30, APP-25 | — |
| tag:ARCHITECTURE | s5s3-aes-key | AES master key env, never in the DB | ported | SETTINGS-25 | — |
| tag:ARCHITECTURE | s5s4-woo-url | WOO_BASE_URL env, HTTPS, not user-editable, read-only | ported | SETTINGS-5, SETTINGS-10 | — |
| tag:ARCHITECTURE | s5b2-species-rate | species list with $/bdft read from server settings sync | ported | SETTINGS-35 | — |
| tag:ARCHITECTURE | s5b3-taxonomy-snapshot | taxonomy snapshot read from server, no auth | ported | SETTINGS-50 | — |
| tag:ARCHITECTURE | s5f1-deploy-setup | deploy sets WOO_BASE_URL; operator creates low-priv WP user | ported | SETTINGS-5, SETTINGS-15 | — |
| tag:ARCHITECTURE | s5f2-enter-creds | user enters credentials in Settings; URL read-only | ported | SETTINGS-20 | — |
| tag:ARCHITECTURE | s5f3-encrypt | browser POSTs credentials; server encrypts AES-GCM, stores ciphertext | ported | SETTINGS-20 | — |
| tag:ARCHITECTURE | s5f4-sync-cache | server syncs taxonomy, caches in SQLite | ported | SETTINGS-50 | — |
| tag:ARCHITECTURE | s5f5-client-read | client reads taxonomy, no auth | ported | SETTINGS-50 | — |
| tag:ARCHITECTURE | s5f6-server-woo | all Woo calls go server-to-Woo | ported | APP-25 | — |
| tag:ARCHITECTURE | status-machine | draft, calibrated, ready, publishing, published, failed | ported | APP-5 | — |
| tag:ARCHITECTURE | ready-set | ready set fields | ported | REVIEW-5 | — |
| tag:ARCHITECTURE | ready-set-fig-grade | ready set requires 1+ fig-* tag and 1+ grade | superseded | METADATA-15, METADATA-30 | grade exactly one; tags optional |
| tag:ARCHITECTURE | anchor-bump | anchor bumps only on stored-subset change including deletions | ported | SETTINGS-55 | — |
| tag:ARCHITECTURE | anchor-repull | client re-pulls full taxonomy iff the anchor advanced | ported | SETTINGS-60 | — |
| tag:ARCHITECTURE | species-exclusivity | species pickers and Call 1 lists are exactly the cached set | ported | METADATA-5 | — |
| tag:ARCHITECTURE | trigger-test-woo | test-woo always resyncs taxonomy | ported | SETTINGS-40 | — |
| tag:ARCHITECTURE | trigger-publish-1h | publish resync if the last sync is older than 1 hour | ported | PUBLISH-5, SETTINGS-60 | — |
| tag:ARCHITECTURE | trigger-optional-launch | optional app-launch or Settings refresh | dropped | — | optional, not required for POC |
| tag:ARCHITECTURE | trigger-no-nav-sync | do not sync on every screen navigation | dropped | — | design guidance, not a requirement |
| tag:ARCHITECTURE | validation-module | shared validation module, version hash, 412 | dropped | — | ruled cut 2026-09-18 |
| tag:ARCHITECTURE | sku-charset | SKU charset [A-Z0-9-]{3,24} | ported | REVIEW-45 | case widened to alphanumeric by ruling |
| tag:ARCHITECTURE | validation-empty-optional | field-exit local check; empty optional is valid | dropped | — | review gate covers required fields; no separate row |
| tag:ARCHITECTURE | call1-call2-context | Call 1 to Call 2 portable context default | superseded | — | thread work pushed out of MVP |
| tag:ARCHITECTURE | test-inference-probe | test-inference two-turn probe records stateful, stateless, fail | ported | SETTINGS-45 | mode removed; probe keeps ok, vision_capable, detail |
| tag:ARCHITECTURE | per-slab-thread | per-slab thread id, delete on publish or abandon | superseded | — | cut 2026-09-18 |
| tag:ARCHITECTURE | inference-runtime-failure | Call 1 timeout 45s returns error plus retry on the phone | ported | METADATA-115 | — |
| tag:ARCHITECTURE | call1-image-source | all originals downscaled to 1024 | superseded | METADATA-35 | PoR sends the clean processed photos |
| tag:ARCHITECTURE | title-desc-maxlength | Woo maxLength caps title and descriptions | dropped | — | inherent: bound by Woo limits |
-50
View File
@@ -1,50 +0,0 @@
# Coverage ledger — tag:DATA-MODEL
Source `tag:DATA-MODEL` (`docs/DATA-MODEL.md` at tag `pre-simplification`, role: schema spec).
Walked 2026-09-18. Column mechanics belong in `docs/contracts/DATA-MODEL.md`; the testable
rules carry PoR IDs.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:DATA-MODEL | settings-table | settings.value_enc is AES-GCM; WOO_BASE_URL is not a settings row | ported | SETTINGS-5, SETTINGS-20, SETTINGS-30 | — |
| tag:DATA-MODEL | ids-client-uuid | slab ids are client-generated UUIDv4 | ported | APP-20 | — |
| tag:DATA-MODEL | timestamps-utc | timestamps are UTC ISO-8601 | ported | APP-20 | — |
| tag:DATA-MODEL | status-enum | status draft/calibrated/ready/publishing/published/failed | ported | APP-5 | — |
| tag:DATA-MODEL | rounded-values-stored | rounded values are what gets stored | ported | CAPTURE-160 | — |
| tag:DATA-MODEL | inches-eighth | inches stored as 1/8 increments | ported | CAPTURE-70 | — |
| tag:DATA-MODEL | sqft-bdft-2dp | sqft and bdft to 2 decimals | ported | CAPTURE-85, CAPTURE-90 | — |
| tag:DATA-MODEL | sku-charset | sku `[A-Z0-9-]{3,24}` | ported | REVIEW-45 | — |
| tag:DATA-MODEL | species-leaf-1 | one species leaf | ported | METADATA-10 | — |
| tag:DATA-MODEL | wood-category-1plus | one or more wood-category leaves | ported | METADATA-10 | — |
| tag:DATA-MODEL | edge-1 | one edge type | ported | METADATA-15 | — |
| tag:DATA-MODEL | figure-1plus | one or more figure terms | ported | METADATA-15 | — |
| tag:DATA-MODEL | grade-1plus | one or more grade terms | superseded | METADATA-15 | grade is exactly one |
| tag:DATA-MODEL | thickness-roundup | thickness term by round-up | ported | METADATA-15 | — |
| tag:DATA-MODEL | moisture-default | moisture defaults kiln-dried, not inferred | ported | METADATA-15 | — |
| tag:DATA-MODEL | fig-tags-1plus | fig-* tags 1+ | superseded | METADATA-30 | tags optional |
| tag:DATA-MODEL | feat-tags-0plus | feat-* tags 0+ | ported | METADATA-30 | — |
| tag:DATA-MODEL | price-2dp-source | price 2 dp, price_source recommendation or override | ported | REVIEW-25 | — |
| tag:DATA-MODEL | inference-thread-id | optional stateful inference thread id | superseded | — | thread work out of MVP |
| tag:DATA-MODEL | state-machine | status transitions draft through failed | ported | APP-5 | — |
| tag:DATA-MODEL | ready-set | ready set fields | ported | REVIEW-5 | — |
| tag:DATA-MODEL | published-delete-row | published deletes the row and photos, keeps sync_log | ported | PUBLISH-25 | — |
| tag:DATA-MODEL | failed-error-retry | failed stores error detail, retry available | ported | PUBLISH-30 | — |
| tag:DATA-MODEL | slab-photos-only-inventory | only inventory photos; calibration photos do not exist | superseded | CAPTURE-115 | calibration photo exists; other batch photos are visual inventory |
| tag:DATA-MODEL | no-hard-deletes | no hard deletes for slabs (audit) | superseded | PUBLISH-25 | the local slab row is deleted on success |
| tag:DATA-MODEL | pricing-rules-flat | one flat price_per_bdft per species, no tiers | ported | SETTINGS-35 | — |
| tag:DATA-MODEL | woo-categories-filter | only species and wood-category leaves are offered | ported | METADATA-5, METADATA-10 | — |
| tag:DATA-MODEL | woo-attributes-five | only the five first-class attributes | ported | METADATA-15 | — |
| tag:DATA-MODEL | woo-tags-scope | only fig-* and feat-*; Woo is authoritative | ported | METADATA-30 | — |
| tag:DATA-MODEL | settings-credentials | woo_wp_username and woo_app_password settings | ported | SETTINGS-20 | — |
| tag:DATA-MODEL | settings-store-url-env | store URL is not a settings row | ported | SETTINGS-5 | — |
| tag:DATA-MODEL | settings-inference | inference base URL, key, model, enabled flags | ported | SETTINGS-30 | — |
| tag:DATA-MODEL | settings-brand-geo | brand_voice and geo_context settings | ported | SETTINGS-30 | — |
| tag:DATA-MODEL | settings-create-status | woo_create_status defaults draft | ported | SETTINGS-65 | — |
| tag:DATA-MODEL | settings-taxonomy-stamps | taxonomy_last_sync_at and taxonomy_anchor | ported | SETTINGS-55, SETTINGS-60 | — |
| tag:DATA-MODEL | settings-crop-1600-080 | output_px_min 1600, fill_target 0.80 | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
| tag:DATA-MODEL | settings-client-prefs | sheet and slider prefs in client localStorage | ported | CAPTURE-145 | — |
| tag:DATA-MODEL | settings-aes | AES master key env, value_enc = nonce plus ciphertext | ported | SETTINGS-25 | — |
| tag:DATA-MODEL | sync-log | append-only sync_log, not deleted on publish | ported | PUBLISH-30 | — |
| tag:DATA-MODEL | inference-log | append-only inference_log audit | ported | METADATA-66 | — |
| tag:DATA-MODEL | contract-details | money float internals, JSON arrays, payload_hash, soft-delete mechanics | dropped | — | schema contract detail |
| tag:DATA-MODEL | prompt-files | prompts bind-mounted as text files, read fresh, reset script | superseded | METADATA-105 | ruling: prompts live in the DB with all revisions |
-48
View File
@@ -1,48 +0,0 @@
# Coverage ledger — tag:DEPLOYMENT
Source `tag:DEPLOYMENT` (`docs/DEPLOYMENT.md` at tag `pre-simplification`, role: deploy
playbook). Vetted 2026-09-18. Testable rules are OPS/SETTINGS; operational facts live in
`deploy/`. Doc retired (see migration TODOs).
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:DEPLOYMENT | env-table | env carries WOO_BASE_URL, Woo credentials, inference key | ported | OPS-10, SETTINGS-5, SETTINGS-30 | — |
| tag:DEPLOYMENT | rotate-creds | rotate Woo credentials then test-woo | duplicate | SETTINGS-40 | rotation is procedure; the verification is test-woo |
| tag:DEPLOYMENT | aes-rekey | SLAB_AES_KEY change requires re-encrypting settings | deferred | — | deferred ruling, audit section 18 item 7 |
| tag:DEPLOYMENT | preamble-hybrid-sot | client owns measure/crop; server-side measure/crop scaffolding not done | ported | CAPTURE-5, CAPTURE-135 | — |
| tag:DEPLOYMENT | preamble-uat-draft | Woo UAT is a draft with `SLAB-UAT-*` on the production store | ported | PUBLISH-10 | — |
| tag:DEPLOYMENT | preamble-inference-remote | inference is a remote configurable endpoint, not on the app host | ported | OPS-45 | — |
| tag:DEPLOYMENT | lab-host-example | historical lab host `192.168.1.201` | dropped | — | non-normative historical example |
| tag:DEPLOYMENT | host-inventory | docker ps, Caddy config, free port, volume convention | ported | OPS-15 | — |
| tag:DEPLOYMENT | reachability | confirm the hostname is reachable from the phone network | ported | OPS-5 | — |
| tag:DEPLOYMENT | inventory-before-bind | write values into deploy/INVENTORY.md and host .env before compose up | ported | OPS-15 | — |
| tag:DEPLOYMENT | repo-layout | repository layout at scaffold | dropped | — | scaffold detail |
| tag:DEPLOYMENT | store-url-env | WOO_BASE_URL in deploy/.env, injected into FastAPI | ported | SETTINGS-5, OPS-10 | — |
| tag:DEPLOYMENT | store-url-https | HTTPS only; FastAPI refuses missing or http at startup | ported | SETTINGS-10 | — |
| tag:DEPLOYMENT | store-url-not-settings-row | not a Settings input and not a SQLite settings row | ported | SETTINGS-5 | — |
| tag:DEPLOYMENT | store-url-readonly | Settings UI shows the URL read-only | ported | SETTINGS-5 | — |
| tag:DEPLOYMENT | app-password-out-of-compose | Woo application password stays out of compose; entered in Settings | ported | SETTINGS-20 | — |
| tag:DEPLOYMENT | house-rules-localhost-caddy | services bind 127.0.0.1; Caddy is the only public surface | ported | OPS-20 | — |
| tag:DEPLOYMENT | house-rules-pinned-images | no latest tags; restart unless-stopped; mem_limit | ported | OPS-25 | — |
| tag:DEPLOYMENT | caddy-fragment | Caddy fragment contents | dropped | — | deploy detail |
| tag:DEPLOYMENT | secret-aes-key | SLAB_AES_KEY env, mode 600, 32-byte base64, never in the repo | ported | SETTINGS-25, OPS-10 | — |
| tag:DEPLOYMENT | secret-woo-creds | Woo credentials AES-GCM in SQLite, dedicated low-priv user, not consumer keys | ported | SETTINGS-15, SETTINGS-20 | — |
| tag:DEPLOYMENT | secret-inference-key | inference API key AES-GCM in SQLite | ported | SETTINGS-30 | — |
| tag:DEPLOYMENT | woocred-https-store | store must already be on HTTPS for Application Passwords | ported | SETTINGS-15 | — |
| tag:DEPLOYMENT | woocred-dedicated-user | dedicated low-privilege WordPress user | ported | SETTINGS-15 | — |
| tag:DEPLOYMENT | woocred-generate-password | generate the application password in the WP profile | dropped | — | procedure |
| tag:DEPLOYMENT | woocred-enter-creds | enter credentials in Settings; AES-GCM; browser never keeps them | ported | SETTINGS-20 | — |
| tag:DEPLOYMENT | backup-sqlite-images | WAL-safe SQLite backup plus images tar; 7 daily and 4 weekly | ported | OPS-30 | — |
| tag:DEPLOYMENT | backup-prompt-files | prompt files live in git and need no separate backup | superseded | — | prompts move to the DB with all revisions (GAP-8) |
| tag:DEPLOYMENT | backup-offbox | mirror the backup to an off-box share | ported | OPS-30 | — |
| tag:DEPLOYMENT | restore-tested | stop, restore DB and images, start; tested once | ported | OPS-35 | — |
| tag:DEPLOYMENT | runbook | operations runbook | dropped | — | procedure |
| tag:DEPLOYMENT | smoke-health | health ok from the phone over HTTPS | ported | APP-45 | — |
| tag:DEPLOYMENT | smoke-test-woo | test-woo ok, create status draft, inference off | ported | SETTINGS-40, SETTINGS-65 | — |
| tag:DEPLOYMENT | smoke-capture-to-ready | create a slab, upload fixtures, poll to ready | ported | APP-5 | — |
| tag:DEPLOYMENT | smoke-bdft-price | verify bdft and price against the fixture | ported | CAPTURE-90, REVIEW-25 | — |
| tag:DEPLOYMENT | smoke-uat-draft | publish a `SLAB-UAT-*` slab; Woo product must be draft | ported | PUBLISH-10 | — |
| tag:DEPLOYMENT | smoke-cleanup | leave or delete the UAT draft | dropped | — | procedure |
| tag:DEPLOYMENT | scaling-postgres-redis | Postgres/Redis/GPU scaling paths | dropped | — | post-POC |
| tag:DEPLOYMENT | scaling-never-lower-1600 | never lower output_px_min below 1600 | superseded | CAPTURE-105 | PoR floor is 800 px |
| tag:DEPLOYMENT | scaling-rembg | RemBG is not part of the hybrid happy path | dropped | — | design note |
-82
View File
@@ -1,82 +0,0 @@
# Coverage ledger — tag:IMPL-PLAN
Source `tag:IMPL-PLAN` (`docs/IMPL-PLAN.md` at tag `pre-simplification`). Vetted 2026-09-18.
Process history and duplicates; doc retired (see migration TODOs).
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:IMPL-PLAN | ground-rules | deterministic core tested first, no feature without a passing test, inference toggleable | ported | QUALITY-5, QUALITY-10, QUALITY-20 | — |
| tag:IMPL-PLAN | gates-abc | Gate A docs freeze, Gate B host, Gate C first draft listing | dropped | — | superseded by MILESTONES and the PoR |
| tag:IMPL-PLAN | phase0-client-modules | client pure modules for the deterministic core | ported | CAPTURE-5, CAPTURE-80, CAPTURE-85, CAPTURE-90 | — |
| tag:IMPL-PLAN | phase0-fastapi-skeleton | app factory, health, SQLite, settings AES, draft store stub | ported | APP-30, APP-45, SETTINGS-20, SETTINGS-25, APP-40 | — |
| tag:IMPL-PLAN | phase0-tests-tv | unit tests for TV-1 through TV-10 | ported | QUALITY-15 | — |
| tag:IMPL-PLAN | phase0-fixture-set | fixture photo set for Gate B review | dropped | — | process |
| tag:IMPL-PLAN | phase0-no-server-recompute | server stores client numbers, no server recompute | ported | CAPTURE-135, APP-40 | — |
| tag:IMPL-PLAN | phase0-u2net-stub | real U2Net stubbed, deferred | ported | CAPTURE-165 | — |
| tag:IMPL-PLAN | phase0-species-synced-only | species offered are exactly the synced leaves | ported | METADATA-5 | — |
| tag:IMPL-PLAN | phase0-offline-deferred | offline PWA deferred | ported | CAPTURE-15 | — |
| tag:IMPL-PLAN | phase0-no-fake-upscale | undersized source warns and requires retake | ported | CAPTURE-105 | — |
| tag:IMPL-PLAN | phase0-acceptance-math | bdft identity, no divide-by-12, rounding | ported | CAPTURE-90, QUALITY-100 | — |
| tag:IMPL-PLAN | phase0-health-compose | health up in compose after INVENTORY is filled | ported | OPS-15, APP-45 | — |
| tag:IMPL-PLAN | phase0-settings-aes | AES round-trip, secrets never in GET | ported | SETTINGS-75, QUALITY-125 | — |
| tag:IMPL-PLAN | phase0-draft-store | draft stores uploads without server recompute | ported | APP-40 | — |
| tag:IMPL-PLAN | phase0-coverage-property | coverage target and property checks | ported | QUALITY-40, QUALITY-45 | — |
| tag:IMPL-PLAN | phase0-exit-gate | Ty confirms the shown output | ported | QUALITY-30 | — |
| tag:IMPL-PLAN | phase1-capture-1-5 | online capture of 1-5 photos | superseded | CAPTURE-25 | no cap |
| tag:IMPL-PLAN | phase1-four-knobs | four knobs inline with live re-run | superseded | CAPTURE-40 | two knobs |
| tag:IMPL-PLAN | phase1-client-measure-png | client sqft/bdft/widths and 3:4 PNG prep | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90, CAPTURE-95 | — |
| tag:IMPL-PLAN | phase1-manual-fields | manual SKU, length, thickness, taxonomy | ported | CAPTURE-70 | — |
| tag:IMPL-PLAN | phase1-calibrated-upload | calibrated draft upload, server stores only | ported | CAPTURE-135 | — |
| tag:IMPL-PLAN | phase1-uat-draft | Woo draft create with `SLAB-UAT-*` on the production store | ported | PUBLISH-10 | — |
| tag:IMPL-PLAN | phase1-inference-off | inference remains off for the slice | ported | QUALITY-20 | — |
| tag:IMPL-PLAN | phase1-routers-openapi | routers per OPENAPI, generated openapi.yaml | dropped | — | contract detail |
| tag:IMPL-PLAN | phase1-optimistic-concurrency | client_rev/server_rev, 409 paths | ported | APP-20 | — |
| tag:IMPL-PLAN | phase1-status-machine-queue | status machine, queue only for publish and inference | ported | APP-5 | — |
| tag:IMPL-PLAN | phase1-integration-tests | create to ready, revision conflict, duplicate SKU, encryption, anchor | ported | QUALITY-35 | — |
| tag:IMPL-PLAN | phase1-put-stores-derived | PUT stores derived fields, does not recompute | ported | APP-40 | — |
| tag:IMPL-PLAN | phase1-taxonomy-anchor | anchor advances only on real change; client re-pulls | ported | SETTINGS-55, SETTINGS-60 | — |
| tag:IMPL-PLAN | phase2-offline | offline capture deferred | ported | CAPTURE-15 | — |
| tag:IMPL-PLAN | phase3-review-override-paths | widths/sqft/bdft shown with manual override paths | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
| tag:IMPL-PLAN | phase3-show-pngs | show processed PNGs, originals while retained | ported | PUBLISH-40 | — |
| tag:IMPL-PLAN | phase3-inference-ui | 0.7 pre-fill, below empty, full ready set | ported | METADATA-45, REVIEW-5 | — |
| tag:IMPL-PLAN | phase3-capture-four-knobs | four knobs on the mask screen | superseded | CAPTURE-40 | two knobs |
| tag:IMPL-PLAN | phase3-publish-poll | publish button, poll, surface error detail | ported | PUBLISH-5, PUBLISH-30 | — |
| tag:IMPL-PLAN | phase3-settings-ui | settings screen for creds, inference, pricing, brand/GEO, create status | ported | SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65 | — |
| tag:IMPL-PLAN | phase3-secrets-masked | secrets never appear in GET | ported | SETTINGS-75 | — |
| tag:IMPL-PLAN | phase4-woo-client | Woo client with Basic auth, taxonomy sync | ported | SETTINGS-15, SETTINGS-50 | — |
| tag:IMPL-PLAN | phase4-auto-assign | auto-assign categories, five attributes, fig/feat tags | ported | METADATA-10, METADATA-15, METADATA-30 | — |
| tag:IMPL-PLAN | phase4-media-create | media upload of processed PNGs, product create per status | ported | PUBLISH-40, PUBLISH-10 | — |
| tag:IMPL-PLAN | phase4-dedupe | dedupe by SKU before create, 409 recovery | ported | PUBLISH-15 | — |
| tag:IMPL-PLAN | phase4-stale-recovery | 422 stale-field recovery with current options | ported | PUBLISH-20 | — |
| tag:IMPL-PLAN | phase4-sync-log-purge | sync_log, set published, purge images | ported | PUBLISH-25, PUBLISH-30 | — |
| tag:IMPL-PLAN | phase4-taxonomy-sync | taxonomy sync endpoint against the real store | ported | SETTINGS-50, SETTINGS-55 | — |
| tag:IMPL-PLAN | phase4-integration-respx | integration tests against a Woo mock | ported | QUALITY-35 | — |
| tag:IMPL-PLAN | phase4-partial-failure | orphaned media logged, manual cleanup note | ported | PUBLISH-31 | — |
| tag:IMPL-PLAN | phase5-call1-proxy | Call 1 proxy, all photos at 1024, taxonomy, 0.7 | ported | METADATA-35, METADATA-40, METADATA-45 | — |
| tag:IMPL-PLAN | phase5-prefill-rules | at or above threshold pre-fill, below empty | ported | METADATA-45 | — |
| tag:IMPL-PLAN | phase5-call2-proxy | Call 2 proxy, never auto, prose only, templates inject numbers | ported | METADATA-70, METADATA-65 | — |
| tag:IMPL-PLAN | phase5-portable-context | portable Call 1 context, previous_response_id | superseded | — | thread work out of MVP |
| tag:IMPL-PLAN | phase5-test-inference | test-inference probe | ported | SETTINGS-45 | mode removed |
| tag:IMPL-PLAN | phase5-prompt-files | prompt files read fresh each call | superseded | METADATA-105 | prompts live in the DB with all revisions |
| tag:IMPL-PLAN | phase5-no-invention | no numeric invention, LoRA deferred | ported | METADATA-65 | — |
| tag:IMPL-PLAN | phase6-host-inventory | host inventory pass | ported | OPS-15 | — |
| tag:IMPL-PLAN | phase6-deploy-backup | deploy, backup, restore tested | ported | OPS-5, OPS-30, OPS-35 | — |
| tag:IMPL-PLAN | phase6-uat-suite | UAT suite on real slabs | ported | QUALITY-110 | — |
| tag:IMPL-PLAN | phase6-e2e-browsers | end-to-end capture to publish on phone and PC browsers | ported | QUALITY-115 | — |
| tag:IMPL-PLAN | phase6-manual-length-override | manual length override recalculates widths/sqft/bdft | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
| tag:IMPL-PLAN | phase6-crop-80-1600 | crop 80 percent, shorter side 1600 | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent and 800 px |
| tag:IMPL-PLAN | phase6-woo-product | Woo product with processed PNGs and taxonomy | ported | PUBLISH-40, PUBLISH-45 | — |
| tag:IMPL-PLAN | phase6-unit-tests-logs | deterministic functions tested, inference calls logged | ported | QUALITY-5, METADATA-66 | — |
| tag:IMPL-PLAN | risks-green-sheet | chroma key plus inline knobs, retake, never lower pixel floor | ported | CAPTURE-35, CAPTURE-41, CAPTURE-155 | — |
| tag:IMPL-PLAN | risks-never-lower-1600 | never lower output_px_min below 1600 | superseded | CAPTURE-105 | PoR floor is 800 px |
| tag:IMPL-PLAN | risks-no-vision | inference toggleable, manual fully functional | ported | METADATA-75 | — |
| tag:IMPL-PLAN | risks-partial-publish | orphaned media, retained images, manual cleanup | ported | PUBLISH-31 | — |
| tag:IMPL-PLAN | risks-sqlite | SQLite contention under concurrent polls | dropped | — | single owner, low volume |
| tag:IMPL-PLAN | risks-host-port | host port or Caddy collision | ported | OPS-15 | — |
| tag:IMPL-PLAN | risks-ruler-deferred | ruler detection deferred; manual length is authoritative | ported | CAPTURE-15 | — |
| tag:IMPL-PLAN | dod-phases | all phases exit-gated by Ty | dropped | — | process |
| tag:IMPL-PLAN | dod-acceptance | hybrid online acceptance met in Phase 6 UAT | ported | QUALITY-110 | — |
| tag:IMPL-PLAN | dod-tests-green-off | pytest, integration, UAT green with inference off | ported | QUALITY-20 | — |
| tag:IMPL-PLAN | dod-deploy-backup | deployed, HTTPS, backed up, restore tested | ported | OPS-5, OPS-30, OPS-35 | — |
| tag:IMPL-PLAN | dod-gitea-source | repo on gitea is source of truth | ported | OPS-50 | — |
| tag:IMPL-PLAN | dod-archive-frozen | archive is frozen history; live docs are the SoT | dropped | — | process |
-79
View File
@@ -1,79 +0,0 @@
# Coverage ledger — tag:OPENAPI
Source `tag:OPENAPI` (`docs/OPENAPI.md` at tag `pre-simplification`, role: contract). Walked
2026-09-18. Wire detail (endpoints, schemas, examples) is contract material for
`docs/contracts/API.md`; only the testable rules carry PoR IDs.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:OPENAPI | auth-no-client-auth | no client auth; trusted LAN/Tailscale plus HTTPS | ported | OPS-55 | — |
| tag:OPENAPI | error-envelope | uniform error envelope { code, message, detail } | ported | APP-35 | — |
| tag:OPENAPI | error-400-404 | 400 validation; 404 not_found | ported | APP-35 | — |
| tag:OPENAPI | error-409-duplicate-sku | 409 duplicate_sku, SKU exists in Woo any status | ported | PUBLISH-15 | — |
| tag:OPENAPI | error-409-revision-conflict | 409 revision_conflict with current_server_rev | ported | APP-20 | — |
| tag:OPENAPI | error-422-unpublishable | 422 unpublishable with field keys, no raw code | ported | REVIEW-5, REVIEW-20 | — |
| tag:OPENAPI | error-412-validation-stale | 412 validation_module_stale | dropped | — | validation module ruled cut 2026-09-18 |
| tag:OPENAPI | error-502-woo | 502 woo_error carries the Woo error body | ported | PUBLISH-30 | — |
| tag:OPENAPI | error-500-internal | 500 internal | ported | APP-35 | — |
| tag:OPENAPI | revisions | client_rev/server_rev, mismatch 409 revision_conflict | ported | APP-20 | — |
| tag:OPENAPI | photos-multipart-40mb | multipart, max 40 MB per request | dropped | — | contract detail |
| tag:OPENAPI | photos-meta-role-kind | meta { kind: inventory, role, seq } | dropped | — | role/kind label discarded per CAPTURE migration |
| tag:OPENAPI | pagination-full-arrays | lists return full arrays | ported | APP-35 | — |
| tag:OPENAPI | api-version | /api/v1, breaking changes bump v2 | ported | APP-30 | — |
| tag:OPENAPI | schema-sku | SKU string 3..24 | ported | REVIEW-45 | — |
| tag:OPENAPI | schema-length-thickness | length_in and thickness_in 1/8 step | ported | CAPTURE-70 | — |
| tag:OPENAPI | schema-derived | sqft, bdft, widths computed by client | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90 | — |
| tag:OPENAPI | schema-species-woodcategory | species leaf 1, wood categories 1+ | ported | METADATA-10 | — |
| tag:OPENAPI | schema-edge-figure | edge 1, figure 1+ | ported | METADATA-15 | — |
| tag:OPENAPI | schema-grade-cardinality | grade 1+ | superseded | METADATA-15 | grade is exactly one |
| tag:OPENAPI | schema-thickness-roundup | thickness term by round-up | ported | METADATA-15 | — |
| tag:OPENAPI | schema-moisture-default | moisture defaults kiln-dried | ported | METADATA-15 | — |
| tag:OPENAPI | schema-fig-tags-cardinality | fig-* 1+ | superseded | METADATA-30 | tags optional |
| tag:OPENAPI | schema-feat-tags | feat-* 0+ | ported | METADATA-30 | — |
| tag:OPENAPI | schema-price | price 2 dp, price_source recommendation or override | ported | REVIEW-25 | — |
| tag:OPENAPI | schema-short-title | short_title field | superseded | REVIEW-10 | Woo canonical name is short_description |
| tag:OPENAPI | slab-read-model | status, inference_status, woo_product_id, published_at, revisions | ported | APP-5 | — |
| tag:OPENAPI | photo-read-410 | original/processed urls, 410 after publish | ported | PUBLISH-25 | — |
| tag:OPENAPI | call1-result-shape | Call 1 taxonomy ids plus confidences | ported | METADATA-40 | — |
| tag:OPENAPI | call1-free-text-to-feat | free-text observations mapped to synced feat-* tags | ported | METADATA-100 | — |
| tag:OPENAPI | call2-result-shape | title, short_title, description | ported | METADATA-55 | short_title naming migrated to short_description |
| tag:OPENAPI | price-rule-shape | flat price_per_bdft per species | ported | SETTINGS-35 | — |
| tag:OPENAPI | settings-view-shape | settings read model; secrets masked | ported | SETTINGS-5, SETTINGS-30, SETTINGS-35, SETTINGS-65, SETTINGS-75 | — |
| tag:OPENAPI | settings-view-crop | fill_target 0.80, output_px_min 1600 | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
| tag:OPENAPI | meta-endpoint | GET /meta version, templates_configured | dropped | — | contract detail |
| tag:OPENAPI | create-slab-calibrated | POST /slabs returns 201 calibrated, stores client numbers | ported | CAPTURE-135, APP-5 | — |
| tag:OPENAPI | create-slab-auto-call1 | auto-run Call 1 only if inference_enabled | ported | METADATA-35 | — |
| tag:OPENAPI | create-slab-confidence-persist | persist inferred values only at or above 0.7 | ported | METADATA-45 | — |
| tag:OPENAPI | create-slab-dup-sku | 409 duplicate_sku on create | ported | PUBLISH-15 | — |
| tag:OPENAPI | get-slabs-list | GET /slabs most recent first | dropped | — | contract detail |
| tag:OPENAPI | get-slab-poll | GET /slabs/{id} poll with backoff | dropped | — | contract detail |
| tag:OPENAPI | put-slab-stores-derived | PUT stores client derived fields, never recomputes | ported | APP-40 | — |
| tag:OPENAPI | put-slab-dup-sku-actions | duplicate SKU actions edit_sku/open_existing, never raw code | ported | PUBLISH-15, PUBLISH-35 | — |
| tag:OPENAPI | put-slab-stale-field | 422 stale_field with current options | ported | PUBLISH-20 | — |
| tag:OPENAPI | put-slab-412 | 412 validation module stale | dropped | — | validation module ruled cut |
| tag:OPENAPI | delete-slab-soft | DELETE soft, unpublished drafts only | dropped | — | contract detail |
| tag:OPENAPI | u2net-endpoint | POST u2net post-POC, not exposed | ported | CAPTURE-165 | — |
| tag:OPENAPI | photo-serve-410 | photo original/processed, 410 after publish | ported | PUBLISH-25 | — |
| tag:OPENAPI | infer-taxon-endpoint | POST infer-taxon, prompt fresh, 1024 plus taxonomy, 202 | ported | METADATA-35 | — |
| tag:OPENAPI | infer-taxon-image-source | original photos downscaled to 1024 | superseded | METADATA-35 | PoR sends the clean processed photos |
| tag:OPENAPI | infer-taxon-timeout | timeout over 45s returns 504 | ported | METADATA-75 | — |
| tag:OPENAPI | infer-content-endpoint | POST infer-content, 202, requires test-inference ok | ported | METADATA-55 | — |
| tag:OPENAPI | infer-content-context-thread | full resend default, previous_response_id, per-slab thread | superseded | — | thread work out of MVP |
| tag:OPENAPI | publish-preconditions | publish requires the full ready set, else 422 | ported | REVIEW-5 | — |
| tag:OPENAPI | publish-dup-sku | 409 duplicate_sku before create | ported | PUBLISH-15 | — |
| tag:OPENAPI | publish-stale | 422 stale_field, draft stays | ported | PUBLISH-20, PUBLISH-30 | — |
| tag:OPENAPI | publish-success | success sets woo_product_id, purges images, deletes draft | ported | PUBLISH-25 | — |
| tag:OPENAPI | publish-woo-fail | Woo failure returns failed, sync_log, 502 | ported | PUBLISH-30 | — |
| tag:OPENAPI | taxonomy-sync | sync rebuilds cache, anchor only on change, previous retained on failure | ported | SETTINGS-50, SETTINGS-55 | — |
| tag:OPENAPI | pricing-crud | pricing rule CRUD | ported | SETTINGS-35 | — |
| tag:OPENAPI | settings-get | GET settings returns SettingsView | ported | SETTINGS-75 | — |
| tag:OPENAPI | settings-put | PUT settings partial, secrets re-encrypted, password never echoed | ported | SETTINGS-20, SETTINGS-30, SETTINGS-65 | — |
| tag:OPENAPI | settings-put-woo-url-reject | woo_base_url not writable | ported | SETTINGS-85 | — |
| tag:OPENAPI | settings-put-consumer-key-reject | reject consumer key/secret payload | ported | SETTINGS-15 | — |
| tag:OPENAPI | test-woo-fail-closed | test-woo fails closed on missing credentials or non-https | ported | SETTINGS-80 | — |
| tag:OPENAPI | test-inference-shape | test-inference ok, mode, vision_capable, detail | ported | SETTINGS-45 | mode removed by ruling |
| tag:OPENAPI | validation-module-endpoint | GET validation/module, hash, 412 on mismatch | dropped | — | ruled cut 2026-09-18 |
| tag:OPENAPI | examples-worked | worked request/response examples | dropped | — | non-normative examples |
| tag:OPENAPI | example-taxonomy-sync-key | example uses synced_at while spec says taxonomy_last_sync_at | dropped | — | contract detail inconsistency |
| tag:OPENAPI | versioning-client-pin | client pins the version it was built against | ported | APP-30 | — |
| tag:OPENAPI | out-of-contract-v1 | bulk, staff/role, webhook push, analytics out of contract | dropped | — | contract detail |
-36
View File
@@ -1,36 +0,0 @@
# Coverage ledger — tag:PROMPTS
Source `tag:PROMPTS` (`docs/PROMPTS.md` at tag `pre-simplification`, role: prompt spec).
Walked 2026-09-18. Prompt templates are contract material for `docs/contracts/PROMPTS.md`;
the testable rules carry PoR IDs.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:PROMPTS | management-location | prompts are server text files under backend/app/prompts, mounted read-only | superseded | METADATA-105 | ruling: prompts live in the DB with all revisions |
| tag:PROMPTS | management-edit-host | edit the text files on the host, no restart | superseded | — | prompts move to the DB |
| tag:PROMPTS | management-reset-script | python -m app.prompts.reset restores defaults | superseded | — | replaced by DB revision history |
| tag:PROMPTS | management-version-log | inference_log stores prompt content or hash per call | ported | METADATA-66 | — |
| tag:PROMPTS | call1-purpose | classify species, edge, figure, grade from photos using synced lists only | ported | METADATA-40 | — |
| tag:PROMPTS | call1-inputs-photos | 1-5 inventory photos downscaled to 1024 | ported | METADATA-35 | downsample rule |
| tag:PROMPTS | call1-photo-cap | 1-5 inventory photos | superseded | CAPTURE-25 | no photo cap |
| tag:PROMPTS | call1-inputs-taxonomy | species, edge, figure, grade lists with ids | ported | METADATA-35, METADATA-40 | — |
| tag:PROMPTS | call1-inputs-metadata | user metadata SKU, length, thickness | ported | METADATA-35 | — |
| tag:PROMPTS | call1-system-constraint | only use provided taxonomy values, do not invent | ported | METADATA-40 | — |
| tag:PROMPTS | call1-schema | taxonomy ids plus confidences and free-text observations | ported | METADATA-40 | — |
| tag:PROMPTS | call1-free-text-to-feat | free-text observations mapped to synced feat-* tags | ported | METADATA-100 | — |
| tag:PROMPTS | call1-prepopulation | Call 1 is the primary pre-fill source when inference is enabled | ported | METADATA-45 | — |
| tag:PROMPTS | call1-confidence-gate | at or above 0.7 pre-fill, below leaves empty | ported | METADATA-45 | — |
| tag:PROMPTS | call1-ready-not-defined | Call 1 does not define the ready gate | ported | METADATA-75 | — |
| tag:PROMPTS | call1-manual-wins | manual entry wins; Call 1 is assist only | ported | METADATA-50 | — |
| tag:PROMPTS | call2-purpose | the model writes descriptive prose only | ported | METADATA-60 | — |
| tag:PROMPTS | call2-inputs-context | portable Call 1 context, previous_response_id | superseded | — | thread work out of MVP |
| tag:PROMPTS | call2-confirmed-inferred | explicit confirmed versus inferred labels | ported | METADATA-60 | PoR states metadata confirmed or changed in review |
| tag:PROMPTS | call2-deterministic-measurements | deterministic measurements, never invented | ported | METADATA-60, METADATA-65 | — |
| tag:PROMPTS | call2-brand-geo | brand voice, GEO context, templates | ported | METADATA-60 | — |
| tag:PROMPTS | call2-validation-rules | shared-validation rules in Call 2 assembly | dropped | — | validation module ruled cut |
| tag:PROMPTS | call2-system-no-invention | measurements are not invented; only provided data is used | ported | METADATA-65 | — |
| tag:PROMPTS | call2-template-assembly | server assembles title and description from templates | ported | METADATA-70, METADATA-80 | — |
| tag:PROMPTS | call2-llm-not-dimensions | the LLM never touches dimensions | ported | METADATA-65 | — |
| tag:PROMPTS | call2-llm-disabled | content_llm_enabled false skips the LLM, templates are used | ported | METADATA-70 | — |
| tag:PROMPTS | tuning-workflow | iterate prompts against real slabs | dropped | — | procedure |
| tag:PROMPTS | title-example-emdash | example title uses an em dash and a multiplication sign | superseded | — | title separator is the hyphen; migration cleanup |
-51
View File
@@ -1,51 +0,0 @@
# Coverage ledger — tag:TECH-SPEC-PIPELINE
Source `tag:TECH-SPEC-PIPELINE` (`docs/TECH-SPEC-PIPELINE.md` at tag `pre-simplification`).
Vetted 2026-09-18. Rules are CAPTURE, vectors are QUALITY. Doc retired (see migration TODOs).
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:TECH-SPEC-PIPELINE | invariants-determinism | pure function of pixels and user inputs; no network in the math | ported | CAPTURE-5 | — |
| tag:TECH-SPEC-PIPELINE | invariants-manual-authoritative | user-entered length, thickness, SKU, price always win | ported | METADATA-50 | — |
| tag:TECH-SPEC-PIPELINE | invariants-fallback-chain | every computed value has a defined fallback; never silently guesses | ported | CAPTURE-155 | — |
| tag:TECH-SPEC-PIPELINE | invariants-units | inches in 1/8 steps; sqft and bdft 2 decimals | ported | CAPTURE-70, CAPTURE-160 | — |
| tag:TECH-SPEC-PIPELINE | invariants-client-authoritative | all deterministic processing in the browser; server stores | ported | CAPTURE-135, APP-40 | — |
| tag:TECH-SPEC-PIPELINE | browser-compat-floor | iOS Safari, Android Chrome, desktop Chrome/Edge/Firefox; hard stop | ported | CAPTURE-10 | — |
| tag:TECH-SPEC-PIPELINE | load-bearing-apis | canvas or OffscreenCanvas, image decode/crop/encode, fetch | ported | CAPTURE-140 | — |
| tag:TECH-SPEC-PIPELINE | units-table | per-quantity source and rounding | ported | CAPTURE-70, CAPTURE-85, CAPTURE-90, REVIEW-25, CAPTURE-160 | — |
| tag:TECH-SPEC-PIPELINE | bdft-not-rounded-whole | bdft is never rounded up to whole board feet | ported | CAPTURE-90 | — |
| tag:TECH-SPEC-PIPELINE | pixels-hidden | pixel-to-inch scale is an implementation detail | dropped | — | internal detail |
| tag:TECH-SPEC-PIPELINE | bg-removal-load-bearing | the mask defines the measurement footprint; the eye is the validator | ported | CAPTURE-50 | — |
| tag:TECH-SPEC-PIPELINE | sheet-detect | 2-5 percent border strip, HSV green or black, else user picks | ported | CAPTURE-35 | — |
| tag:TECH-SPEC-PIPELINE | overlay-source-photo | overlay the mask on the source photo and confirm the edge | ported | CAPTURE-50 | — |
| tag:TECH-SPEC-PIPELINE | knobs-inline-live | knobs inline on capture, live re-run, persisted, no server round trip | ported | CAPTURE-45, CAPTURE-145, CAPTURE-150 | — |
| tag:TECH-SPEC-PIPELINE | knobs-four | sheet, sensitivity, edge offset, feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
| tag:TECH-SPEC-PIPELINE | algorithms-floodfill | chroma key or threshold with border-connected flood fill | ported | CAPTURE-35, CAPTURE-41 | — |
| tag:TECH-SPEC-PIPELINE | bg-poc-client-u2net-deferred | client-only mask with retake; U2Net deferred, contract preserved | ported | CAPTURE-165 | — |
| tag:TECH-SPEC-PIPELINE | edge-quality-contiguous | the mask must be one contiguous slab contour, else retake | ported | CAPTURE-55 | — |
| tag:TECH-SPEC-PIPELINE | contour-length-axis | min-area bounding rect; long side is the length axis, prevailing length | ported | CAPTURE-60 | — |
| tag:TECH-SPEC-PIPELINE | axis-rotate-confirm | user rotates and confirms; nearly square picks the axis | ported | CAPTURE-60, CAPTURE-65 | — |
| tag:TECH-SPEC-PIPELINE | scale-formula | scale = longest_axis_px / user length | ported | CAPTURE-75 | — |
| tag:TECH-SPEC-PIPELINE | widths | sample every 6 inches perpendicular; min, max, average | ported | CAPTURE-80 | — |
| tag:TECH-SPEC-PIPELINE | sqft-formula | sqft = mask_pixels / scale squared / 144, 2 decimals | ported | CAPTURE-85 | — |
| tag:TECH-SPEC-PIPELINE | bdft-formula | bdft = sqft times thickness_in, 2 decimals | ported | CAPTURE-90 | — |
| tag:TECH-SPEC-PIPELINE | normalization-3-4-png | 3:4 portrait, transparent PNG, slab centered | ported | CAPTURE-95, CAPTURE-100 | — |
| tag:TECH-SPEC-PIPELINE | normalization-80-fill-1600 | 80 percent fill and 1600 px floor | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
| tag:TECH-SPEC-PIPELINE | normalization-every-photo | applies to every inventory photo | ported | CAPTURE-30, CAPTURE-110 | — |
| tag:TECH-SPEC-PIPELINE | dimension-overlay-v1_5 | auto-dimension overlay is v1.5, out of this spec | superseded | CAPTURE-120, CAPTURE-125 | the overlay is shipped |
| tag:TECH-SPEC-PIPELINE | pricing | species $/bdft, recommendation, override, no-rule behavior | ported | REVIEW-25, SETTINGS-35 | — |
| tag:TECH-SPEC-PIPELINE | constants-code | FILL_TARGET, ASPECT, OUTPUT_PX_MIN, WIDTH_SAMPLE, INCH_STEP, decimals | superseded | CAPTURE-100, CAPTURE-105 | fill and pixel floor superseded |
| tag:TECH-SPEC-PIPELINE | factory-bg-defaults | sheet auto, green hue/sat/val, black threshold | ported | CAPTURE-35, CAPTURE-41 | — |
| tag:TECH-SPEC-PIPELINE | tv1-green-sheet | interior green patch not touching the border survives; U2Net not invoked | ported | QUALITY-55 | — |
| tag:TECH-SPEC-PIPELINE | tv2-black-sheet | dark interior wood survives; flood fill isolates border-connected regions | ported | QUALITY-60 | — |
| tag:TECH-SPEC-PIPELINE | tv3-scale-sqft-bdft | 96 in, 1.5 in, 0.64 sqft yields 0.96 bdft | ported | QUALITY-65 | — |
| tag:TECH-SPEC-PIPELINE | tv4-width-samples | 6 inch stations on a 96 inch axis report min, max, average | ported | QUALITY-70 | — |
| tag:TECH-SPEC-PIPELINE | tv5-angled-end | one square end and one 45 degree cut; axis follows prevailing length | ported | QUALITY-75 | — |
| tag:TECH-SPEC-PIPELINE | tv6-crop | 3:4, fill at extremes, transparent PNG, shorter side floor | ported | QUALITY-80 | PoR uses 90 percent fill and the 800 px floor |
| tag:TECH-SPEC-PIPELINE | tv7-too-small | undersized source warns, is not upscaled, not publishable until retake | ported | QUALITY-85 | PoR floor is 800 px |
| tag:TECH-SPEC-PIPELINE | tv8-pricing | 0.96 bdft at 12.50 gives 12.00; override stored | ported | QUALITY-90 | — |
| tag:TECH-SPEC-PIPELINE | tv9-thickness-range | 1 in rounds up into the synced Woo thickness band | ported | QUALITY-95 | — |
| tag:TECH-SPEC-PIPELINE | tv10-rounding | half-up 2 decimal rounding cases | ported | QUALITY-100 | — |
| tag:TECH-SPEC-PIPELINE | assumptions | top-down first photo, user length, declared thickness, projected area | ported | CAPTURE-20, CAPTURE-70, CAPTURE-85, CAPTURE-90, CAPTURE-135 | — |
| tag:TECH-SPEC-PIPELINE | cross-reference-table | cross-references to other docs | dropped | — | stale refs; migration |
| tag:TECH-SPEC-PIPELINE | issue4-version-pins | exact minimum version pins tracked under issue #4 | dropped | — | tracked as an issue, not a requirement |
-29
View File
@@ -1,29 +0,0 @@
# Coverage ledger — tag:TEST-SPEC
Source `tag:TEST-SPEC` (`docs/TEST-SPEC.md` at tag `pre-simplification`). Rows seeded by the
settings/auth/health pilot 2026-09-18. TEST-SPEC is reconciled LAST, against a finished PoR.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:TEST-SPEC | GC-1 | SLAB-UAT-* creates a Woo draft regardless of woo_create_status | ported | PUBLISH-10 | — |
| tag:TEST-SPEC | GC-2 | duplicate SKU returns 409 with edit_sku and open_existing | ported | PUBLISH-15 | — |
| tag:TEST-SPEC | GC-3 | test-woo returns ok with taxonomy and always resyncs on success | ported | SETTINGS-40 | — |
| tag:TEST-SPEC | GC-4 | WOO_BASE_URL compose-only, GET read-only, PUT rejects a write with 422 | ported | SETTINGS-85 | — |
| tag:TEST-SPEC | GC-5 | FastAPI refuses to start on missing or http WOO_BASE_URL | ported | SETTINGS-10 | — |
| tag:TEST-SPEC | W-3 | taxonomy anchor unchanged on no-op, bumps only on stored-subset hash change | ported | SETTINGS-55 | sync and rebuild live in SETTINGS-50 |
| tag:TEST-SPEC | W-4 | test-woo always runs a taxonomy resync after a successful test | duplicate | SETTINGS-40 | same rule as GC-3 |
| tag:TEST-SPEC | AES-1 | credential encrypt/decrypt round-trips; ciphertext never appears in GET | ported | SETTINGS-75, QUALITY-125 | — |
| tag:TEST-SPEC | AES-2 | missing SLAB_AES_KEY refuses startup, no plaintext fallback | ported | SETTINGS-25 | — |
| tag:TEST-SPEC | API-10 | test-woo fails closed on missing credentials or non-HTTPS URL | ported | SETTINGS-80 | — |
| tag:TEST-SPEC | FUND-21 | 401/400/404 mapped to an actionable error, not raw passthrough | ported | APP-50 | — |
| tag:TEST-SPEC | H-1 | health 200 with woo_reachable false when credentials are not configured | ported | APP-45 | — |
| tag:TEST-SPEC | INS-1 | health non-200 on DB or volume failure; woo_reachable false when the store is unreachable | ported | APP-45 | — |
| tag:TEST-SPEC | UIE-8 | Settings screen: WP creds and test-woo, inference and test, pricing, brand/GEO, create status, read-only URL | ported | SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65 | screen composition itself belongs to the contract/UX docs |
| tag:TEST-SPEC | OPS-3 | compose health, test-woo ok, create status draft at deploy | ported | QUALITY-110 | — |
| tag:TEST-SPEC | FUND-3, FUND-5, FUND-6, FUND-7 | keyboard tab order, visible focus ring, 320 px reflow | ported | APP-60 | found by TEST-SPEC reconciliation |
| tag:TEST-SPEC | FUND-24 | no secret material in logs | ported | APP-65 | found by TEST-SPEC reconciliation |
| tag:TEST-SPEC | V-3, API-8 | shared validation module hash, 412 | dropped | — | validation module ruled cut |
| tag:TEST-SPEC | C2-2 | previous_response_id, per-slab thread | superseded | — | thread work out of MVP |
| tag:TEST-SPEC | UI-1, UIE-9, UIE-10 | four knobs | superseded | CAPTURE-40 | two controls |
| tag:TEST-SPEC | UIE-1, E2E-4 | manual override of computed fields | superseded | REVIEW-30, REVIEW-40 | computed fields read-only |
| tag:TEST-SPEC | TV-6, TV-7, E2E-5 | 80 percent fill, 1600 px | superseded | CAPTURE-100, CAPTURE-105 | 90 percent, 800 px |
-78
View File
@@ -1,78 +0,0 @@
# Coverage ledger — tag:UX and tag:UX-DESIGN
Sources: `tag:UX` (`docs/UX.md`, SIGNED presentation spec) and `tag:UX-DESIGN`
(`docs/UX-DESIGN.md`, non-normative wireframes). Walked 2026-09-18. UX migrates to
`docs/contracts/UX.md`; UX-DESIGN is retired.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| tag:UX | jobs | five jobs: photo to listing, practice drafts, retake, fill fields, publish | ported | CAPTURE-5, PUBLISH-10, CAPTURE-50, METADATA-50, PUBLISH-5 | — |
| tag:UX | photos-1-to-5 | 1 to 5 inventory photos | superseded | CAPTURE-25 | no cap |
| tag:UX | no-calibration-photos | there are no calibration photos; only inventory photos exist | superseded | CAPTURE-25, CAPTURE-30 | the calibration photo exists |
| tag:UX | first-photo-topdown | first photo top-down, lens parallel to the broad face | ported | CAPTURE-20 | — |
| tag:UX | mask-overlay-only-check | the overlay on the source photo is the only quality check | ported | CAPTURE-50 | — |
| tag:UX | knobs-four | sheet, sensitivity, edge offset, feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
| tag:UX | knobs-live-persist | live re-run, no apply step, no server round trip, persisted | ported | CAPTURE-45, CAPTURE-145, CAPTURE-150 | — |
| tag:UX | u2net-deferred | server matting deferred, no control | ported | CAPTURE-165 | — |
| tag:UX | length-axis-confirm | bounding rect and axis, rotate, nearly square choice | ported | CAPTURE-60, CAPTURE-65 | — |
| tag:UX | numbers-manual | length, thickness, SKU entered by hand | ported | CAPTURE-70 | — |
| tag:UX | widths-min-max-avg | widths every 6 inches; min, max, average | ported | CAPTURE-80 | — |
| tag:UX | sqft-bdft-2dp | square feet and board feet to two decimals | ported | CAPTURE-85, CAPTURE-90 | — |
| tag:UX | bdft-identity | one square foot at one inch is one board foot, no divide-by-12 | ported | CAPTURE-90 | — |
| tag:UX | server-no-recompute | computed in the browser and stored as sent | ported | CAPTURE-135, APP-40 | — |
| tag:UX | photo-prep-80-1600 | 3:4 at 80 percent fill, shorter side 1600 or more | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
| tag:UX | continue-calibrated | Continue uploads originals, PNGs, numbers; status calibrated | ported | CAPTURE-135, APP-5 | — |
| tag:UX | vision-fill-threshold | Call 1 pre-fills at or above 0.7 after the draft uploads | ported | METADATA-35, METADATA-45 | — |
| tag:UX | inference-off-empty | with inference off every field starts empty | ported | METADATA-70 | — |
| tag:UX | inference-never-blocks | inference never blocks publish and never auto-publishes | ported | METADATA-75 | — |
| tag:UX | call2-not-auto | Call 2 fires only when the owner taps it | ported | METADATA-55 | — |
| tag:UX | sku-charset | SKU `[A-Z0-9-]{3,24}` | ported | REVIEW-45 | PoR allows alphanumeric (lowercase too) |
| tag:UX | length-thickness-editable | length and thickness editable in review | ported | REVIEW-40 | — |
| tag:UX | calc-fields-override | square feet, board feet, widths have a manual override path | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
| tag:UX | species-exactly-one | exactly one species leaf from the synced list | ported | METADATA-10 | — |
| tag:UX | wood-categories-1plus | at least one wood category | ported | METADATA-10 | — |
| tag:UX | edge-one | one edge type | ported | METADATA-15 | — |
| tag:UX | figure-1plus | at least one figure term | ported | METADATA-15 | — |
| tag:UX | grade-1plus | at least one grade term | superseded | METADATA-15 | grade is exactly one |
| tag:UX | thickness-band-roundup | thickness band rounds up into the synced Woo range | ported | METADATA-15 | — |
| tag:UX | moisture-default | moisture defaults kiln-dried, never inferred | ported | METADATA-15 | — |
| tag:UX | fig-tags-1plus | at least one fig-* tag | superseded | METADATA-30 | tags are optional |
| tag:UX | feat-tags-0plus | zero or more feat-* tags | ported | METADATA-30 | — |
| tag:UX | price-reco-override | recommendation from species $/bdft, override sticks | ported | REVIEW-25 | — |
| tag:UX | price-no-rule-manual | no rule leaves the price empty; the owner types it | ported | REVIEW-25 | price is required before create |
| tag:UX | title-desc-not-required-to-ready | title, short title, description not required to reach ready | superseded | REVIEW-10 | they are required on the product |
| tag:UX | templates-always-run | templates always run and inject the numbers | superseded | METADATA-70, METADATA-80 | the model writes when Call 2 runs; template is the fallback |
| tag:UX | inline-nudges | per-field inline nudge; no single submit-time list | ported | REVIEW-20 | — |
| tag:UX | generate-text-button | Generate text runs Call 2 and fills title, short title, description | ported | METADATA-55 | — |
| tag:UX | draft-vs-live | `woo_create_status` default draft; SLAB-UAT-* always draft | ported | PUBLISH-10, SETTINGS-65 | — |
| tag:UX | published-not-live | published means the store create succeeded, not that it is live | ported | PUBLISH-50 | — |
| tag:UX | publish-success | success cleans up the local draft and photos | ported | PUBLISH-25 | — |
| tag:UX | publish-dup-sku | duplicate SKU on the field, edit SKU or open existing | ported | PUBLISH-15 | — |
| tag:UX | publish-stale-value | stale value on the field, pick from the current list | ported | PUBLISH-20 | — |
| tag:UX | publish-store-unreachable | plain message, no status number or stack trace, retry | ported | PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-38 | — |
| tag:UX | publish-log-retry | the server logs every attempt; retry from the same screen | ported | PUBLISH-30 | — |
| tag:UX | browser-matrix | iOS Safari, Android Chrome, desktop Chrome/Edge/Firefox | ported | CAPTURE-10 | — |
| tag:UX | unsupported-hard-stop | hard stop with an update or switch link, no polyfill | ported | CAPTURE-10 | — |
| tag:UX | deferred-features | photo quality, OCR, ruler, server measuring, U2Net, offline deferred | ported | CAPTURE-15, CAPTURE-165 | — |
| tag:UX | where-jobs-live-table | where each job lives | dropped | — | stale refs; migration |
| tag:UX-DESIGN | wireframes-non-normative | wireframes are non-normative and defer to UX.md | dropped | — | non-normative by its own text |
| tag:UX-DESIGN | settings-screen | settings fields, test connections, pricing, brand/GEO | ported | SETTINGS-5, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65 | — |
| tag:UX-DESIGN | settings-vision-content-flags | Enable Vision and Enable Content default off | ported | SETTINGS-90 | — |
| tag:UX-DESIGN | settings-test-inference | test connection shows stateful/stateless/fail | ported | SETTINGS-45 | mode kept as diagnostic; Call 2 does not use thread IDs |
| tag:UX-DESIGN | generate-text-availability | Generate text appears only when content generation is enabled and reachable; disabled when the service is down | ported | METADATA-110 | — |
| tag:UX-DESIGN | settings-reset-knobs | reset capture knobs to default | ported | CAPTURE-145 | — |
| tag:UX-DESIGN | first-run-guidance | first-run card | dropped | — | presentation detail; fold into the UX contract |
| tag:UX-DESIGN | capture-knobs-four | four knobs on capture | superseded | CAPTURE-40 | two knobs |
| tag:UX-DESIGN | type-values-screen | SKU, length, thickness, computed values, done | ported | CAPTURE-70, CAPTURE-85, CAPTURE-90 | — |
| tag:UX-DESIGN | calc-values-override | typed number wins over the computed value | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
| tag:UX-DESIGN | review-screen-fields | all store fields on review | ported | REVIEW-5, METADATA-10, METADATA-15, METADATA-30, REVIEW-25 | minus grade/fig cardinality |
| tag:UX-DESIGN | review-grade-fig-mandatory | grade and fig-* mandatory | superseded | METADATA-15, METADATA-30 | grade exactly one; tags optional |
| tag:UX-DESIGN | ai-badge | AI badge on Call 1-filled fields | dropped | — | presentation detail; fold into the UX contract |
| tag:UX-DESIGN | per-field-nudges | per-field nudges as the user works | ported | REVIEW-20 | — |
| tag:UX-DESIGN | submit-time-validation | submit-time validation fires after all fields are filled | superseded | REVIEW-20 | no single submit-time list |
| tag:UX-DESIGN | title-desc-screen | separate Title/Description screen, exit review returns to calibrated | ported | APP-10, REVIEW-40 | — |
| tag:UX-DESIGN | publish-states | publishing, success, failed with retry | ported | PUBLISH-5, PUBLISH-25, PUBLISH-30 | — |
| tag:UX-DESIGN | error-states | mask, axis, too-small, duplicate SKU, stale value, unreachable | ported | CAPTURE-55, CAPTURE-105, PUBLISH-15, PUBLISH-20, PUBLISH-30 | — |
| tag:UX-DESIGN | vision-off-below-threshold | empty fields with nudges when vision is off or below threshold | ported | METADATA-45, METADATA-70 | — |
| tag:UX-DESIGN | browser-gate-message | hard-stop message says update Safari or Chrome | superseded | CAPTURE-10 | matrix includes PC browsers |
| tag:UX-DESIGN | uat-cases-derived | fourteen UAT cases derived from wireframes | duplicate | REVIEW-20, PUBLISH-10, PUBLISH-15, PUBLISH-20, METADATA-45, CAPTURE-105 | TEST-SPEC owns the test plan |
-59
View File
@@ -1,59 +0,0 @@
# Coverage ledger — UAT-PRACTICE.md
Source `cur:UAT-PRACTICE` (`docs/UAT-PRACTICE.md`, role: practice). Walked 2026-09-18.
Four conflicts were ruled superseded; the rest port to the PoR.
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|---|---|---|---|---|---|
| cur:UAT-PRACTICE | inventory-filled | deploy/INVENTORY filled before deploy | ported | OPS-15 | — |
| cur:UAT-PRACTICE | lan-tailscale | reachable over LAN/Tailscale | ported | OPS-5 | — |
| cur:UAT-PRACTICE | https-only | HTTPS only, valid cert | ported | OPS-5 | — |
| cur:UAT-PRACTICE | health-passes | health endpoint returns ok | ported | APP-30 | — |
| cur:UAT-PRACTICE | store-url-set | WOO_BASE_URL in host .env, https | ported | SETTINGS-5 | — |
| cur:UAT-PRACTICE | wp-store-https | WordPress store must already be on HTTPS | ported | SETTINGS-10, SETTINGS-15 | — |
| cur:UAT-PRACTICE | dedicated-low-priv-user | dedicated low-privilege WP user | ported | SETTINGS-15 | — |
| cur:UAT-PRACTICE | generate-password | generate application password in WP profile | dropped | — | procedure, not a requirement |
| cur:UAT-PRACTICE | enter-creds-encrypted | username + application password in Settings, server-encrypted | ported | SETTINGS-20 | — |
| cur:UAT-PRACTICE | test-woo-resync | test-woo reports ok and resyncs taxonomy | ported | SETTINGS-40 | — |
| cur:UAT-PRACTICE | fastapi-sole-caller | FastAPI is the only store caller | ported | APP-25 | — |
| cur:UAT-PRACTICE | rotate-creds | rotate application password then test-woo | duplicate | SETTINGS-40 | rotation is procedure; the verification is test-woo |
| cur:UAT-PRACTICE | sku-prefix | practice SKU starts with `SLAB-UAT-` | ported | PUBLISH-10 | — |
| cur:UAT-PRACTICE | always-draft | `SLAB-UAT-*` always creates a Woo draft | ported | PUBLISH-10 | — |
| cur:UAT-PRACTICE | create-status-default-draft | `woo_create_status` defaults to draft | ported | SETTINGS-65 | — |
| cur:UAT-PRACTICE | customers-never-see | a draft is hidden from the storefront | ported | PUBLISH-10, PUBLISH-50 | — |
| cur:UAT-PRACTICE | supported-browsers | iOS Safari, Android Chrome, desktop Chrome/Edge/Firefox; hard stop | ported | CAPTURE-10 | — |
| cur:UAT-PRACTICE | matrix-issue-refs | signed matrix and issue #4 provenance | dropped | — | stale provenance, not a requirement |
| cur:UAT-PRACTICE | photo-count-1-to-5 | take 1 to 5 photos | superseded | CAPTURE-25 | PoR is one or more; no cap in code |
| cur:UAT-PRACTICE | first-photo-topdown | first photo top-down, lens parallel | ported | CAPTURE-20 | — |
| cur:UAT-PRACTICE | four-knobs | Sheet, Sensitivity, Edge offset, Feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
| cur:UAT-PRACTICE | confirm-mask | confirm the mask | ported | CAPTURE-50 | — |
| cur:UAT-PRACTICE | length-axis | bounding rectangle, rotate, nearly square choice | ported | CAPTURE-60, CAPTURE-65 | — |
| cur:UAT-PRACTICE | enter-length-thickness-sku | length/thickness 1/8 inch, SKU typed | ported | CAPTURE-70 | — |
| cur:UAT-PRACTICE | computed-numbers | sqft, bdft, widths, bdft identity | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90 | — |
| cur:UAT-PRACTICE | continue-upload-status | Continue uploads originals, PNGs, numbers; status calibrated | ported | APP-5, CAPTURE-135 | — |
| cur:UAT-PRACTICE | species-exactly-one | pick exactly one species | ported | METADATA-10 | — |
| cur:UAT-PRACTICE | wood-categories | pick at least one wood category | ported | METADATA-10 | — |
| cur:UAT-PRACTICE | grade-at-least-one | at least one grade | superseded | METADATA-15 | grade is exactly one |
| cur:UAT-PRACTICE | figure-at-least-one | at least one figure | ported | METADATA-15 | — |
| cur:UAT-PRACTICE | thickness-band-roundup | thickness band rounds up | ported | METADATA-15 | — |
| cur:UAT-PRACTICE | moisture-default | moisture default kiln-dried | ported | METADATA-15 | — |
| cur:UAT-PRACTICE | fig-tags-at-least-one | pick at least one `fig-*` tag | superseded | METADATA-30 | tags are optional |
| cur:UAT-PRACTICE | feat-tags-zero-or-more | zero or more `feat-*` tags | ported | METADATA-30 | — |
| cur:UAT-PRACTICE | price-reco-override | recommendation from species `$/bdft`, override sticks | ported | REVIEW-25 | — |
| cur:UAT-PRACTICE | price-missing-rate | no rate starts empty, type it, publish allowed | ported | REVIEW-25 | price required before create; manual entry when no rate |
| cur:UAT-PRACTICE | title-description-templates | type or leave for templates; templates inject numbers | ported | METADATA-70, REVIEW-10 | — |
| cur:UAT-PRACTICE | inline-nudges-ready | inline nudge; cannot reach ready until filled | ported | REVIEW-20 | — |
| cur:UAT-PRACTICE | sku-check-create | server checks SKU, creates if new | ported | PUBLISH-5 | — |
| cur:UAT-PRACTICE | success-cleanup | success message, local draft and photos gone, status published | ported | PUBLISH-25, PUBLISH-50 | — |
| cur:UAT-PRACTICE | published-not-live | published means store create succeeded, not live | ported | PUBLISH-50 | — |
| cur:UAT-PRACTICE | store-draft-fields | confirm draft status and fields in the store | ported | PUBLISH-10, PUBLISH-40, PUBLISH-45, PUBLISH-55, METADATA-15 | — |
| cur:UAT-PRACTICE | storefront-hidden | not visible on the live storefront | ported | PUBLISH-10, PUBLISH-50 | — |
| cur:UAT-PRACTICE | repeat-pc | repeat the walk on a desktop browser | ported | CAPTURE-10 | — |
| cur:UAT-PRACTICE | pass-fail-checklist | pass/fail checks table | duplicate | REVIEW-5, PUBLISH-10, CAPTURE-90 | restates rows above |
| cur:UAT-PRACTICE | duplicate-sku-recovery | duplicate SKU recovery, edit or open existing | ported | PUBLISH-15 | — |
| cur:UAT-PRACTICE | fail-no-success | no success message, plain error, server log, retry | ported | PUBLISH-30, PUBLISH-35, PUBLISH-37 | — |
| cur:UAT-PRACTICE | fail-stale-value | value no longer valid, pick from current list | ported | PUBLISH-20, REVIEW-35 | — |
| cur:UAT-PRACTICE | fail-store-unreachable | store unreachable, plain message, retry | ported | PUBLISH-30, PUBLISH-36, PUBLISH-38 | — |
| cur:UAT-PRACTICE | cleanup-draft | leave or delete the UAT draft; app does not delete store products | dropped | — | procedure |
| cur:UAT-PRACTICE | final-poc-publish | final POC publish is a separate gated step | dropped | — | scope/procedure |
| cur:UAT-PRACTICE | where-rules-live | "where each rule lives" table | dropped | — | stale doc refs; migration |
-9
View File
@@ -1,9 +0,0 @@
.git
frontend/node_modules
frontend/dist
frontend/.svelte-kit
**/.DS_Store
*.md
!deploy/README.md
.audit
docs/archive
+1 -11
View File
@@ -35,14 +35,4 @@ images/
# Logs
*.log
# Playwright e2e (#65) — keep README; ignore churn
frontend/tests/e2e/artifacts/*.png
frontend/tests/e2e/artifacts/run-log.txt
frontend/tests/e2e/test-results/
frontend/tests/e2e/playwright-report/
frontend/blob-report/
frontend/playwright/.cache/
.uat-data/
.venv-overnight/
deploy/certs/
-5
View File
@@ -1,5 +0,0 @@
# Shipped milestones
Role: history. Historical log of shipped milestones. Cites requirement IDs; defines none.
Not yet written.
-5
View File
@@ -1,5 +0,0 @@
# Project
Role: vision. Overall project vision and current state. Cites requirement IDs; defines none.
Not yet written.
-5
View File
@@ -1,5 +0,0 @@
# Roadmap
Role: roadmap. Phases across all milestones. Cites requirement IDs; defines none.
Not yet written.
-27
View File
@@ -1,27 +0,0 @@
# M3 — roadmap
Role: roadmap. Objectives for v0.5 / M3. Cites requirement IDs; defines none.
## Objective
A Photos-ready slab becomes submission-ready for WooCommerce, and a practice `SLAB-UAT-*` draft
matching the reviewed ready set exists in the store. Create works with inference off.
## Capabilities in scope
- Woo connection (WordPress application password) and taxonomy sync.
- Review to the full ready set, with per-slab pricing and override.
- Inference assist (Call 1 / Call 2) is optional; create is independent of inference
(`METADATA-75`).
- Publish: create the Woo product; `SLAB-UAT-*` always a draft.
## Sources of truth
- Behavior and acceptance: `docs/capabilities/CREATE.md`.
- Scope and the M3 ID subset: `docs/MILESTONES.md`.
- Interfaces: `docs/contracts/` (`API.md`, `WOO-API.md`, `DATA-MODEL.md`, `PROMPTS.md`, `UX.md`,
`CONTENT-WOO.md`, `TEST-SPEC.md`).
## Exit
Per `docs/capabilities/CREATE.md` (`QUALITY-110`).
@@ -1,86 +0,0 @@
# M3 — task breakdown
Role: breakdown. Ordered task list for v0.5 / M3. Cites requirement IDs; defines none.
Objectives: `roadmap.md` in this folder. Behavior and acceptance: `docs/capabilities/CREATE.md`.
Scope and IDs: `docs/MILESTONES.md`. Decisions live in `docs/contracts/`, not here.
TDD every slice: the failing test precedes its implementation; never parallelize RED and GREEN of
the same scenario. Execute slices **serially** (one local GPU; see `AGENTS.md`). Frontend ships
**with** the slice that first needs the screen, not as a trailing blob.
## Hard dependencies
```
S0 full schema.sql + factory + health
└─ S1 crypto + Settings
├─ S2 slabs CRUD (needs S0 tables; tests seed fixture taxonomy rows)
│ └─ S3 photos (needs slab_id)
└─ S4 Woo + taxonomy (needs S1 credentials only — not S2/S3)
│
S5 pricing + title/short-desc + ready ← S2 AND S4
├─ S6 publish ← S3 + S4 + S5
└─ S7 inference last ← S2 + S3 + S4 + S5; not S6
```
Default serial order for Atlas: **S0 → S1 → S2 → S3 → S4 → S5 → S6 → S7 → Exit**.
S4 does not wait on S2/S3. If S2 is blocked after S1, start S4. S7 does not wait on S6
(`METADATA-75`).
## Slices
| Slice | Delivers | M3 IDs | Hard deps |
|---|---|---|---|
| S0 | factory; **full** `schema.sql` from `docs/contracts/DATA-MODEL.md` (including empty taxonomy tables); config/startup guards; health/meta; JSON logs; Dockerfile | APP-30, APP-45, APP-55, APP-65, SETTINGS-10, SETTINGS-25, OPS-25 | — |
| S1 | AES-GCM crypto + Settings API/UI (masked, write-only secrets). Missing key refuses startup (S0); this slice encrypts/decrypts. Decrypt failure → owner re-enters credentials. | SETTINGS-5/15/20/25/30/75/85/90, QUALITY-125 | S0 |
| S2 | slabs CRUD, optimistic concurrency, soft delete. Fixture `woo_categories` rows for FK tests. Frontend: API client + slab list. | APP-5/15/20/35/40, REVIEW-45 | S0 |
| S3 | photos (multipart → BLOBs, original/processed, 410 after purge). Frontend: photo attach on the draft. | CAPTURE-95/105/130, PUBLISH-40 | S2 |
| S4 | Woo client (application-password Basic) + taxonomy sync + test-woo. Fills the S0 taxonomy tables. Owns `taxonomy_anchor` / `taxonomy_last_sync_at`. Frontend: Settings test-woo. | METADATA-5/10/15/20/30, SETTINGS-40/50/51/55/60/80, QUALITY-35 | S1 |
| S5 | species `$/bdft` rates; deterministic title + short_description; server-derived `ready`. Inference off: owner types `description` by hand (`METADATA-70`). Frontend: Review screen. | REVIEW-5/10/15/20/25/30/35/40/50, METADATA-70/80/85/90, SETTINGS-35, QUALITY-90/95 | S2, S4 |
| S6 | publish sequence, UAT draft, duplicate/stale/partial failure; upload processed PNGs; publish-time taxonomy refresh when last sync is stale (`SETTINGS-60`). Frontend: Publish screen. | PUBLISH-5/10/15/20/25/30/31/35/36/37/38/40/45/50/55/60, SETTINGS-60 | S3, S4, S5 |
| S7 | inference last: Call 1, Call 2 (one static seed; calculated title/short_description injected into the Call 2 prompt), prompt revision REST. Never blocks create. Frontend: Generate text control. | METADATA-35/40/45/50/55/60/65/66/75/95/100/105/106/110/115, SETTINGS-45/70 | S2, S3, S4, S5 |
| Exit | inference-off suite, Woo-mock integration, Compose UI E2E, real `SLAB-UAT-*` draft | QUALITY-20/35/50/105/110/120 | S0–S7 |
IDs split on purpose (do not “move” them to one slice):
| ID | Slices | Split |
|---|---|---|
| SETTINGS-25 | S0 + S1 | S0 refuses startup without the key; S1 implements AES-GCM |
| SETTINGS-60 | S4 + S6 | S4 owns anchor + cache rebuild; S6 refreshes on publish when stale |
| PUBLISH-40 | S3 + S6 | S3 stores PNG BLOBs; S6 uploads them to Woo |
| QUALITY-35 | S4 + Exit | S4 writes the Woo-mock tests; Exit re-runs them |
## Parked work items
1. The seven per-leaf Call 2 prose templates are **not M3**. S7 ships the single static `call2`
seed in `docs/contracts/PROMPTS.md` §6. The model keys off the already-calculated title,
short_description, fact block, photos, and metadata. Per-leaf templates are later.
2. `Specialty cuts` is out of M3: no title pattern, no prose template, no Call 2 prompt. Owner will
not inventory that leaf in M3.
3. S7 ships the prompt revision REST interface (`METADATA-106`) so UAT prompt tuning is an API call
on the trusted LAN. There is no prompt editing UI.
4. Authentication for the prompt revision interface is `later` (`METADATA-107`), not M3.
## Frontend layout
New code stays outside the Capture union: `frontend/src/api/*`, `frontend/src/create/*`.
| With slice | Screen |
|---|---|
| S1 | Settings (store creds, inference flags, secret masking) |
| S2 | Slab list / draft resume |
| S5 | Review (ready set, primary wood category, price, title/short_description) |
| S6 | Publish |
| S7 | Generate text |
## Verification
Slice bar: `docs/contracts/TEST-SPEC.md`. Docs gate: `python tools/check_requirements.py`.
Exit bar: `docs/MILESTONES.md` and `docs/capabilities/CREATE.md`.
## Not yet validated
- Long-horizon multi-step agentic loops (more than one or two tool round-trips).
- M3 implementation has not started.
+176 -48
View File
@@ -1,69 +1,197 @@
# AGENTS.md
# AGENTS.md — SlabUploader
`docs/REQUIREMENTS.md` is the only normative text. All other docs cite IDs; none define requirements.
Working contract for coding agents. Read before writing code.
## Structure contract (read before creating or moving any doc)
Status: **Gate A freeze** · signed 2026-08-31 PT · docs cut to `main` 2026-09-06 · SoT: Gitea `Ty_Tech/SlabUploader` on **gitea-atd** (not mirrors).
- Requirements live only in `docs/REQUIREMENTS.md`; milestone scope in `docs/MILESTONES.md`.
- Before creating or moving any doc under `docs/`, look up its home in `docs/STRUCTURE.md`.
- If no home exists, stop — do not create a new artifact.
- To change a requirement: edit the single row in `REQUIREMENTS.md`; update `Test`; if scope changed, update `MILESTONES.md`. Do not create a doc.
- Run `python tools/check_requirements.py` after any docs change; it must pass.
---
## Agent runtime rules
## 0. Where each doc lives
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
| Doc | Owns |
|---|---|
| `docs/TECH-SPEC-PIPELINE.md` | Deterministic math, image prep, constants, test vectors |
| `docs/ARCHITECTURE.md` | Hybrid topology, what-runs-where, secrets why, status machine |
| `AGENTS.md` | Hard rules for coding agents (locks below) |
| `docs/DATA-MODEL.md` | Store row / schema facts |
| `docs/OPENAPI.md` | Wire / HTTP contracts |
| `docs/CONTENT-WOO.md` | Listing copy and Woo payload |
| `docs/PROMPTS.md` | Call 1 / Call 2 prompt files |
| `docs/DEPLOYMENT.md` | Compose, Caddy, secrets placement, ops |
| `docs/UX.md` | Presentation: what the mill owner sees and does |
| `docs/TEST-SPEC.md` | Engineer test bar: pyramid cases, phased by gate, through PROD |
| `docs/UAT-PRACTICE.md` | Walkable practice UAT for `SLAB-UAT-*` |
| `docs/IMPL-PLAN.md` | Phases 0–6. Phase 2 is a deferred stub (offline PWA / OCR / ruler). |
| `README.md` | Orientation only |
| `docs/archive/PRD-2026-08.txt` | Frozen archive. Not a source for new work. |
## Build & test (verified commands)
Live docs agree. If two live files disagree, that is a docs bug: fix the files, then continue.
Frontend (`frontend/`, Svelte 4 + Vite + TS, no router):
### Known open gaps
```
cd frontend && npm install
npm run test # node --import tsx --test src/**/*.test.ts (unit)
npm run build # tsc --noEmit && vite build (type-check + build)
npm run test:e2e # playwright test (see Pitfalls: mocks the API)
```
- Issue #4: exact minimum browser version pins and deeper load-bearing API inventory. Phone + PC families are already locked in `docs/UX.md` / TECH-SPEC.
Backend (`backend/`, FastAPI + stdlib sqlite, no ORM, deps pinned in `backend/requirements.txt`):
---
```
cd backend && python -m pytest tests/ -q
uvicorn backend.app:app --host 127.0.0.1 --port 18081
```
## 0b. Gates (human map)
No Makefile, no CI workflows, no Gitea CI — nothing runs in a pipeline; the only automation is the local commands above plus the human UAT walk (`deploy/uat_walk.sh`).
| Label | Human name | What it means |
|---|---|---|
| Gate A | Docs freeze | Product locks written into the repo |
| Gate B | Run-host setup | Inventory the real host, deploy skeleton |
| Gate C | First draft listing | One Woo draft listing with inference OFF |
## Conventions
Phase map: **Phase 0 = Gates A+B.** **Gate C = early Phase 1** claimable publish slice. Call 1/2 after Gate C, still inside POC. Depth: `docs/IMPL-PLAN.md`.
- The LIVE FastAPI routes in `backend/*.py` are the API source of truth; `docs/contracts/API.md` is aspirational and can drift (it describes a polling model the backend never shipped). Read the router, not the doc, before wiring a client.
- Frontend is one Svelte app, no router: `App.svelte` switches on a `writable` flow store. Screens live under `frontend/src/<area>/`.
- Commit subjects: `feat(scope): …` / `fix(scope): …` / `test(scope): …` / `chore(scope): …`, often citing a requirement ID or Gitea number.
### Freeze provenance (Gate A)
## Gates (must pass before claiming work complete)
| SHA | What locked |
|---|---|
| `a94d81d` | gitea-atd `Ty_Tech/SlabUploader` as remotes SoT |
| `457711b` | AGENTS.md + hybrid IMPL Phase 0 |
| `4ec478f` | Contradiction patches (README/PRD/Woo/DEPLOYMENT) |
| `4d9f307` / `4e97770` | Inference review + hybrid capture pipeline (pre-freeze baseline) |
Run from repo root:
Docs refactor is on `main` (cut 2026-09-06). Product locks above are unchanged.
```
python tools/check_requirements.py
python tools/check_coverage.py
```
### Known stale PRD lines
Exit 0 on both = pass.
`docs/archive/PRD-2026-08.txt` is frozen history. These PRD lines are superseded by live docs:
FR17 (fig-* 1+, feat-* 0+), FR19 (species $/bdft only), FR40 (client-side, not server-side
processing), NFR server normalization timing, FR42 (env-agnostic DEPLOYMENT + deploy/INVENTORY.md).
## Pitfalls
---
- E2E mocks the API: `tests/e2e/*.spec.ts` use Playwright `page.route('**/api/v1/**')` with fixture ids — there is **no live backend** under e2e. A green `test:e2e` is not proof the real backend works; that's the human `deploy/uat_walk.sh` (real Woo, never a mock — QUALITY-105).
- Ports on the 5060 UAT host (192.168.1.202): FastAPI `18083`, FE `18082`/`10881` are typically already up (compose on `deploy/docker-compose.yml`). Check before binding; don't assume `18081` is free.
- `SLAB_UAT_UNMASK_SECRETS=1` makes `GET /settings` return decrypted Woo/inference secrets — dev-test only, never set in prod (SETTINGS-75 masking is the default).
- Never hardcode live Woo category/term ids into app code (QUALITY-105 / METADATA-5); resolve groupers by slug (`species`, `wcat`) at runtime.
- `tsc` won't catch Svelte template errors; `npm run build` (which runs `tsc --noEmit`) is the real type gate for the frontend.
## 1. Topology (locked — hybrid)
## Pointers (read these for everything else)
**Client (SvelteKit) owns the happy path:**
- Docs map and shipped status: `docs/README.md`
- Scope and ID subsets: `docs/MILESTONES.md`
- M3 behavior: `docs/capabilities/CREATE.md`
- Hosts, ports, UAT environment: `docs/DEPLOYMENT.md` and `deploy/INVENTORY.md`
- Task ordering for M3: `.planning/milestones/v0.5/M3/task_breakdown.md`
- Product invariants and acceptance criteria: the requirement rows in `docs/REQUIREMENTS.md` (cite by ID)
- Capture 1–5 photos
- Sheet detect + chroma-key / black threshold + **four knobs inline on capture** (sheet, sensitivity, edge offset, feather) with **live re-run** and mask overlay on the source photo
- Mask overlay + **user confirm** before measurement
- Length axis overlay (rotate / confirm)
- sqft, bdft, 6" width samples
- 3:4 transparent PNG, 80% fill at extremes, shorter side ≥ 1600 when source allows
- Draft payload: originals + processed PNGs + length/thickness/SKU/sqft/bdft/widths
**Server (FastAPI) owns:**
- Store drafts / settings / taxonomy cache
- U2Net "Try harder" — **deferred from POC**, not deleted. Architecture keeps the one-photo server path for awkward backgrounds post-POC. POC mask tuning is **client-side only** (sheet + four knobs, live re-run).
- WooCommerce (taxonomy sync, SKU check, create product) — browser never calls Woo
- Inference **proxy** (Call 1 / Call 2) — keys never in browser
- Prompt files (server text files, read fresh)
Happy path never leaves the phone until the user continues after confirmed mask + numbers.
Deferred work and non-goals are in `docs/ARCHITECTURE.md` and `docs/IMPL-PLAN.md`.
Depth: `docs/ARCHITECTURE.md`.
---
## 2. Deferred work
The MVP is online-only. Measuring lives in the browser. Manual SKU entry, manual length, and client-side measure/crop are the source of truth. Undersized source warns and requires retake. The server stores client-sent numbers; there is no server happy-path pipeline for the deterministic core.
Phase 2 is a deferred stub for offline PWA, OCR, and ruler detection. Post-POC items: LoRA / fine-tune, figure-/category-weighted pricing (v1.5), auto-dimension overlay (v1.5), bulk publish, staff roles, analytics, SKU update path. Depth: `docs/ARCHITECTURE.md` §4 and `docs/IMPL-PLAN.md` Phase 2.
---
## 3. Deterministic core (non-negotiable)
Encode TECH-SPEC exactly:
- **bdft = sqft × thickness_in** (identity: 1 sqft @ 1" = 1 bdft).
- Units: inches in 1/8" steps; sqft/bdft 2 decimals; price half-up 2 decimals.
- Manual length, thickness, SKU, price always win.
- Image prep: sheet/sliders → mask confirm → length axis confirm → 3:4 PNG / 80% fill / ≥1600px when source allows.
- Undersized source warns and requires retake. Photo is not publishable until retake.
- Pricing: species `$/bdft` from Settings, seeded by Woo species sync; `rec = bdft × price_per_bdft`; user override sticks; missing rule leaves rec empty, manual allowed.
- Port TECH-SPEC **TV-1 through TV-10** (including TV-10 Rounding). Client (or shared pure modules the client uses) is authoritative for math; server **stores** client-sent numbers.
Depth: `docs/TECH-SPEC-PIPELINE.md`.
---
## 4. Secrets and settings
**Server-side only:**
- Woo WordPress username + application password (dedicated low-privilege WP user for product create/edit + media)
- Inference API key and endpoint (treat inference URL as sensitive)
- AES master key (`SLAB_AES_KEY` env only; never in DB or repo)
**`WOO_BASE_URL`:** compose / host `.env` only (`https://` required). Settings shows it read-only. Not a SQLite settings row. FastAPI rejects `http://` at startup.
**Server:** AES-GCM at rest in SQLite for Woo Application Password + inference secrets. Settings UI POSTs username + application password to FastAPI; server encrypts. All Woo and inference calls are server-side. Rotate by revoking the application password in WP Users → Profile and re-entering in Settings. WooCommerce consumer keys (`ck_`/`cs_`) are not implemented.
**Browser may persist (non-secret):** sheet mode, sensitivity, edge offset, feather (localStorage OK; reset-to-default required).
Why/flow: `docs/ARCHITECTURE.md` §5. Ciphertext key inventory: `docs/DATA-MODEL.md` settings table.
---
## 5. Woo UAT and create status (Ty locked 2026-08-31)
- Store URL: **`WOO_BASE_URL`** from Docker Compose (`https://` only). Intended UAT/POC store is Whidbey Wood Store production; the compose value is the SoT, not a Settings field.
- Mid-phase UAT creates: Woo status **draft**, SKU prefix **`SLAB-UAT-*`**
- Settings field **`woo_create_status`**: **`draft | publish`**, default **`draft`**
- Hard rule: if SKU matches `SLAB-UAT-*`, force **Woo draft** even if Settings say publish
- Final POC success: one real (non-UAT) SKU → **Woo publish** only after Ty review
- Duplicate SKU → stop; UI maps **409** to the SKU field (edit SKU or open existing)
- After successful publish: purge originals + processed images on server
Woo draft/publish status is separate from slab lifecycle status (§7).
Payload and publish sequence depth: `docs/CONTENT-WOO.md`. Key only: DATA-MODEL `woo_create_status`.
---
## 6. Inference (inside POC, sequenced)
- Feature-flagged; **full suite must pass with inference OFF**.
- **Gate C** claimable path: inference **OFF**; manual taxonomy + deterministic numbers + Woo draft path.
- **Call 1 (vision) / Call 2 (text) only after Gate C is green**, still inside overall POC before "done."
- Call 1 auto only if `inference_enabled`; Call 2 never auto-fires (user taps Generate text).
- Call 1 body: all inventory photos @1024. Taxonomy lists sent to Call 1 are **exactly the Woo-synced species / attribute sets**.
- **Confidence 0.7 (default):** ≥ 0.7 → pre-populate species, wood categories, edge, figure, grade, and feat-* from Call 1 (**mutable**). < 0.7 → those fields stay **empty**. Ready still requires the full named set in §7. Presentation: `docs/UX.md`.
- **Taxonomy cache:** `taxonomy_last_sync_at` vs `taxonomy_anchor` (bump only on stored-subset hash change, including deletions). Client re-pulls iff the anchor advanced. `test-woo` always syncs; publish if last_sync >1h. No per-nav sync. No Woo taxonomy timestamp.
- Call 2 portable context: default full resend; never require `previous_response_id`. `test-inference` two-turn probe `stateful|stateless|fail`. Fail blocks **Call 2 enablement only**.
- Templates inject deterministic numbers. LLM does not invent dimensions.
Prompt file mechanics: `docs/PROMPTS.md`.
---
## 7. Slab status machine (app-internal only)
`draft → calibrated → ready → publishing → published` (or `failed` with detail).
- **calibrated:** mask + axis confirmed; length/thickness/SKU entered; client computed sqft/bdft/widths; draft uploaded.
- **ready:** SKU, length, thickness, client sqft/bdft/widths, ≥1 inventory PNG, exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag, ≥1 grade, thickness store band (round-up), 1 moisture (default kiln-dried; not inferred), price. feat-* 0+. Title/description may be typed, generated, or templated at publish. Vision may prefill the taxonomy subset only when confidence ≥ 0.7.
- Inference does not gate publish. Below-threshold Call 1 does not prefill; empty mandatory fields block ready.
Slab `draft` is separate from Woo draft (`woo_create_status`).
---
## 8. Test and delivery expectations
- No feature without a passing test and shown execution output (PR/commit message).
- Deterministic core unit-tested before UI consumes it.
- One phase = reviewable, shippable increment; Ty exit-gates phases.
- Phase naming: Phase 0–6 stay delivery chapters. **Phase 0 = Gates A+B.** **Gate C = early Phase 1** claimable publish slice.
- Secrets, real Woo application passwords, and AES keys stay out of the repo.
---
## 9. Deploy sketch
- Target: inventoried Docker host. Docker Compose (`frontend` + `fastapi`) + Caddy → `https://${APP_HOSTNAME}` (LAN/Tailscale).
- Fill `deploy/INVENTORY.md` before binding ports or merging Caddy (co-resident apps can collide; bolt.diy is one example).
- Health: `GET https://${APP_HOSTNAME}/api/health`.
- `WOO_BASE_URL` in compose (HTTPS). Settings displays it read-only.
- Inference is a remote configurable endpoint, not on the app host by default.
Depth: `docs/DEPLOYMENT.md`.
-70
View File
@@ -1,70 +0,0 @@
# Prototype: per-user Woo credentials
Branch: `proto-per-user-woo-creds` (cut from `overnight-whole-app`).
## Goal
2–3 owners can share one SlabUploader install and one Woo store URL, each using
their own WordPress user + application password. Inference URL/key stay global.
No full auth, ACL, or publish mutex in this prototype.
## Credential scheme
| Layer | What |
| --- | --- |
| Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` |
| Gate | Full-screen `WooSetupGate` on App mount when this browser lacks device creds |
| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls (**required**) |
| Server | `client_for_request(conn, headers)` → override only; **no Settings fallback** on those routes |
| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — used by **health probe** only; **never** updated by the device-creds / gate path |
Woo-touching calls that **require** headers (401 `woo_device_creds_required` if missing):
- `POST /api/v1/slabs/{id}/publish`
- `POST /api/v1/admin/taxonomy/sync`
- `POST /api/v1/settings/test-woo`
Inference / Call 2 / Settings PUT for inference keys are unchanged (shared Settings).
Optional test escape hatch: `SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1` (prefer sending headers in tests).
## Setup gate
1. On App mount, if this browser has no device username + app password → blocking overlay.
2. Fields: device WP user + app password (required); Woo base URL display-only from Settings;
inference URL/key show configured / not set (masked) and may be entered → PUT Settings
for inference only.
3. Primary CTA: **Save device creds & Test Woo**. Gate dismisses only after Test Woo succeeds.
4. Returning browser with device creds → skip gate.
5. Publish (or Test Woo) Woo auth failure / missing headers → reopen gate.
## Flow (request → WooClient)
1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`).
2. `withWooHeaders()` merges `X-Woo-*` onto the fetch.
3. Backend `client_for_request` builds a **new** `WooClient` for that request only
(Basic auth from override plaintext). Missing headers → `WooDeviceCredsRequired`.
4. Publish / taxonomy / media use that client instance — no process-global
“current user”.
## UAT with two browsers
1. Deploy this branch to the **proto** stack only (`slab-proto-woo-creds` on
`:10892` / `:10893`). Do **not** touch overnight `:10890`; never `:10880`.
2. In WordPress, create two application passwords for two WP users on the same store.
3. Browser A (or profile A): setup gate → enter user A + app password →
**Save device creds & Test Woo** → gate dismisses.
4. Browser B: same for user B (separate localStorage).
5. Confirm Test Woo succeeds for both, and that device passwords were not written
into shop-default Settings.
6. Publish different slabs from each browser (do not work the same slab concurrently).
7. Optional: clear device creds in Settings → reload → gate reappears.
## Risks left (prototype)
- **LAN can forge headers** — there is no app login; any client on the network can
send arbitrary `X-Woo-*` values. Treat as trusted LAN only.
- Device passwords sit in **localStorage** (XSS-readable). Acceptable for this
prototype only.
- No slab ownership ACL / taxonomy mutex / publish CAS — operators must not edit
the same slab at once.
- Taxonomy cache remains shared; a sync from either user refreshes the same rows.
+50 -16
View File
@@ -1,22 +1,56 @@
# SlabUploader
Lists one-of-a-kind wood slabs on Whidbey Wood Store.
Browser app for listing one-of-a-kind wood slabs on the Whidbey Wood Store.
Start at [`docs/README.md`](docs/README.md) for goals, what already shipped, and Milestone 3.
You photograph a slab, confirm the cut and the numbers, review the listing, and create the WooCommerce product. The browser does the measuring and crop. The server stores the draft, talks to the store, and (later) runs vision. Runs on phones and PCs.
| Artifact | File |
Owner: Ty (Whidbey). Store: www.whidbeywoodstore.com. Runs in Docker Compose behind Caddy on a host you inventory first (`APP_HOSTNAME`).
## Where we are
Gate A freeze signed 2026-08-31. All live docs sit on `main`. Read `AGENTS.md` before writing code.
The bar for this first version: a real slab, photographed on a phone or a PC, listed on the real store. Practice listings are WooCommerce **drafts** with SKUs that start `SLAB-UAT-`. After Ty reviews, one real SKU goes **live**. Online only for now; offline capture is deferred.
Build order is in `docs/IMPL-PLAN.md`. Empty app on the real host first, then the first draft listing, then vision.
## Documentation
| File | What |
|---|---|
| Docs map | `docs/README.md` |
| Structure contract | `docs/STRUCTURE.md` |
| Requirements (normative) | `docs/REQUIREMENTS.md` |
| Milestones (ID subsets) | `docs/MILESTONES.md` |
| Product overview | `docs/PRODUCT.md` |
| Invariants and agent context | `AGENTS.md` |
| Milestone 1 Capture (shipped) | `docs/capabilities/CAPTURE.md` |
| Milestone 2 Photos (shipped) | `docs/capabilities/PHOTOS.md` |
| Milestone 3 Create (paused — requirements reconciliation) | `docs/capabilities/CREATE.md` |
| UAT practice walk | `docs/UAT-PRACTICE.md` |
| Hosting | `deploy/` |
| `AGENTS.md` | Hard rules. Read first. |
| `docs/ARCHITECTURE.md` | Hybrid topology, what runs where, secrets, status machine |
| `docs/TECH-SPEC-PIPELINE.md` | Math, image pipeline, constants, test vectors TV-1–TV-10 |
| `docs/DATA-MODEL.md` | SQLite tables, slab statuses, settings |
| `docs/OPENAPI.md` | Browser to server API |
| `docs/CONTENT-WOO.md` | Titles, descriptions, WooCommerce payload |
| `docs/PROMPTS.md` | Call 1 / Call 2 prompt files (server text files) |
| `docs/DEPLOYMENT.md` | Compose, Caddy, secrets placement, ops |
| `docs/UX.md` | Presentation: what the mill owner sees and does |
| `docs/UAT-PRACTICE.md` | Walkable practice UAT for `SLAB-UAT-*` |
| `docs/IMPL-PLAN.md` | Phases 0–6, exit gates, deferred stub (Phase 2) |
| `deploy/INVENTORY.md` | Host worksheet before binding ports |
| `docs/archive/PRD-2026-08.txt` | Frozen history, not a source for new work |
Owner: Ty. Store: https://www.whidbeywoodstore.com/
Repo: `Ty_Tech/SlabUploader`
## Short map
- Client (SvelteKit) owns the happy path: mask knobs, sqft, bdft, 3:4 PNG. Server (FastAPI) stores drafts, talks to WooCommerce, and proxies vision. Server matting ("Try harder") is deferred from POC; POC mask tuning is client-side only.
- Board feet = square feet × thickness in inches. No divide-by-12. Test vectors TV-1 through TV-10 run in the browser.
- Secrets never sit in the browser. Encrypted on the server. Store login is a WordPress application password on a low-privilege user; WooCommerce consumer keys are not implemented.
- Practice SKUs `SLAB-UAT-*` are always drafts, even if Settings say publish.
- Vision stays off until the first draft listing works. Species suggestion and Generate text come after, still inside this project.
- Two containers (`frontend`, `fastapi`) plus Caddy. One SQLite, image volumes. Inventory the host first.
Where deferred items live: `docs/ARCHITECTURE.md` §4 and `docs/IMPL-PLAN.md` Phase 2 (offline PWA, OCR, ruler — deferred stub).
## Repo
Source of truth: Gitea `Ty_Tech/SlabUploader` on gitea-atd
(`https://gitea.vps1.afterthedemo.com/Ty_Tech/SlabUploader`). TrueNAS and GitHub copies are not the source of truth for this work.
## Getting started
1. Read `AGENTS.md` and Phase 0 in `docs/IMPL-PLAN.md`.
2. Build the client math modules and a thin FastAPI health/settings app. Measuring lives in the browser.
3. Ty signs each phase.
4. Live docs on `main` are the source of truth. The archive PRD is frozen history only.
+12 -15
View File
@@ -1,24 +1,21 @@
# S0 minimal backend Dockerfile (OPS-25: pinned, not latest)
FROM python:3.14.3-slim-bookworm
FROM python:3.13-slim
WORKDIR /app
# system deps minimal
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /app/requirements.txt
COPY backend/ .
COPY backend/ /app/backend/
# Create data and images directories
RUN mkdir -p /data /images /app/prompts
ENV PYTHONPATH=/app \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Bind mount for prompts (read-only)
# Volumes for data and images are handled by docker-compose
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# non-root? later; for S0 root ok
EXPOSE 8000
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
-2
View File
@@ -1,2 +0,0 @@
"""SlabUploader backend package (S0 foundation)."""
__all__ = ["app", "db"]
+45
View File
@@ -0,0 +1,45 @@
# Alembic configuration for SlabUploader
[alembic]
script_location = alembic
sqlalchemy.url = sqlite:///data/slab.db
# Templates used to generate migration files
file_template = %%(year)s_%%(month)s_%%(day)s_%%(rev)s_%%(slug)s
# Timezone for file timestamps
timezone = UTC
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = INFO
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %Y-%m-%dT%H:%M:%S%z
View File
+65
View File
@@ -0,0 +1,65 @@
"""Alembic environment configuration.
Sets up sys.path so that app packages are importable when running
alembic from the backend directory.
Alembic uses sync connections for migrations even with an async app.
"""
import os
import sys
# Ensure the backend root is on the path so app.* is importable
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Import models so that Base.metadata includes all tables
from app.db.models import Base
config = context.config
fileConfig(config.config_file_name)
def run_migrations_online() -> None:
"""Run migrations in 'online' mode using sync engine."""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=Base.metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
connectable.dispose()
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=Base.metadata,
render_as_batch=True,
literal_binds=True,
dialect_opts={"paramstyle": "batch"},
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,178 @@
"""initial schema.
Revision ID: initial
Revises:
Create Date: 2026-09-07
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# slabs
op.create_table(
"slabs",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("sku", sa.String(length=24), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="draft"),
sa.Column("length_in", sa.Float(), nullable=False),
sa.Column("thickness_in", sa.Float(), nullable=False),
sa.Column("sqft", sa.Float(), nullable=True),
sa.Column("bdft", sa.Float(), nullable=True),
sa.Column("width_min_in", sa.Float(), nullable=True),
sa.Column("width_max_in", sa.Float(), nullable=True),
sa.Column("width_avg_in", sa.Float(), nullable=True),
sa.Column("species_id", sa.Integer(), nullable=True),
sa.Column("wood_category_ids", sa.JSON(), nullable=True),
sa.Column("edge_type_term_id", sa.Integer(), nullable=True),
sa.Column("figure_term_ids", sa.JSON(), nullable=True),
sa.Column("grade_term_ids", sa.JSON(), nullable=True),
sa.Column("thickness_term_id", sa.Integer(), nullable=True),
sa.Column("moisture_term_id", sa.Integer(), nullable=True),
sa.Column("fig_tag_ids", sa.JSON(), nullable=True),
sa.Column("feat_tag_ids", sa.JSON(), nullable=True),
sa.Column("price_per_bdft", sa.Float(), nullable=True),
sa.Column("price", sa.Float(), nullable=False),
sa.Column("price_source", sa.Text(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("short_title", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("content_source", sa.Text(), nullable=True),
sa.Column("client_rev", sa.Integer(), nullable=False, server_default="1"),
sa.Column("server_rev", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
sa.Column("updated_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
sa.Column("inference_thread_id", sa.Text(), nullable=True),
sa.CheckConstraint("length_in >= 0"),
sa.CheckConstraint("thickness_in >= 0"),
sa.CheckConstraint("price >= 0"),
sa.UniqueConstraint("sku"),
)
op.create_index("idx_slabs_status", "slabs", ["status"])
op.create_index("idx_slabs_updated_at", "slabs", ["updated_at"])
# slab_photos
op.create_table(
"slab_photos",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("slab_id", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("role", sa.Text(), nullable=True),
sa.Column("seq", sa.Integer(), nullable=False, server_default="0"),
sa.Column("original_path", sa.Text(), nullable=True),
sa.Column("processed_path", sa.Text(), nullable=True),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
sa.ForeignKeyConstraint(["slab_id"], ["slabs.id"], ondelete="CASCADE"),
)
# pricing_rules
op.create_table(
"pricing_rules",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("species", sa.Text(), nullable=False),
sa.Column("price_per_bdft", sa.Float(), nullable=False),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
sa.UniqueConstraint("species"),
)
# woo_categories
op.create_table(
"woo_categories",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.UniqueConstraint("woo_id"),
)
# woo_attributes
op.create_table(
"woo_attributes",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("type", sa.Text(), server_default="select"),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.UniqueConstraint("woo_id"),
)
# woo_attribute_terms
op.create_table(
"woo_attribute_terms",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("attribute_id", sa.Integer(), nullable=False),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["attribute_id"], ["woo_attributes.woo_id"]),
sa.UniqueConstraint("woo_id"),
)
# woo_tags
op.create_table(
"woo_tags",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.UniqueConstraint("woo_id"),
)
# settings
op.create_table(
"settings",
sa.Column("key", sa.Text(), nullable=False, primary_key=True),
sa.Column("value_enc", sa.LargeBinary(), nullable=True),
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
)
# sync_log
op.create_table(
"sync_log",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
sa.Column("slab_id", sa.Text(), nullable=True),
sa.Column("sku", sa.Text(), nullable=True),
sa.Column("woo_product_id", sa.Integer(), nullable=True),
sa.Column("status", sa.Text(), nullable=True),
sa.Column("http_status", sa.Integer(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("payload_hash", sa.Text(), nullable=True),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
)
# inference_log
op.create_table(
"inference_log",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
sa.Column("slab_id", sa.Text(), nullable=True),
sa.Column("call", sa.Text(), nullable=True),
sa.Column("request_payload", sa.Text(), nullable=True),
sa.Column("response_payload", sa.Text(), nullable=True),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
)
def downgrade() -> None:
op.drop_table("inference_log")
op.drop_table("sync_log")
op.drop_table("settings")
op.drop_table("woo_tags")
op.drop_table("woo_attribute_terms")
op.drop_table("woo_attributes")
op.drop_table("woo_categories")
op.drop_table("pricing_rules")
op.drop_table("slab_photos")
op.drop_index("idx_slabs_updated_at", table_name="slabs")
op.drop_index("idx_slabs_status", table_name="slabs")
op.drop_table("slabs")
-314
View File
@@ -1,314 +0,0 @@
"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25).
Base /api/v1; health at /api/health (non-200 on storage not writable).
Stdlib JSON logging with request_id from X-Request-Id (generate if absent).
Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY.
No secrets in logs. Minimal, no S1+.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import uuid
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod
__version__ = "0.5.0-m3"
# --- Structured JSON logging (stdlib only) ---
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
class _JsonFormatter(logging.Formatter):
"""JSON lines; redacts any secret-looking content (APP-65)."""
def format(self, record: logging.LogRecord) -> str:
rid = getattr(record, "request_id", None) or request_id_var.get("")
msg = record.getMessage()
# Redact secrets: never log creds, keys, aes material
lower = msg.lower()
if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")):
msg = "[REDACTED secret material]"
data: dict[str, Any] = {
"ts": self.formatTime(record, self.datefmt),
"level": record.levelname,
"logger": record.name,
"request_id": rid,
"msg": msg,
}
if record.exc_info:
data["exc"] = self.formatException(record.exc_info)
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _configure_logging() -> None:
level = os.getenv("LOG_LEVEL", "INFO").upper()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(_JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(getattr(logging, level, logging.INFO))
# uvicorn access logs also json? for S0 keep simple; they inherit
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
_configure_logging()
logger = logging.getLogger("slab.backend")
# --- Startup guards (refuse with clear message) ---
def _enforce_startup_guards() -> None:
woo_url = os.getenv("WOO_BASE_URL")
if not woo_url:
msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)"
logger.error(msg)
raise RuntimeError(msg)
if not woo_url.startswith("https://"):
msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")"
logger.error(msg)
raise RuntimeError(msg)
if not os.getenv("SLAB_AES_KEY"):
msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)"
logger.error(msg)
raise RuntimeError(msg)
# db/images envs are required by db.py on use; guards here only per spec
_enforce_startup_guards()
# --- Request ID middleware (threads X-Request-Id or generates) --
class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
token = request_id_var.set(rid)
try:
response = await call_next(request)
response.headers["X-Request-Id"] = rid
return response
finally:
request_id_var.reset(token)
# --- App ---
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("startup: applying schema (idempotent)")
try:
init_db()
logger.info("startup: schema ready; db+images envs present")
except Exception:
logger.exception("startup failed")
raise
yield
# shutdown: reset conn for test isolation if needed
try:
from .db import _reset_for_tests
_reset_for_tests()
except Exception:
pass
app = FastAPI(
title="SlabUploader",
version=__version__,
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestIdMiddleware)
@app.exception_handler(HTTPException)
async def _unwrap_error(request: Request, exc: HTTPException):
if isinstance(exc.detail, dict) and "error" in exc.detail:
return JSONResponse(status_code=exc.status_code, content=exc.detail)
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
@app.get("/api/health")
async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
#84: woo_reachable is a real authenticated probe (signal only — store outage
does not fail health).
"""
db_ok = probe_writable()
img_ok = probe_images_writable()
if not (db_ok and img_ok):
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
raise HTTPException(status_code=503, detail="storage not writable")
return {
"status": "ok",
"version": __version__,
"woo_reachable": _probe_woo_reachable(),
}
def _probe_woo_reachable() -> bool:
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
Storage health never depends on this result. Missing creds → False.
"""
try:
base, username, enc_password = _woo_credentials()
if not base or not username or not enc_password:
return False
client = WooClient.from_settings(base, username, enc_password)
# One lightweight authenticated call (categories page 1).
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
return code == 200
except Exception:
return False
@app.get("/api/v1/meta")
async def meta() -> dict[str, Any]:
"""Per API.md §3."""
return {"version": __version__}
@app.get("/api/v1/settings")
async def get_settings() -> dict[str, Any]:
return settings_mod.get_settings()
@app.put("/api/v1/settings")
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
return settings_mod.update_settings(payload)
from . import taxonomy as taxonomy_mod
from .woo import (
WooClient,
WooAuthError,
WooConnectionError,
WooCredentialsMissing,
WooDeviceCredsRequired,
WooError,
WooForbiddenError,
client_for_request,
settings_woo_creds,
)
def _woo_credentials() -> tuple[str, str | None, str | None]:
"""Shop-default Woo creds (Settings). Used by health probe only."""
return settings_woo_creds(get_connection())
@app.post("/api/v1/settings/test-woo")
async def test_woo(request: Request) -> dict[str, Any]:
"""Probe Woo + sync taxonomy. Requires X-Woo-Username / X-Woo-App-Password
(device creds); never reads shop-default Settings for this route.
"""
try:
client = client_for_request(get_connection(), request.headers)
except WooDeviceCredsRequired as e:
return JSONResponse(
status_code=401,
content=_error("woo_device_creds_required", str(e)),
)
except WooCredentialsMissing as e:
return {
"ok": False,
"detail": str(e),
"taxonomy_anchor": None,
"taxonomy_last_sync_at": None,
}
try:
result = taxonomy_mod.sync(client, get_connection())
except WooAuthError as e:
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooForbiddenError as e:
return {"ok": False, "detail": f"request blocked by store (403): {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooConnectionError as e:
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooError as e:
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
return {
"ok": True,
"detail": "taxonomy synced",
"taxonomy_anchor": result["taxonomy_anchor"],
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
}
@app.get("/api/v1/admin/taxonomy")
async def get_taxonomy() -> dict[str, Any]:
return taxonomy_mod.get_cached(get_connection())
@app.post("/api/v1/admin/taxonomy/sync")
async def post_taxonomy_sync(request: Request) -> dict[str, Any]:
"""Refresh taxonomy cache. Requires X-Woo-* device headers (no Settings fallback)."""
try:
client = client_for_request(get_connection(), request.headers)
except WooDeviceCredsRequired as e:
return JSONResponse(
status_code=401,
content=_error("woo_device_creds_required", str(e)),
)
except WooCredentialsMissing as e:
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
try:
result = taxonomy_mod.sync(client, get_connection())
except WooError as e:
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
return {
"ok": True,
"detail": "taxonomy synced",
"taxonomy_anchor": result["taxonomy_anchor"],
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
}
# expose for tests
from .slabs import router as slabs_router
app.include_router(slabs_router)
from .photos import router as photos_router
app.include_router(photos_router)
from .pricing import router as pricing_router
app.include_router(pricing_router)
from .review import router as review_router
app.include_router(review_router)
from .publish import router as publish_router
app.include_router(publish_router)
from .inference import router as inference_router
app.include_router(inference_router)
# expose for tests
__all__ = ["app", "__version__"]
View File
View File
+23
View File
@@ -0,0 +1,23 @@
"""Health endpoint router."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/api/health")
async def health_check() -> dict:
"""Deploy health and smoke path.
Returns:
Health status with woo_reachable flag. When Woo credentials
are not configured, woo_reachable is False.
Contract (OPENAPI):
GET /api/health → { "status": "ok", "version": "...", "woo_reachable": bool }
"""
return {
"status": "ok",
"version": "0.1.0",
"woo_reachable": False,
}
+233
View File
@@ -0,0 +1,233 @@
"""Settings endpoint: GET returns masked settings, PUT stores encrypted secrets.
Per OPENAPI.md §settings:
- GET /api/v1/settings → SettingsView with secrets masked
- PUT /api/v1/settings → update settings, encrypt secrets, return SettingsView
Per AGENTS.md §4:
- Woo credentials encrypted AES-GCM at rest
- Ciphertext never appears in GET settings
- Missing SLAB_AES_KEY refuses startup
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import text
from app.config import Settings
from app.db.engine import get_session
from app.db.models import Setting
from app.security.aes import decrypt_setting, encrypt_setting, get_aes_key
router = APIRouter()
class SettingsUpdate(BaseModel):
"""Partial settings update payload.
Woo consumer keys and woo_base_url are rejected (woo_base_url is compose-only).
Consumer keys are included as fields so they can be explicitly rejected.
"""
woo_base_url: str | None = None
woo_wp_username: str | None = None
woo_app_password: str | None = None
woo_consumer_key: str | None = None
woo_consumer_secret: str | None = None
inference_base_url: str | None = None
inference_api_key: str | None = None
inference_model: str | None = None
inference_enabled: bool | None = None
content_llm_enabled: bool | None = None
brand_voice: str | None = None
geo_context: str | None = None
woo_create_status: str | None = Field(default=None, description="'draft' | 'publish'")
aspect_ratio: str | None = None
output_px: int | None = None
output_px_min: int | None = None
fill_target: float | None = None
class SettingsView(BaseModel):
"""Settings read model. Secrets masked."""
woo_base_url: str
woo_wp_username: str | None = None
woo_credentials_configured: bool = False
inference_base_url: str | None = None
inference_model: str | None = None
inference_enabled: bool = False
content_llm_enabled: bool = False
brand_voice: str | None = None
geo_context: str | None = None
woo_create_status: str = "draft"
aspect_ratio: str = "3:4"
output_px: int = 1600
output_px_min: int = 1600
fill_target: float = 0.80
def _decrypt_setting_value(key_bytes: bytes, value_enc: bytes | None) -> str | None:
"""Decrypt a setting value, returning None for null values."""
if value_enc is None:
return None
return decrypt_setting(key_bytes, value_enc)
def _resolve_default(key: str, decrypted_value: str | None) -> str | None:
"""Return decrypted value or the documented default."""
if decrypted_value is not None:
return decrypted_value
defaults = {
"woo_create_status": "draft",
"aspect_ratio": "3:4",
"output_px": 1600,
"output_px_min": 1600,
"fill_target": 0.80,
}
return defaults.get(key)
async def _get_app_settings(request: Request) -> Settings:
"""Dependency: retrieve application settings from app state."""
return request.app.state.settings
async def _build_settings_view(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
"""Build a SettingsView from the database and app settings."""
key_bytes = get_aes_key(app_settings.aes_key)
session = await get_session()
rows = await session.execute(text("SELECT key, value_enc FROM settings"))
all_rows = rows.all()
await session.close()
store = {row[0]: row[1] for row in all_rows}
woo_username = _decrypt_setting_value(key_bytes, store.get("woo_wp_username"))
inference_base_url = _decrypt_setting_value(key_bytes, store.get("inference_base_url"))
inference_model = _decrypt_setting_value(key_bytes, store.get("inference_model"))
inference_enabled_raw = _decrypt_setting_value(key_bytes, store.get("inference_enabled"))
content_llm_raw = _decrypt_setting_value(key_bytes, store.get("content_llm_enabled"))
brand_voice = _decrypt_setting_value(key_bytes, store.get("brand_voice"))
geo_context = _decrypt_setting_value(key_bytes, store.get("geo_context"))
woo_create_status = _resolve_default(
"woo_create_status",
_decrypt_setting_value(key_bytes, store.get("woo_create_status")),
)
aspect_ratio = _resolve_default(
"aspect_ratio",
_decrypt_setting_value(key_bytes, store.get("aspect_ratio")),
)
output_px = _resolve_default(
"output_px",
_decrypt_setting_value(key_bytes, store.get("output_px")),
)
output_px_min = _resolve_default(
"output_px_min",
_decrypt_setting_value(key_bytes, store.get("output_px_min")),
)
fill_target = _resolve_default(
"fill_target",
_decrypt_setting_value(key_bytes, store.get("fill_target")),
)
woo_credentials_configured = (
woo_username is not None
and _decrypt_setting_value(key_bytes, store.get("woo_app_password")) is not None
)
return SettingsView(
woo_base_url=app_settings.woo_base_url,
woo_wp_username=woo_username,
woo_credentials_configured=woo_credentials_configured,
inference_base_url=inference_base_url,
inference_model=inference_model,
inference_enabled=(
inference_enabled_raw == "true" if inference_enabled_raw is not None else False
),
content_llm_enabled=(
content_llm_raw == "true" if content_llm_raw is not None else False
),
brand_voice=brand_voice,
geo_context=geo_context,
woo_create_status=woo_create_status,
aspect_ratio=aspect_ratio,
output_px=output_px,
output_px_min=output_px_min,
fill_target=fill_target,
)
@router.get("/api/v1/settings", response_model=SettingsView)
async def get_settings(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
"""GET settings. Secrets (app password, inference key) are never returned.
Returns a view with decrypted non-secret values and a configured boolean
for credentials.
"""
return await _build_settings_view(app_settings)
@router.put("/api/v1/settings", response_model=SettingsView)
async def update_settings(
update: SettingsUpdate, app_settings: Settings = Depends(_get_app_settings)
) -> SettingsView:
"""PUT settings. Encrypt secrets, store non-secrets. Return updated view.
Rejects consumer key fields. Returns 422 for invalid woo_create_status.
"""
# Reject consumer keys and woo_base_url (compose-only, read-only in UI)
payload = update.model_dump(exclude_unset=True)
if "woo_consumer_key" in payload or "woo_consumer_secret" in payload:
raise HTTPException(
status_code=422,
detail="WooCommerce consumer keys are not supported. Use WordPress application passwords.",
)
if "woo_base_url" in payload:
raise HTTPException(
status_code=422,
detail="woo_base_url is set via compose environment, not editable via settings.",
)
# Validate woo_create_status
if update.woo_create_status is not None and update.woo_create_status not in (
"draft",
"publish",
):
raise HTTPException(
status_code=422,
detail="woo_create_status must be 'draft' or 'publish'",
)
key_bytes = get_aes_key(app_settings.aes_key)
now = datetime.now(timezone.utc).isoformat()
session = await get_session()
for field, value in payload.items():
if value is None:
continue
ct = encrypt_setting(key_bytes, str(value))
setting = await session.execute(
text("SELECT key FROM settings WHERE key = :key"), {"key": field}
)
existing = setting.fetchone()
if existing:
await session.execute(
text("UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key"),
{"val": ct, "now": now, "key": field},
)
else:
await session.execute(
text("INSERT INTO settings (key, value_enc, updated_at) VALUES (:key, :val, :now)"),
{"key": field, "val": ct, "now": now},
)
await session.commit()
# Return updated view
return await _build_settings_view(app_settings)
+432
View File
@@ -0,0 +1,432 @@
"""Slabs API: create calibrated draft from multipart upload.
Per OPENAPI.md §slabs:
- POST /api/v1/slabs accepts multipart/form-data with:
- data: JSON SlabCreate payload
- files[]: original image files
- meta[]: photo metadata JSON objects
- Returns 201 with status: calibrated and server_rev
- Server stores client-sent numbers only; no server-side recompute.
Per DATA-MODEL.md:
- slabs table: client-generated UUID, sku unique, status calibrated
- slab_photos table: slab_id FK, kind inventory, role, seq, original_path
- server_rev = 1 on create
"""
import logging
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
from app.config import Settings
from app.db.engine import get_session
from app.db.models import Slab, SlabPhoto, SlabStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class PhotoMeta(BaseModel):
"""Photo metadata from the multipart request."""
kind: str = Field(default="inventory")
role: str | None = None
seq: int = 0
@field_validator("kind")
@classmethod
def validate_kind(cls, v: str) -> str:
if v != "inventory":
raise ValueError("kind must be 'inventory'")
return v
@field_validator("role")
@classmethod
def validate_role(cls, v: str | None) -> str | None:
if v is not None and v not in ("topdown", "extra"):
raise ValueError("role must be 'topdown' or 'extra'")
return v
class SlabCreateData(BaseModel):
"""Client-sent slab data (the `data` multipart field).
Mirrors the OPENAPI.md SlabCreate schema. All numeric fields are
client-computed; the server stores them as-is without recomputation.
"""
id: str = Field(description="Client-generated UUID")
sku: str = Field(min_length=3, max_length=24, pattern=r"^[A-Z0-9-]+$")
length_in: float = Field(ge=0)
thickness_in: float = Field(ge=0)
sqft: float | None = Field(default=None, ge=0)
bdft: float | None = Field(default=None, ge=0)
width_min_in: float | None = Field(default=None, ge=0)
width_max_in: float | None = Field(default=None, ge=0)
width_avg_in: float | None = Field(default=None, ge=0)
species_id: int | None = None
wood_category_ids: list[int] | None = None
edge_type_term_id: int | None = None
figure_term_ids: list[int] | None = None
grade_term_ids: list[int] | None = None
thickness_term_id: int | None = None
moisture_term_id: int | None = None
fig_tag_ids: list[int] | None = None
feat_tag_ids: list[int] | None = None
price_per_bdft: float | None = Field(default=None, ge=0)
price: float = Field(ge=0)
price_source: str | None = Field(default=None, pattern=r"^(recommendation|override)$")
title: str | None = None
short_title: str | None = None
description: str | None = None
content_source: str | None = Field(default=None, pattern=r"^(manual|llm)$")
client_rev: int = Field(default=1, ge=1)
class PhotoRead(BaseModel):
"""Photo read model returned in slab responses."""
id: str
kind: str = "inventory"
role: str | None = None
seq: int = 0
original_url: str
processed_url: str | None = None
class SlabRead(BaseModel):
"""Slab read model — all fields from create plus server state."""
id: str
sku: str
status: str
length_in: float
thickness_in: float
sqft: float | None = None
bdft: float | None = None
width_min_in: float | None = None
width_max_in: float | None = None
width_avg_in: float | None = None
species_id: int | None = None
wood_category_ids: list[int] | None = None
edge_type_term_id: int | None = None
figure_term_ids: list[int] | None = None
grade_term_ids: list[int] | None = None
thickness_term_id: int | None = None
moisture_term_id: int | None = None
fig_tag_ids: list[int] | None = None
feat_tag_ids: list[int] | None = None
price_per_bdft: float | None = None
price: float
price_source: str | None = None
title: str | None = None
short_title: str | None = None
description: str | None = None
content_source: str | None = None
client_rev: int
server_rev: int = 1
created_at: str
updated_at: str
inference_thread_id: str | None = None
species_confidence: float | None = None
woo_product_id: int | None = None
published_at: str | None = None
photos: list[PhotoRead] = []
inference_status: str | None = None
inference_error: str | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IMAGES_DIR = Path("/data/images")
def _get_images_dir() -> Path:
"""Return the images storage directory, creating it if needed.
For tests, falls back to a temp directory.
"""
try:
_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
return _IMAGES_DIR
except (PermissionError, OSError):
# Fallback: use temp directory (e.g., in tests)
return Path(tempfile.mkdtemp(prefix="slab_"))
def _save_photo_file(photo_id: str, file_bytes: bytes, filename: str) -> str:
"""Save an uploaded photo file to disk and return the relative path.
Stores as images/{photo_id}/{filename}. In test environments,
uses a temp directory.
"""
base_dir = _get_images_dir()
photo_dir = base_dir / photo_id
photo_dir.mkdir(parents=True, exist_ok=True)
file_path = photo_dir / filename
file_path.write_bytes(file_bytes)
return str(f"images/{photo_id}/{filename}")
def _slab_read_from_model(slab: Slab, photos: list[SlabPhoto] | None = None) -> SlabRead:
"""Convert a Slab ORM model to SlabRead response."""
photos_read = []
if photos:
for p in photos:
original_url = (
f"/api/v1/slabs/{slab.id}/photos/{p.id}/original"
if p.original_path
else None
)
photos_read.append(
PhotoRead(
id=p.id,
kind=p.kind,
role=p.role,
seq=p.seq,
original_url=original_url or "",
processed_url=p.processed_path,
)
)
return SlabRead(
id=slab.id,
sku=slab.sku,
status=slab.status.value,
length_in=slab.length_in,
thickness_in=slab.thickness_in,
sqft=slab.sqft,
bdft=slab.bdft,
width_min_in=slab.width_min_in,
width_max_in=slab.width_max_in,
width_avg_in=slab.width_avg_in,
species_id=slab.species_id,
wood_category_ids=slab.wood_category_ids,
edge_type_term_id=slab.edge_type_term_id,
figure_term_ids=slab.figure_term_ids,
grade_term_ids=slab.grade_term_ids,
thickness_term_id=slab.thickness_term_id,
moisture_term_id=slab.moisture_term_id,
fig_tag_ids=slab.fig_tag_ids,
feat_tag_ids=slab.feat_tag_ids,
price_per_bdft=slab.price_per_bdft,
price=slab.price,
price_source=slab.price_source,
title=slab.title,
short_title=slab.short_title,
description=slab.description,
content_source=slab.content_source,
client_rev=slab.client_rev,
server_rev=slab.server_rev,
created_at=slab.created_at,
updated_at=slab.updated_at,
inference_thread_id=slab.inference_thread_id,
photos=photos_read,
inference_status=slab.inference_thread_id,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/api/v1/slabs", status_code=201)
async def create_slab(request: Request) -> JSONResponse:
"""Create a calibrated draft from a multipart upload.
The request body must be multipart/form-data with these fields:
- `data` — JSON-encoded SlabCreate payload
- `files[]` — original image files (1–5 photos)
- `meta[]` — JSON-encoded photo metadata objects
Returns 201 with the created slab (status: calibrated, server_rev: 1).
Server stores client-sent numbers only; no server-side recompute.
Per AGENTS.md §3: client is authoritative for deterministic math;
server stores client-sent numbers.
"""
# Parse multipart form data
form = await request.form()
# --- Extract and validate `data` field ---
data_json = form.get("data")
if not data_json:
raise HTTPException(
status_code=400,
detail={
"code": "validation",
"message": "Missing 'data' field in multipart upload.",
},
)
try:
create_data = SlabCreateData.model_validate_json(data_json)
except Exception as e:
raise HTTPException(
status_code=400,
detail={
"code": "validation",
"message": f"Invalid data JSON: {e}",
},
)
# --- Extract `files[]` and `meta[]` ---
files: list[request.file_uploads] = form.getlist("files[]")
metas: list[str] = form.getlist("meta[]")
# Require at least one photo
if not files:
raise HTTPException(
status_code=400,
detail={
"code": "validation",
"message": "At least one photo file is required.",
},
)
# Pair files with metadata; extras get default meta
if len(metas) < len(files):
# Pad with default meta for files without explicit metadata
default_meta = PhotoMeta(kind="inventory", role=None, seq=0)
metas = list(metas) + [default_meta.model_dump_json()] * (len(files) - len(metas))
if len(files) != len(metas):
raise HTTPException(
status_code=400,
detail={
"code": "validation",
"message": "files[] and meta[] counts must match.",
},
)
# --- Validate meta objects and resolve photo files ---
photo_metas = []
for i, meta_json in enumerate(metas):
try:
meta = PhotoMeta.model_validate_json(meta_json)
except Exception as e:
raise HTTPException(
status_code=400,
detail={
"code": "validation",
"message": f"Invalid meta[{i}] JSON: {e}",
},
)
photo_metas.append(meta)
# --- Database: create slab and photos ---
session = await get_session()
now = datetime.now(timezone.utc).isoformat()
try:
# Check for duplicate SKU
existing = await session.execute(
text("SELECT id FROM slabs WHERE sku = :sku"), {"sku": create_data.sku}
)
if existing.fetchone():
raise HTTPException(
status_code=409,
detail={
"code": "duplicate_sku",
"message": f"SKU '{create_data.sku}' already exists.",
"actions": ["edit_sku", "open_existing"],
},
)
# Create slab record — status starts as calibrated
slab = Slab(
id=create_data.id,
sku=create_data.sku,
status=SlabStatus.CALIBRATED,
length_in=create_data.length_in,
thickness_in=create_data.thickness_in,
sqft=create_data.sqft,
bdft=create_data.bdft,
width_min_in=create_data.width_min_in,
width_max_in=create_data.width_max_in,
width_avg_in=create_data.width_avg_in,
species_id=create_data.species_id,
wood_category_ids=create_data.wood_category_ids,
edge_type_term_id=create_data.edge_type_term_id,
figure_term_ids=create_data.figure_term_ids,
grade_term_ids=create_data.grade_term_ids,
thickness_term_id=create_data.thickness_term_id,
moisture_term_id=create_data.moisture_term_id,
fig_tag_ids=create_data.fig_tag_ids,
feat_tag_ids=create_data.feat_tag_ids,
price_per_bdft=create_data.price_per_bdft,
price=create_data.price,
price_source=create_data.price_source,
title=create_data.title,
short_title=create_data.short_title,
description=create_data.description,
content_source=create_data.content_source,
client_rev=create_data.client_rev,
server_rev=1,
created_at=now,
updated_at=now,
inference_thread_id=None,
)
session.add(slab)
# Create photo records and store files
photos = []
for file_upload, meta in zip(files, photo_metas):
file_bytes = await file_upload.read()
filename = file_upload.filename or f"photo_{meta.seq}.jpg"
photo_id = str(uuid.uuid4())
original_path = _save_photo_file(photo_id, file_bytes, filename)
photo = SlabPhoto(
id=photo_id,
slab_id=slab.id,
kind=meta.kind,
role=meta.role,
seq=meta.seq,
original_path=original_path,
processed_path=None,
created_at=now,
)
session.add(photo)
photos.append(photo)
await session.commit()
# Build response
result = _slab_read_from_model(slab, photos)
return JSONResponse(
status_code=201,
content=result.model_dump(mode="json"),
)
except HTTPException:
await session.rollback()
raise
except Exception as exc:
await session.rollback()
logger.exception("Failed to create slab")
raise HTTPException(
status_code=500,
detail={
"code": "internal",
"message": f"Failed to create slab: {exc}",
},
)
finally:
await session.close()
+46
View File
@@ -0,0 +1,46 @@
"""Application settings loaded from environment."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""FastAPI application settings.
WOO_BASE_URL is the deploy-time SoT for the WooCommerce store.
It must be HTTPS. FastAPI refuses startup when missing or HTTP.
SLAB_AES_KEY is the AES-GCM master key (32-byte base64). Required.
Missing key refuses startup.
All Woo credentials and inference secrets are stored AES-GCM encrypted
in the settings table at runtime, not passed as env vars.
"""
model_config = SettingsConfigDict(env_prefix="SLAB_", env_file=".env", env_file_encoding="utf-8")
aes_key: str | None = None
woo_base_url: str | None = None
app_hostname: str | None = None
# Image output constants (from TECH-SPEC)
aspect_ratio: str = "3:4"
output_px: int = 1600
output_px_min: int = 1600
fill_target: float = 0.80
def validate(self) -> None:
"""Refuse startup on invalid config."""
if self.aes_key is None:
raise ValueError("SLAB_AES_KEY environment variable is required")
if len(self.aes_key) < 32:
raise ValueError("SLAB_AES_KEY must be at least 32 bytes base64")
if self.woo_base_url is None:
raise ValueError("WOO_BASE_URL environment variable is required")
if not self.woo_base_url.startswith("https://"):
raise ValueError("WOO_BASE_URL must be HTTPS")
@property
def woo_credentials_configured(self) -> bool:
"""Whether Woo application password has been stored (checked via DB at runtime)."""
return False # Always false from config alone; checked via settings table at runtime
View File
+84
View File
@@ -0,0 +1,84 @@
"""SQLite engine and session management.
Uses SQLAlchemy async engine with aiosqlite driver.
For tests, pass `test_db_url` via the settings or use a test fixture
to point at an in-memory SQLite database.
"""
import logging
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.db.models import Base
logger = logging.getLogger(__name__)
# Default DB path for production
DEFAULT_DB_URL = "sqlite+aiosqlite:////data/slab.db"
_engine: object = None
_session_factory: object = None
_test_url: str | None = None
def set_test_url(url: str) -> None:
"""Override the database URL for testing. Must be called before create_app."""
global _test_url
_test_url = url
def get_db_url() -> str:
"""Return the database URL, preferring test URL if set."""
if _test_url:
return _test_url
return DEFAULT_DB_URL
async def init_db() -> None:
"""Initialize the async engine, session factory, and create tables.
For in-memory SQLite tests, creates tables directly.
For file-based SQLite, attempts alembic migrations first, falling
back to direct table creation.
"""
global _engine, _session_factory
db_url = get_db_url()
logger.info("Connecting to %s", db_url)
_engine = create_async_engine(
db_url,
echo=False,
connect_args={"check_same_thread": False},
)
_session_factory = async_sessionmaker(
_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
# Create all tables (works for both in-memory and file-based)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database initialized")
async def close_db_connection() -> None:
"""Dispose of the engine (closes all connections)."""
global _engine
if _engine is not None:
await _engine.dispose()
_engine = None
async def get_session() -> AsyncSession:
"""Get a database session."""
if _session_factory is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
return _session_factory()
+185
View File
@@ -0,0 +1,185 @@
"""SQLAlchemy ORM models for the SlabUploader database.
These models map to the schema defined in docs/DATA-MODEL.md.
"""
import enum
from sqlalchemy import (
Index,
JSON,
CheckConstraint,
Enum,
Float,
ForeignKey,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.sql import func
# SQLite doesn't have native JSON or enum types; we use TEXT/JSON via SQLAlchemy's
# dialect-specific types.
class Base(DeclarativeBase):
pass
class SlabStatus(str, enum.Enum):
DRAFT = "draft"
CALIBRATED = "calibrated"
READY = "ready"
PUBLISHING = "publishing"
PUBLISHED = "published"
FAILED = "failed"
class Slab(Base):
__tablename__ = "slabs"
id: Mapped[str] = mapped_column(Text, primary_key=True)
sku: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
status: Mapped[SlabStatus] = mapped_column(
Enum(SlabStatus, native_enum=False, values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=SlabStatus.DRAFT,
)
length_in: Mapped[float] = mapped_column(Float, nullable=False)
thickness_in: Mapped[float] = mapped_column(Float, nullable=False)
sqft: Mapped[float | None] = mapped_column(Float, nullable=True)
bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
width_min_in: Mapped[float | None] = mapped_column(Float, nullable=True)
width_max_in: Mapped[float | None] = mapped_column(Float, nullable=True)
width_avg_in: Mapped[float | None] = mapped_column(Float, nullable=True)
species_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
wood_category_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
edge_type_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
figure_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
grade_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
thickness_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
moisture_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
fig_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
feat_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
price_per_bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
price: Mapped[float] = mapped_column(Float, nullable=False)
price_source: Mapped[str | None] = mapped_column(Text, nullable=True)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
short_title: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
content_source: Mapped[str | None] = mapped_column(Text, nullable=True)
client_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
server_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
updated_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
inference_thread_id: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
CheckConstraint("length_in >= 0"),
CheckConstraint("thickness_in >= 0"),
CheckConstraint("price >= 0"),
Index("idx_slabs_status", "status"),
Index("idx_slabs_updated_at", "updated_at"),
)
class SlabPhoto(Base):
__tablename__ = "slab_photos"
id: Mapped[str] = mapped_column(Text, primary_key=True)
slab_id: Mapped[str] = mapped_column(
Text, ForeignKey("slabs.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str | None] = mapped_column(Text, nullable=True)
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
original_path: Mapped[str | None] = mapped_column(Text, nullable=True)
processed_path: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class PricingRule(Base):
__tablename__ = "pricing_rules"
id: Mapped[str] = mapped_column(Text, primary_key=True)
species: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
price_per_bdft: Mapped[float] = mapped_column(Float, nullable=False)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class WooCategory(Base):
__tablename__ = "woo_categories"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
parent_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
synced_at: Mapped[str] = mapped_column(Text)
class WooAttribute(Base):
__tablename__ = "woo_attributes"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
type: Mapped[str] = mapped_column(Text, default="select")
synced_at: Mapped[str] = mapped_column(Text)
class WooAttributeTerm(Base):
__tablename__ = "woo_attribute_terms"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
attribute_id: Mapped[int] = mapped_column(
Integer, ForeignKey("woo_attributes.woo_id"), nullable=False
)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
synced_at: Mapped[str] = mapped_column(Text)
class WooTag(Base):
__tablename__ = "woo_tags"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
synced_at: Mapped[str] = mapped_column(Text)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(Text, primary_key=True)
value_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class SyncLog(Base):
__tablename__ = "sync_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
sku: Mapped[str | None] = mapped_column(Text, nullable=True)
woo_product_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
payload_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class InferenceLog(Base):
__tablename__ = "inference_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
call: Mapped[str | None] = mapped_column(Text, nullable=True)
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
View File
+26
View File
@@ -0,0 +1,26 @@
"""Logging configuration."""
import logging
import sys
def configure_logging() -> None:
"""Configure root logger with structured formatting.
Format: ISO-8601 timestamp, level, logger name, message.
Secrets (AES keys, Woo passwords, inference keys) are never logged.
"""
root = logging.getLogger()
root.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.INFO)
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
handler.setFormatter(formatter)
if not root.handlers:
root.addHandler(handler)
+56
View File
@@ -0,0 +1,56 @@
"""App factory and lifespan."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api import health as health_router
from app.api import settings as settings_router
from app.api import slabs as slabs_router
from app.config import Settings
from app.db.engine import close_db_connection, init_db
from app.logging_config import configure_logging
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown hooks.
Handles database initialization, logging configuration,
and cleanup on shutdown.
"""
configure_logging()
logging.info("SlabUploader starting up")
await init_db()
yield
logging.info("SlabUploader shutting down")
await close_db_connection()
def create_app(app_settings: Settings | None = None) -> FastAPI:
"""Create a fresh FastAPI app instance.
Each call produces an independent app with its own database connection
and cleared dependency overrides. This is the factory used by tests.
Args:
app_settings: Application settings. Uses env vars if None.
Returns:
Configured FastAPI application.
"""
if app_settings is None:
app_settings = Settings()
app_settings.validate()
app = FastAPI(title="SlabUploader", version="0.1.0", lifespan=lifespan)
# Store settings on app state for dependency injection
app.state.settings = app_settings
app.include_router(health_router.router)
app.include_router(settings_router.router)
app.include_router(slabs_router.router)
return app
+9
View File
@@ -0,0 +1,9 @@
# Call 1 prompt — species detection
# Read fresh each invocation. Edit on host without restart.
Identify the slab species, figure, edge type, and grade from the provided images.
Return results constrained to the provided taxonomy only. Do not invent species,
figure terms, or grade terms that are not in the taxonomy list.
Confidence threshold: 0.7. Below threshold, return empty for that field.
+7
View File
@@ -0,0 +1,7 @@
# Call 2 prompt — content generation
# Read fresh each invocation. Edit on host without restart.
Generate a title, short title, and description for the slab using the provided
deterministic dimensions, species, and figure information.
Do not invent dimensions. Use the provided length, thickness, widths, sqft, and bdft.
View File
+94
View File
@@ -0,0 +1,94 @@
"""AES-GCM settings encryption and decryption.
Per AGENTS.md §4 and DATA-MODEL.md settings table:
- AES master key is SLAB_AES_KEY env var (32 bytes base64).
- value_enc = nonce(12) || ciphertext.
- Fresh nonce per encrypt.
- Tampered ciphertext raises authentication failure.
- No plaintext fallback.
"""
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class AuthenticationError(Exception):
"""Raised when ciphertext integrity check fails (tamper or wrong key)."""
pass
def get_aes_key(key_input: str | bytes) -> bytes:
"""Decode the AES master key to 32 bytes.
Accepts either a base64-encoded string or raw bytes.
Args:
key_input: Base64-encoded string or raw bytes (must be >= 32 bytes when decoded).
Returns:
32 raw bytes suitable for AES-256-GCM.
Raises:
ValueError: if key is too short or not valid base64.
"""
if isinstance(key_input, bytes):
key_bytes = key_input
else:
key_bytes = base64.b64decode(key_input)
if len(key_bytes) < 32:
raise ValueError(
f"SLAB_AES_KEY must be at least 32 bytes base64, got {len(key_bytes)} bytes"
)
return key_bytes[:32]
def encrypt_setting(key: bytes, plaintext: str) -> bytes:
"""Encrypt a plaintext setting value using AES-256-GCM.
Args:
key: 32-byte AES key (decoded from SLAB_AES_KEY).
plaintext: The secret value to encrypt (e.g. woo_app_password).
Returns:
Bytes of form nonce(12) || ciphertext. Each call generates a
fresh 12-byte nonce.
"""
import os
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return nonce + ct
def decrypt_setting(key: bytes, ciphertext: bytes) -> str:
"""Decrypt a setting ciphertext back to plaintext.
Args:
key: 32-byte AES key (decoded from SLAB_AES_KEY).
ciphertext: Bytes of form nonce(12) || ciphertext from encrypt_setting.
Returns:
Decrypted plaintext string.
Raises:
AuthenticationError: if ciphertext is tampered, truncated,
or decrypted with the wrong key.
"""
if len(ciphertext) < 12:
raise AuthenticationError("Ciphertext too short to contain nonce")
nonce = ciphertext[:12]
ct = ciphertext[12:]
try:
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ct, None)
except Exception as exc:
raise AuthenticationError(
f"Decryption failed: integrity check failed"
) from exc
return plaintext.decode("utf-8")
View File
-65
View File
@@ -1,65 +0,0 @@
"""S1 AES-GCM crypto helpers (SETTINGS-20, SETTINGS-25, QUALITY-125).
Master key = SLAB_AES_KEY env (32-byte base64). Ciphertext = nonce(12) || ct (GCM tag verified on decrypt).
value_enc stores base64(nonce||ct). Decrypt failures are detectable (no crash, no secret leak).
"""
from __future__ import annotations
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
class DecryptError(Exception):
"""Clean, detectable error for tampered/wrong-key ciphertext. Never leaks raw blob."""
pass
def _get_key() -> bytes:
key_b64 = os.environ.get("SLAB_AES_KEY")
if not key_b64:
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)")
try:
key = base64.b64decode(key_b64, validate=True)
except Exception:
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must be valid base64") from None
if len(key) != 32:
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must decode to exactly 32 bytes")
return key
def encrypt(plaintext: str) -> str:
"""Encrypt plaintext -> base64(nonce(12) || ciphertext) for storage in value_enc."""
if not isinstance(plaintext, str):
raise TypeError("plaintext must be str")
key = _get_key()
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
blob = nonce + ct
return base64.b64encode(blob).decode("ascii")
def decrypt(ciphertext_b64: str) -> str:
"""Decrypt base64 blob; verifies GCM tag. Raises DecryptError on tamper/wrong key (no leak)."""
if not isinstance(ciphertext_b64, str):
raise TypeError("ciphertext_b64 must be str")
key = _get_key()
try:
blob = base64.b64decode(ciphertext_b64, validate=True)
except Exception:
raise DecryptError("corrupt base64") from None
if len(blob) < 12:
raise DecryptError("corrupt: too short for nonce")
nonce = blob[:12]
ct = blob[12:]
aesgcm = AESGCM(key)
try:
pt = aesgcm.decrypt(nonce, ct, None)
return pt.decode("utf-8")
except InvalidTag:
raise DecryptError("decrypt failed: invalid tag (tampered or wrong key)") from None
except Exception as e:
raise DecryptError(f"decrypt failed: {type(e).__name__}") from None
-167
View File
@@ -1,167 +0,0 @@
"""S0 db factory package: stdlib sqlite3 ONLY. ... (same as before)"""
from __future__ import annotations
import os
import sqlite3
import threading
import logging
from pathlib import Path
from typing import Any
DB_PATH_ENV = "SLAB_DB_PATH"
IMAGES_DIR_ENV = "SLAB_IMAGES_DIR"
SCHEMA_FILE = "schema.sql"
_lock = threading.Lock()
_conn: sqlite3.Connection | None = None
_current_path: str | None = None
def _get_schema_path() -> Path:
# backend/db/__init__.py -> backend/db/schema.sql
return Path(__file__).parent / SCHEMA_FILE
def get_connection(db_path: str | None = None) -> sqlite3.Connection:
global _conn, _current_path
if db_path is None:
db_path = os.environ.get(DB_PATH_ENV)
if not db_path:
raise RuntimeError(f"{DB_PATH_ENV} env var is required")
db_path = str(Path(db_path).resolve())
with _lock:
if _conn is None or _current_path != db_path:
if _conn is not None:
_conn.close()
_conn = sqlite3.connect(db_path, check_same_thread=False)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("PRAGMA foreign_keys=ON")
_conn.execute("PRAGMA busy_timeout=5000")
_conn.execute("PRAGMA synchronous=NORMAL")
_current_path = db_path
return _conn
def _has_schema_version(conn: sqlite3.Connection) -> bool:
try:
return conn.execute("SELECT 1 FROM schema_version LIMIT 1").fetchone() is not None
except sqlite3.OperationalError:
return False
# (table, column) pairs that must exist for the DB to be a valid M3 schema.
# A pre-M3 (M2) volume lacks these, so any missing sentinel signals drift.
_SCHEMA_SENTINELS = [
("settings", "value"),
("slabs", "deleted_at"),
("slabs", "server_rev"),
("slab_photos", "original_blob"),
("sync_log", "payload_hash"),
]
def _schema_matches(conn: sqlite3.Connection) -> bool:
for table, column in _SCHEMA_SENTINELS:
try:
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
except sqlite3.OperationalError:
return False
if column not in cols:
return False
return True
# Tables the app owns and that must be rebuilt together on drift.
_M3_TABLES = [
"slabs", "slab_photos", "pricing_rules", "settings", "woo_categories",
"woo_attributes", "woo_attribute_terms", "woo_tags", "sync_log",
"inference_log", "prompts", "prompt_revisions",
]
def _migrate(conn: sqlite3.Connection) -> None:
"""Idempotent column additions for volumes created before a column existed.
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
so new columns must be added here. Each guard is a no-op once the column is
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
"""
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
if photo_cols and "role" not in photo_cols:
conn.execute(
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
"CHECK(role IN ('calibration','overlay','batch'))"
)
def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn = get_connection(db_path)
schema_path = _get_schema_path()
if not schema_path.exists():
raise FileNotFoundError(f"Schema not found: {schema_path}")
with open(schema_path, "r", encoding="utf-8") as f:
schema = f.read()
with _lock:
conn.executescript(schema)
conn.commit()
if not _has_schema_version(conn):
if not _schema_matches(conn):
logger = logging.getLogger("slab.db")
logger.info("Detected pre-M3 schema drift, rebuilding app tables")
for t in _M3_TABLES:
conn.execute(f"DROP TABLE IF EXISTS {t}")
conn.executescript(schema)
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
conn.commit()
# Safe to run every boot: no-op once the columns exist.
with _lock:
_migrate(conn)
conn.commit()
return conn
def get_images_dir() -> Path:
img = os.environ.get(IMAGES_DIR_ENV)
if not img:
raise RuntimeError(f"{IMAGES_DIR_ENV} env var is required")
return Path(img).resolve()
def probe_writable(conn: sqlite3.Connection | None = None) -> bool:
try:
c = conn or get_connection()
c.execute("SELECT 1").fetchone()
return True
except Exception:
return False
def probe_images_writable() -> bool:
try:
img_dir = get_images_dir()
if not img_dir.exists():
img_dir.mkdir(parents=True, exist_ok=True)
probe = img_dir / ".health-probe"
probe.write_text("ok", encoding="utf-8")
probe.unlink(missing_ok=True)
return True
except Exception:
return False
def _reset_for_tests() -> None:
"""Close global conn so temp dirs can be cleaned on Windows (test only)."""
global _conn, _current_path
with _lock:
if _conn is not None:
try:
_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
try:
_conn.close()
except Exception:
pass
_conn = None
_current_path = None
-162
View File
@@ -1,162 +0,0 @@
-- SlabUploader SQLite schema (S0)
-- Every table/column from docs/contracts/DATA-MODEL.md using CREATE TABLE IF NOT EXISTS
-- Taxonomy tables exist but remain EMPTY (S4 populates). Stdlib sqlite3 only.
-- Pragmas applied by db factory: journal_mode=WAL, foreign_keys=ON, busy_timeout=5000, synchronous=NORMAL
PRAGMA foreign_keys = ON;
-- 2. slabs
CREATE TABLE IF NOT EXISTS slabs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','calibrated','ready','publishing','published','failed')),
sku TEXT UNIQUE CHECK(length(sku) BETWEEN 3 AND 24),
length_in REAL,
thickness_in REAL,
sqft REAL,
bdft REAL,
width_min_in REAL,
width_max_in REAL,
width_avg_in REAL,
species_id INTEGER,
wood_category_ids TEXT, -- JSON array of leaf ids
edge_type_term_id INTEGER,
figure_term_ids TEXT, -- JSON array
grade_term_id INTEGER,
thickness_term_id INTEGER,
moisture_term_id INTEGER,
fig_tag_ids TEXT, -- JSON array optional
feat_tag_ids TEXT, -- JSON array optional
price REAL,
price_source TEXT CHECK(price_source IN ('recommendation','override') OR price_source IS NULL),
title TEXT,
short_description TEXT,
description TEXT,
content_source TEXT CHECK(content_source IN ('manual','llm','template') OR content_source IS NULL),
species_confidence REAL,
figure_confidence REAL,
grade_confidence REAL,
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
calibration_photo_id TEXT,
client_rev INTEGER NOT NULL DEFAULT 1,
server_rev INTEGER NOT NULL DEFAULT 1,
woo_product_id INTEGER,
published_at TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (species_id) REFERENCES woo_categories(id),
FOREIGN KEY (calibration_photo_id) REFERENCES slab_photos(id)
);
-- 3. slab_photos
CREATE TABLE IF NOT EXISTS slab_photos (
id TEXT PRIMARY KEY,
slab_id TEXT NOT NULL,
seq INTEGER NOT NULL,
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
original_blob BLOB,
processed_blob BLOB,
width_px INTEGER,
height_px INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY (slab_id) REFERENCES slabs(id) ON DELETE CASCADE
);
-- 4. pricing_rules
CREATE TABLE IF NOT EXISTS pricing_rules (
id INTEGER PRIMARY KEY,
species_id INTEGER NOT NULL UNIQUE,
price_per_bdft REAL NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (species_id) REFERENCES woo_categories(id)
);
-- 5. Taxonomy cache (empty at S0; columns chosen to support FKs + S4 sync; ambiguity resolved in learnings)
CREATE TABLE IF NOT EXISTS woo_categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
parent_id INTEGER,
woo_id INTEGER
);
CREATE TABLE IF NOT EXISTS woo_attributes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
woo_id INTEGER
);
CREATE TABLE IF NOT EXISTS woo_attribute_terms (
id INTEGER PRIMARY KEY,
attribute_id INTEGER NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
woo_id INTEGER,
FOREIGN KEY (attribute_id) REFERENCES woo_attributes(id)
);
CREATE TABLE IF NOT EXISTS woo_tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
woo_id INTEGER
);
-- 6. settings (key/value/value_enc per §6; secrets in value_enc = nonce||ciphertext)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
value_enc TEXT
);
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
-- 7. prompts + prompt_revisions (append-only revisions)
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL CHECK(name IN ('call1','call2')),
active_revision_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (active_revision_id) REFERENCES prompt_revisions(id)
);
CREATE TABLE IF NOT EXISTS prompt_revisions (
id INTEGER PRIMARY KEY,
prompt_id INTEGER NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT,
FOREIGN KEY (prompt_id) REFERENCES prompts(id)
);
-- 8. sync_log (append-only)
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY,
slab_id TEXT,
woo_product_id INTEGER,
status TEXT NOT NULL CHECK(status IN ('success','failed')),
payload_hash TEXT,
message TEXT,
created_at TEXT NOT NULL
);
-- 9. inference_log (append-only audit)
CREATE TABLE IF NOT EXISTS inference_log (
id INTEGER PRIMARY KEY,
slab_id TEXT,
call INTEGER NOT NULL CHECK(call IN (1,2)),
prompt_revision_id INTEGER,
model TEXT,
request_payload TEXT,
response_payload TEXT,
latency_ms INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY (prompt_revision_id) REFERENCES prompt_revisions(id)
);
-1042
View File
File diff suppressed because it is too large Load Diff
-135
View File
@@ -1,135 +0,0 @@
"""Photo BLOB storage (S3). Multipart upload of original + processed variants into
slab_photos; GET original/processed while retained; 410 Gone once purged.
Cites CAPTURE-95/105/130 (lineup order), PUBLISH-40 (processed PNGs, no cap).
"""
from typing import Any
from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import JSONResponse, Response
from .db import get_connection
router = APIRouter(prefix="/api/v1/slabs")
def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
def _get_slab(conn, slab_id: str):
return conn.execute(
"SELECT id, deleted_at FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
).fetchone()
def _get_photo(conn, slab_id: str, photo_id: str):
row = conn.execute(
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role "
"FROM slab_photos WHERE id=? AND slab_id=?",
(photo_id, slab_id),
).fetchone()
return row
def _next_seq(conn, slab_id: str) -> int:
row = conn.execute("SELECT COALESCE(MAX(seq),0)+1 FROM slab_photos WHERE slab_id=?", (slab_id,)).fetchone()
return row[0]
@router.post("/{slab_id}/photos", status_code=201)
async def upload_photo(
slab_id: str,
photo_id: str = Form(...),
seq: int | None = Form(None),
is_calibration: int = Form(0),
role: str = Form("batch"),
original: UploadFile = File(...),
processed: UploadFile | None = File(None),
) -> dict:
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
if is_calibration not in (0, 1):
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
if role not in ("calibration", "overlay", "batch"):
return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch"))
orig_bytes = await original.read()
proc_bytes = await processed.read() if processed is not None else None
if not orig_bytes:
return JSONResponse(status_code=400, content=_error("validation", "original photo is empty"))
# Replace any existing photo with the same id on this slab (re-upload).
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
conn.execute(
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
)
conn.commit()
row = _get_photo(conn, slab_id, photo_id)
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
"role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
def _now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
@router.get("/{slab_id}/photos/{photo_id}/original")
async def get_original(slab_id: str, photo_id: str):
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
row = _get_photo(conn, slab_id, photo_id)
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
if row["original_blob"] is None:
return Response(status_code=410)
return Response(content=row["original_blob"], media_type="image/png")
@router.get("/{slab_id}/photos/{photo_id}/processed")
async def get_processed(slab_id: str, photo_id: str):
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
row = _get_photo(conn, slab_id, photo_id)
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
if row["processed_blob"] is None:
return Response(status_code=410)
return Response(content=row["processed_blob"], media_type="image/png")
@router.get("/{slab_id}/photos")
async def list_photos(slab_id: str) -> list[dict]:
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
rows = conn.execute(
"SELECT id, seq, is_calibration, role, width_px, height_px, "
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
(slab_id,),
).fetchall()
return [dict(r) for r in rows]
@router.delete("/{slab_id}/photos/{photo_id}", status_code=204)
async def delete_photo(slab_id: str, photo_id: str):
"""#90: drop a photo the owner removed in Review (keep/remove edit). The
remaining photos are re-numbered by the client via re-upload."""
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
cur = conn.execute(
"DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)
)
conn.commit()
if cur.rowcount == 0:
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
return Response(status_code=204)
-99
View File
@@ -1,99 +0,0 @@
"""Species pricing rules (SETTINGS-35): one flat $/bdft per synced species leaf, no tiers.
Cites SETTINGS-35, REVIEW-25, API.md §9.
"""
import re
from typing import Any
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .db import get_connection
router = APIRouter(prefix="/api/v1/admin/pricing")
def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
def _row_to_rule(row) -> dict:
d = dict(row)
d["species_id"] = row["species_id"]
return d
@router.get("")
async def list_rules() -> list[dict]:
conn = get_connection()
rows = conn.execute(
"SELECT id, species_id, price_per_bdft, created_at, updated_at FROM pricing_rules ORDER BY species_id"
).fetchall()
return [_row_to_rule(r) for r in rows]
@router.post("", status_code=201)
async def create_rule(payload: dict[str, Any]) -> dict:
species_id = payload.get("species_id")
rate = payload.get("price_per_bdft")
if species_id is None or rate is None:
return JSONResponse(status_code=400, content=_error("validation", "species_id and price_per_bdft required"))
if not isinstance(rate, (int, float)) or rate < 0:
return JSONResponse(status_code=400, content=_error("validation", "price_per_bdft must be a non-negative number"))
conn = get_connection()
if conn.execute("SELECT 1 FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone():
return JSONResponse(status_code=409, content=_error("duplicate", "rule exists for species", {"species_id": species_id}))
now = _now()
conn.execute(
"INSERT INTO pricing_rules(species_id, price_per_bdft, created_at, updated_at) VALUES (?,?,?,?)",
(species_id, round(float(rate), 2), now, now),
)
conn.commit()
row = conn.execute("SELECT * FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone()
return _row_to_rule(row)
@router.put("/{rule_id}")
async def update_rule(rule_id: int, payload: dict[str, Any]) -> dict:
conn = get_connection()
cur = conn.execute("SELECT * FROM pricing_rules WHERE id=?", (rule_id,)).fetchone()
if cur is None:
return JSONResponse(status_code=404, content=_error("not_found", "rule not found"))
rate = payload.get("price_per_bdft")
if rate is None or not isinstance(rate, (int, float)) or rate < 0:
return JSONResponse(status_code=400, content=_error("validation", "price_per_bdft must be a non-negative number"))
species_id = payload.get("species_id", cur["species_id"])
if species_id != cur["species_id"]:
if conn.execute("SELECT 1 FROM pricing_rules WHERE species_id=? AND id<>?", (species_id, rule_id)).fetchone():
return JSONResponse(status_code=409, content=_error("duplicate", "rule exists for species", {"species_id": species_id}))
conn.execute(
"UPDATE pricing_rules SET species_id=?, price_per_bdft=?, updated_at=? WHERE id=?",
(species_id, round(float(rate), 2), _now(), rule_id),
)
conn.commit()
row = conn.execute("SELECT * FROM pricing_rules WHERE id=?", (rule_id,)).fetchone()
return _row_to_rule(row)
@router.delete("/{rule_id}", status_code=204)
async def delete_rule(rule_id: int):
conn = get_connection()
cur = conn.execute("DELETE FROM pricing_rules WHERE id=?", (rule_id,))
conn.commit()
if cur.rowcount == 0:
return JSONResponse(status_code=404, content=_error("not_found", "rule not found"))
from fastapi import Response
return Response(status_code=204)
def rate_for_species(conn, species_id: int) -> float | None:
row = conn.execute("SELECT price_per_bdft FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone()
return row[0] if row else None
def _now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
-264
View File
@@ -1,264 +0,0 @@
"""Publish sequence (S6). Cites PUBLISH-5/10/15/20/25/30/31/36/37/38/55/60.
Sequence (PUBLISH-5): validate ready set -> look up SKU (stop if exists) ->
refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload processed
PNGs -> POST /products -> finalize. SLAB-UAT-* always draft (PUBLISH-10).
Failure: mark failed, retain photos, write sync_log, plain message (PUBLISH-30).
Partial (media ok, product POST failed): orphan media left, logged (PUBLISH-31).
"""
import json
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from .db import get_connection
from .woo import (
WooAuthError,
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooDeviceCredsRequired,
WooError,
WooForbiddenError,
client_for_request,
settings_woo_creds,
)
from .review import compute_ready, _slab_dict
router = APIRouter(prefix="/api/v1/slabs")
def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _is_uat(sku: str) -> bool:
return sku.startswith("SLAB-UAT-")
def _slugify(name: str, sku: str) -> str:
base = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return f"{base}-{sku.lower()}"
def _taxonomy_stale(conn) -> bool:
row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()
if not row or not row[0]:
return True
try:
last = datetime.fromisoformat(row[0])
except ValueError:
return True
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) - last > timedelta(hours=1)
def _build_payload(slab: dict, conn, status: str) -> dict:
payload: dict[str, Any] = {
"type": "simple",
"status": status,
"sku": slab["sku"],
"name": slab["title"],
"slug": _slugify(slab["title"], slab["sku"]),
"regular_price": f"{slab['price']:.2f}",
"description": slab["description"],
"short_description": slab["short_description"],
"virtual": False,
"downloadable": False,
"catalog_visibility": "visible",
"sold_individually": True,
}
wood_cats = slab.get("wood_category_ids") or []
payload["categories"] = [{"id": slab["species_id"]}]+[{"id": c} for c in wood_cats]
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
if tags:
payload["tags"] = [{"id": t} for t in tags]
# Five locked attributes: resolve the attribute row from the synced cache by
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
# then fall back to case-insensitive name. Term *options* are always the
# names of the selected synced terms.
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
built_attrs = []
for field, attr_slug, attr_name, pos in (
("edge_type_term_id", "edge-type", "Edge Type", 0),
("figure_term_ids", "pa_figure", "Figure", 1),
("grade_term_id", "pa_grade", "Grade", 2),
("thickness_term_id", "pa_thickness", "Thickness", 3),
("moisture_term_id", "pa_moisture", "Moisture", 4),
):
raw = slab.get(field)
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
options = [terms[t]["name"] for t in term_ids if t in terms]
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
if attr_id and options:
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
if built_attrs:
payload["attributes"] = built_attrs
return payload
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
"""Match a locked attribute against the synced Woo set by slug, then name."""
slug_l = slug.lower().lstrip()
# Accept both "figure" and "pa_figure" style slugs from Woo.
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
for a in attrs.values():
s = (a.get("slug") or "").lower()
if s in slug_alts:
return a["id"]
name_l = name.lower()
for a in attrs.values():
if (a.get("name") or "").lower() == name_l:
return a["id"]
return None
def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
if isinstance(e, WooDeviceCredsRequired):
return (
"woo_device_creds_required",
"Enter this device's WordPress user and application password.",
False,
)
if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError):
return "woo_auth", "The store rejected the credentials. Check the app password.", False
if isinstance(e, WooForbiddenError):
# WAF / security plugin block — retrying the same request will not help.
return (
"woo_forbidden",
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
False,
)
if isinstance(e, WooConnectionError):
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
return "woo_failed", "The store create failed. You can retry.", True
@router.post("/{slab_id}/publish")
async def publish(slab_id: str, request: Request) -> dict:
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row)
# 1. Ready set (PUBLISH-5)
ready, missing = compute_ready(slab)
if not ready:
return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing}))
# 2. SKU lookup (PUBLISH-15) — stop if it exists in the store.
# Per-request Woo creds: require X-Woo-* device headers (no Settings fallback).
try:
client = client_for_request(conn, request.headers)
except WooDeviceCredsRequired as e:
return JSONResponse(
status_code=401,
content=_error(
"woo_device_creds_required",
"Enter this device's WordPress user and application password.",
),
)
except WooCredentialsMissing as e:
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
try:
existing = _sku_exists(client, slab["sku"])
except WooError as e:
code, msg, rec = _classify(e)
_log(conn, slab_id, None, "failed", msg)
conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
conn.commit()
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
if existing:
_log(conn, slab_id, None, "failed", "SKU already exists in the store")
return JSONResponse(status_code=409, content=_error("duplicate_sku", "SKU already exists", {"sku": slab["sku"]}))
# 3. Refresh taxonomy if stale (SETTINGS-60).
if _taxonomy_stale(conn):
try:
from . import taxonomy as taxonomy_mod
taxonomy_mod.sync(client, conn)
except WooError as e:
code, msg, rec = _classify(e)
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
# 4/5. Upload processed PNGs, then POST product.
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
# its dimension-overlay copy, then the remaining batch photos. The Review
# photo strip stores the owner's keep order (leftmost = primary), so the
# overlay (which can sit after a moved batch photo) is re-ordered here by
# role rather than by stored seq. Ties fall back to stored seq.
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
photos = conn.execute(
"SELECT id, processed_blob, role FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL "
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
(slab_id,),
).fetchall()
uploaded_ids: list[int] = []
try:
for p in photos:
uploaded_ids.append(client.upload_media(p["processed_blob"], f"{slab['sku']}-{p['id']}.png"))
payload = _build_payload(slab, conn, status)
if uploaded_ids:
payload["images"] = [{"id": i} for i in uploaded_ids]
woo_product_id = _create_product(client, payload)
except WooError as e:
code, msg, rec = _classify(e)
partial = bool(uploaded_ids)
if partial:
msg = f"{msg} The uploaded images were left in the store — remove them manually."
_log(conn, slab_id, None, "failed", msg)
conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
conn.commit()
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec, "partial_media": partial}))
# 6. Finalize (PUBLISH-25): set woo_product_id, purge photos, discard local row.
now = _now()
conn.execute("UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?",
(woo_product_id, now, now, slab_id))
conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE slab_id=?", (slab_id,))
_log(conn, slab_id, woo_product_id, "success", "published")
conn.commit()
return {
"ok": True,
"status": "published",
"woo_product_id": woo_product_id,
"draft": _is_uat(slab["sku"]),
}
def _woo_creds(conn) -> tuple[str, str | None, str | None]:
"""Shop-default Woo creds (Settings). Prefer client_for_request for HTTP paths."""
return settings_woo_creds(conn)
def _create_status(conn) -> str:
row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone()
return (row[0] if row and row[0] else "draft")
def _sku_exists(client: WooClient, sku: str) -> bool:
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
return len(client.get_products_by_sku(sku)) > 0
def _create_product(client: WooClient, payload: dict) -> int:
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
return client.create_product(payload)
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
conn.execute(
"INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)",
(slab_id, woo_product_id, status, None, message, _now()),
)
+7
View File
@@ -0,0 +1,7 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
line-length = 100
target-version = "py313"
+3
View File
@@ -0,0 +1,3 @@
pytest>=8.3,<9
httpx>=0.28,<1
respx>=0.22,<1
+11 -9
View File
@@ -1,9 +1,11 @@
# S0 backend requirements (pinned, no ORM)
fastapi==0.115.2
uvicorn[standard]==0.30.6
python-multipart==0.0.9
pytest==8.3.3
httpx==0.27.2
cryptography==43.0.1
Pillow==11.3.0
# stdlib only for sqlite3, logging, json, contextvars, threading
fastapi>=0.115,<1
uvicorn[standard]>=0.34,<1
sqlalchemy>=2.0,<3
aiosqlite>=0.20,<1
alembic>=1.14,<2
httpx>=0.28,<1
pydantic>=2.9,<3
pydantic-settings>=2.7,<3
python-dotenv>=1.0,<2
cryptography>=43.0,<45
python-multipart>=0.0.30,<1
-262
View File
@@ -1,262 +0,0 @@
"""Review to ready (S5): deterministic title/short description, price suggestion,
and server-derived `ready` status. Cites REVIEW-5/10/15/20/25/30/40/45/50,
METADATA-80/85/90, SETTINGS-35.
"""
import json
import re
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .db import get_connection
from .pricing import rate_for_species
router = APIRouter(prefix="/api/v1/slabs")
# Wood-category leaf slug -> (title pattern, noun, carries_dims).
# Patterns interpolate {figure}, {species}, {length}, {width}, {thickness}, {bdft}.
# Figure is Title-Case adjective; absent figure drops the slot.
_LEAF_PATTERNS = {
"live-edge-wood": ("{figure}{species} Live Edge Slab", "Live Edge Slab", False),
"dimensional-wood": ("{figure}{species} {live_edge}Dimensional", "Dimensional", False),
"knife-blanks": ("{figure}{species} Knife Blank ({dims})", "Knife Blank", True),
"bowl-blanks": ("{figure}{species} {live_edge}Bowl Blank ({dims})", "Bowl Blank", True),
"logs-and-caps": ("{figure}{species} Log Cap", "Log Cap", False),
"pen-blanks": ("{figure}{species} Pen Blank ({dims})", "Pen Blank", True),
"pepper-mill-blanks": ("{figure}{species} Pepper Mill Blank ({dims})", "Pepper Mill Blank", True),
}
# Required fields for a slab to reach `ready` (REVIEW-5). Measurement fields
# (widths/sqft/bdft) are read-only client math but must be present.
_REQUIRED = [
"sku", "length_in", "thickness_in", "sqft", "bdft",
"width_min_in", "width_max_in", "width_avg_in",
"species_id", "wood_category_ids", "edge_type_term_id",
"figure_term_ids", # UX requires ≥1 figure (Create-desc gate + ready)
"grade_term_id", "thickness_term_id", "moisture_term_id",
"price", "price_source", "title", "short_description", "description",
"content_source",
]
def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _split_length(length_in: float) -> tuple[int, float]:
ft = int(length_in // 12)
rem = round(length_in - ft * 12, 1)
return ft, rem
def _fmt1(v: float) -> str:
# 1 dp, trim trailing .0
s = f"{v:.1f}"
return s[:-2] if s.endswith(".0") else s
def _fmt2(v: float) -> str:
# 2 dp, trim trailing zeros (thickness carries 1/8-inch precision)
s = f"{v:.2f}".rstrip("0").rstrip(".")
return s
def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
)
figure = figure.strip() if figure else ""
live_edge = ""
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
# "Live Edge" is a product-form flag driven by the *synced* edge term
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
title = pattern.format(
figure=figure + " " if figure else "",
species=species_name,
live_edge=live_edge,
noun=noun,
dims=_dims(slab["length_in"], slab["width_avg_in"], slab["thickness_in"]) if carries_dims else "",
)
return re.sub(r"\s+", " ", title).strip()
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
"""True when the synced edge term name/slug indicates live edge."""
if not edge_term_id:
return False
term = terms.get(edge_term_id) or {}
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
return "live" in blob
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
title = build_title(slab, figure, species_name, primary_leaf, terms)
ft, rem = _split_length(slab["length_in"])
length_str = f"{ft}ft {_fmt1(rem)}in"
bdft = round(slab["bdft"], 1)
line = f"{title}, {length_str} x {_fmt1(slab['width_avg_in'])}in x {_fmt2(slab['thickness_in'])}in, {_fmt1(bdft)} bdft"
return re.sub(r"\s+", " ", line).strip()
def _slab_dict(row) -> dict:
d = dict(row)
for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"):
if d.get(f) is not None and isinstance(d[f], str):
try:
d[f] = json.loads(d[f])
except (ValueError, TypeError):
d[f] = []
return d
def _taxonomy_lookup(conn):
cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()}
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
return cats, terms
def missing_required(slab: dict) -> list[str]:
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
# REVIEW-45: SKU is 3 to 24 chars, alphanumeric and hyphen only. The middle tier
# (not the DB CHECK) is the business-logic arbiter here; the DB constraint is a backstop.
_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$")
def sku_valid(sku: Any) -> bool:
return isinstance(sku, str) and bool(_SKU_RE.match(sku))
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
missing = missing_required(slab)
# A present-but-malformed SKU is not ready (business rule, enforced in the middle tier).
if "sku" not in missing and not sku_valid(slab.get("sku")):
missing.append("sku")
return (len(missing) == 0), missing
def price_suggestion(conn, slab: dict) -> float | None:
if not slab.get("species_id") or slab.get("bdft") is None:
return None
rate = rate_for_species(conn, slab["species_id"])
if rate is None:
return None
return round(slab["bdft"] * rate, 2)
@router.post("/{slab_id}/review")
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
win (METADATA-50). Assembled values are written back to the slab so the lock
sticks for publish.
"""
payload = payload or {}
conn = get_connection()
row = conn.execute(
"SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
).fetchone()
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row)
# --- Deterministic review: report ready/missing + the deterministic
# title/short_description built from the current metadata. The client's
# "Create title" / "Create short desc" buttons call this endpoint and write the
# returned strings into the fields (#89: no separate lock action).
# #80: persist owner overrides (price/sku) from the payload so the review
# endpoint is self-contained. title/short_description are NOT overridden here —
# they are produced by the deterministic build below, not persisted from the
# payload (so the review POST can't clobber the owner's Create workflow).
cats, terms = _taxonomy_lookup(conn)
def _as_cat_id(v):
if v is None or v == "":
return None
try:
return int(v)
except (TypeError, ValueError):
return None
primary_id = _as_cat_id(payload.get("primary_wood_category_id"))
wood_cats = slab.get("wood_category_ids") or []
if not primary_id:
primary_id = _as_cat_id(wood_cats[0]) if wood_cats else None
# Taxonomy rows use integer PKs; coerce so string JSON ids still resolve.
primary_leaf = cats.get(primary_id, {}) if primary_id is not None else {}
species_id = _as_cat_id(slab.get("species_id"))
species_name = cats.get(species_id, {}).get("name", "") if species_id is not None else ""
figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
# #80: persist owner overrides from the payload (price/sku only).
overrides: dict[str, Any] = {}
for k in ("price", "sku"):
if k in payload and payload[k] is not None:
overrides[k] = payload[k]
if overrides:
set_clause = ", ".join(f"{k} = ?" for k in overrides)
conn.execute(
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
[*overrides.values(), _now(), slab_id],
)
conn.commit()
slab.update(overrides)
# Deterministic title / short_description, re-derived from the current
# metadata on every review (the Create button's source of truth).
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
# title_built tells the client whether the Create-title path actually ran
# (vs echoing a pre-typed title when species/primary are missing from taxonomy).
title_build_missing: list[str] = []
if not species_name:
title_build_missing.append("species")
if not primary_leaf:
title_build_missing.append("primary_wood_category")
title_built = not title_build_missing
title = (
build_title(slab, figure, species_name, primary_leaf, terms)
if title_built
else slab.get("title") or None
)
short_desc = (
build_short_description(slab, figure, species_name, primary_leaf, terms)
if title_built
else slab.get("short_description") or None
)
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
return {
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
"price": price, "price_suggestion": price_suggestion(conn, slab),
"primary_wood_category_id": primary_id,
"title_built": title_built,
"title_build_missing": title_build_missing,
}
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
else the stored price, else the suggestion (None if no rate)."""
suggested = price_suggestion(conn, slab)
if payload.get("price") is not None:
return payload["price"]
if slab.get("price") not in (None, 0):
return slab["price"]
return suggested
-144
View File
@@ -1,144 +0,0 @@
"""S1 Settings service (DATA-MODEL §6, API.md §10, SETTINGS-5/15/20/25/30/75/85/90).
Secrets (value_enc) are AES-GCM write-only; GET returns only {configured: bool}.
woo_base_url is env read-only. Flags default false. Partial PUT. Decrypt fail -> configured:false (no 500, no leak).
"""
from __future__ import annotations
import os
from typing import Any
from fastapi import HTTPException
from .crypto import decrypt, encrypt, DecryptError
from .db import get_connection
SECRET_KEYS: set[str] = {
"woo_app_password",
"inference_base_url",
"inference_api_key",
}
PLAIN_KEYS: set[str] = {
"woo_wp_username",
"inference_model",
"inference_enabled",
"content_llm_enabled",
"brand_voice",
"geo_context",
"woo_create_status",
"taxonomy_last_sync_at",
"taxonomy_anchor",
}
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
DEFAULTS: dict[str, Any] = {
"inference_enabled": False,
"content_llm_enabled": False,
"woo_create_status": "draft",
}
def _row_to_value(row: dict) -> Any:
if row is None:
return None
v = row.get("value")
if v is None:
return None
if isinstance(v, str):
if v.lower() in ("true", "false"):
return v.lower() == "true"
return v
def get_settings() -> dict[str, Any]:
conn = get_connection()
rows = {}
for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall():
rows[r["key"]] = {"value": r["value"], "value_enc": r["value_enc"]}
result: dict[str, Any] = {}
result["woo_base_url"] = os.environ.get("WOO_BASE_URL", "")
# TEMPORARY UAT override (owner-directed, LAN/HTTP only): when set, secrets are
# returned as their decrypted plaintext so they can be inspected on the dev-test
# box. Default OFF -> SETTINGS-75 masking intact. Remove before prod.
unmask = os.environ.get("SLAB_UAT_UNMASK_SECRETS") == "1"
for k in sorted(ALL_KEYS):
row = rows.get(k, {})
if k in SECRET_KEYS:
enc = row.get("value_enc")
if unmask:
if enc:
try:
result[k] = decrypt(enc)
except Exception:
result[k] = ""
else:
result[k] = ""
else:
if enc:
try:
decrypt(enc) # verify roundtrip possible
result[k] = {"configured": True}
except Exception:
result[k] = {"configured": False}
else:
result[k] = {"configured": False}
else:
if k in rows:
val = _row_to_value(row)
result[k] = val if val is not None else DEFAULTS.get(k)
else:
result[k] = DEFAULTS.get(k)
return result
def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
if "woo_base_url" in payload:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "validation",
"message": "woo_base_url is read-only (SETTINGS-85: env only)",
"detail": {"field": "woo_base_url"},
}
},
)
conn = get_connection()
for k, v in payload.items():
if k not in ALL_KEYS:
continue # ignore unknown for partial
if k in SECRET_KEYS:
if v in (None, "", False):
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)",
(k, None),
)
else:
try:
enc = encrypt(str(v))
except Exception:
enc = None
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,?)",
(k, None, enc),
)
else:
# plain
if v in (None, ""):
conn.execute("DELETE FROM settings WHERE key=?", (k,))
else:
if k in ("inference_enabled", "content_llm_enabled"):
val_str = "true" if bool(v) else "false"
else:
val_str = str(v)
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)",
(k, val_str),
)
conn.commit()
return get_settings()
-309
View File
@@ -1,309 +0,0 @@
"""Slab CRUD service + FastAPI router (S2).
Client-derived numbers are stored as sent (never recomputed). Optimistic
concurrency via a single compare-and-set UPDATE on server_rev. Soft delete
sets deleted_at; published slabs cannot be deleted.
"""
import json
from datetime import datetime, timezone
from typing import Any, Dict, List
from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
from .db import get_connection
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
router = APIRouter(prefix="/api/v1/slabs")
# Client-settable slab columns (explicit allowlist; never build SQL from client keys).
ALLOWED_FIELDS = [
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
"width_min_in", "width_max_in", "width_avg_in", "species_id",
"wood_category_ids", "edge_type_term_id", "figure_term_ids", "grade_term_id",
"thickness_term_id", "moisture_term_id", "fig_tag_ids", "feat_tag_ids",
"price", "price_source", "title", "short_description", "description",
"content_source", "species_confidence", "figure_confidence", "grade_confidence",
"inference_status", "calibration_photo_id",
]
JSON_ARRAY_FIELDS = {"wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"}
# Columns a client may set on update (id/status/revs/timestamps/deleted_at excluded).
ALLOWED_UPDATE_FIELDS = set(ALLOWED_FIELDS) - {
"id", "status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at",
}
TAXONOMY_TABLES = {
"species_id": "woo_categories",
"wood_category_ids": "woo_categories",
"edge_type_term_id": "woo_attribute_terms",
"figure_term_ids": "woo_attribute_terms",
"grade_term_id": "woo_attribute_terms",
"thickness_term_id": "woo_attribute_terms",
"moisture_term_id": "woo_attribute_terms",
"fig_tag_ids": "woo_tags",
"feat_tag_ids": "woo_tags",
}
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
FIELD_LABELS = {
"species_id": "Species",
"wood_category_ids": "Wood category",
"edge_type_term_id": "Edge type",
"figure_term_ids": "Figure",
"grade_term_id": "Grade",
"thickness_term_id": "Thickness",
"moisture_term_id": "Moisture",
"fig_tag_ids": "Figure tags",
"feat_tag_ids": "Feature tags",
}
PRICE_SOURCES = {"recommendation", "override", "manual"}
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _error(code: str, message: str, detail: Any = None) -> Dict[str, Any]:
return {"error": {"code": code, "message": message, "detail": detail}}
def _err(code: str, message: str, detail: Any = None, status: int = 400) -> JSONResponse:
return JSONResponse(status_code=status, content=_error(code, message, detail))
def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
out = dict(payload)
for f in JSON_ARRAY_FIELDS:
if f in out and isinstance(out[f], list):
out[f] = json.dumps(out[f])
return out
# #89: server-managed fields that a plain client PUT must not set directly.
# (metadata_locked was dropped — the deterministic title/short-desc are built by
# the review endpoint and written back by the client's Create buttons.)
CLIENT_FORBIDDEN_FIELDS = set()
def _row_to_slab(row) -> Dict[str, Any]:
d = dict(row)
for f in JSON_ARRAY_FIELDS:
if d.get(f) is not None and isinstance(d[f], str):
try:
d[f] = json.loads(d[f])
except (ValueError, TypeError):
pass
return d
def _sku_taken(conn, sku: str, exclude_id: str | None = None) -> bool:
if exclude_id:
return conn.execute(
"SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL AND id<>?", (sku, exclude_id)
).fetchone() is not None
return conn.execute(
"SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL", (sku,)
).fetchone() is not None
def _check_taxonomy(conn, table: str, val: Any) -> bool:
if isinstance(val, list):
if not val:
return True
ph = ",".join(["?"] * len(val))
rows = conn.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", val).fetchall()
return {r[0] for r in rows} == set(val)
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
def _current_options(conn, table: str) -> list[dict[str, Any]]:
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
def _normalize_tax_val(val: Any) -> Any:
if isinstance(val, str):
try:
return json.loads(val)
except (ValueError, TypeError):
return []
return val
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
for field, table in TAXONOMY_TABLES.items():
if field not in payload or payload[field] is None:
continue
val = _normalize_tax_val(payload[field])
if not _check_taxonomy(conn, table, val):
label = FIELD_LABELS.get(field, field)
return _err(
"stale_field",
f"{label} is no longer in the store taxonomy",
{"field": field, "current_options": _current_options(conn, table)},
status=422,
)
return None
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
if "price_source" in payload and payload["price_source"] is not None:
if payload["price_source"] not in PRICE_SOURCES:
return _err(
"validation",
"price_source is not a known value",
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
status=400,
)
if "inference_status" in payload and payload["inference_status"] is not None:
if payload["inference_status"] not in INFERENCE_STATUSES:
return _err(
"validation",
"inference_status is not a known value",
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
status=400,
)
return None
# Capture-only fields required to create a slab. The Review screen fills the
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
# editable until create"), so create must accept a capture-math payload.
# client_rev is server-managed (set to 1 at create) — never required from the client.
REQUIRED_CREATE_FIELDS = [
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
"width_min_in", "width_max_in", "width_avg_in",
]
@router.post("", status_code=201)
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
if missing:
return _err("validation", "missing fields", {"fields": missing}, status=400)
# #83: reject unknown keys (client sent a field that is not client-settable).
# The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped
# — but a 400 tells the client its payload is malformed, not that the slab
# was created with the extra keys silently ignored.
unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"]
if unknown:
return _err("validation", "unknown fields", {"fields": unknown}, status=400)
# Business rule (middle tier, not the DB): an empty/blank SKU at create means
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
# governs present SKUs, and NULL is the honest "absent" representation.
sku = payload.get("sku")
if isinstance(sku, str) and sku.strip() == "":
payload = {**payload, "sku": None}
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
# Resolve it from the measured thickness against the live synced Woo bands and
# overwrite whatever the client sent (the UI has no band control).
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
now = _now_iso()
data = _encode(payload)
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
server_cols = ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
server_vals = ["calibrated", 1, 1, None, now, now]
cols = allowed_cols + server_cols
# One value per col, in cols order: data values for the allowed fields, then the 6 server values.
vals = [data[f] for f in allowed_cols] + server_vals
ph = ",".join(["?"] * len(cols))
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
try:
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
except Exception:
# QUALITY-120: never surface a driver stack to the owner.
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
return _row_to_slab(row)
@router.get("")
async def list_slabs() -> List[Dict[str, Any]]:
conn = get_connection()
rows = conn.execute("SELECT * FROM slabs WHERE deleted_at IS NULL ORDER BY updated_at DESC, id DESC").fetchall()
return [_row_to_slab(r) for r in rows]
@router.get("/{id}")
async def get_slab(id: str) -> Dict[str, Any]:
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if row is None:
return _err("not_found", "Slab not found", status=404)
return _row_to_slab(row)
@router.put("/{id}")
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if "client_rev" not in payload:
return _err("validation", "client_rev required", status=400)
# #89: server-managed fields a plain PUT must not set.
for field in CLIENT_FORBIDDEN_FIELDS:
if field in payload:
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if current is None:
return _err("not_found", "Slab not found", status=404)
client_rev = payload["client_rev"]
if client_rev != current["server_rev"]:
return _err("revision_conflict", "client_rev mismatch",
{"current_server_rev": current["server_rev"]}, status=409)
if "sku" in payload and payload["sku"] != current["sku"]:
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
eff_thickness = payload.get("thickness_in", current["thickness_in"])
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
data = _encode(payload)
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
if not cols:
return _row_to_slab(current)
set_clause = ", ".join(f"{f} = ?" for f in cols)
vals = [data[f] for f in cols] + [client_rev + 1, _now_iso(), id, client_rev]
cur = conn.execute(
f"UPDATE slabs SET {set_clause}, server_rev=?, updated_at=? WHERE id=? AND server_rev=?",
vals,
)
conn.commit()
if cur.rowcount == 0:
cur_server = conn.execute("SELECT server_rev FROM slabs WHERE id=?", (id,)).fetchone()
return _err("revision_conflict", "client_rev mismatch",
{"current_server_rev": cur_server[0] if cur_server else None}, status=409)
row = conn.execute("SELECT * FROM slabs WHERE id=?", (id,)).fetchone()
return _row_to_slab(row)
@router.delete("/{id}", status_code=204)
async def delete_slab(id: str):
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if row is None:
return _err("not_found", "Slab not found", status=404)
if row["status"] == "published":
return _err("publish_conflict", "Published slab cannot be deleted", status=409)
conn.execute("UPDATE slabs SET deleted_at=? WHERE id=?", (_now_iso(), id))
conn.commit()
return Response(status_code=204)
-157
View File
@@ -1,157 +0,0 @@
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
Rebuild happens in one transaction; on any failure the prior cache is kept.
Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen
(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored
subset, recorded only when it changes. Display names are HTML-unescaped so
Woo entities like ``&amp;`` store as ``&``.
"""
import hashlib
import html
import json
from datetime import datetime, timezone
from .woo import WooClient
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _decode_name(name: str | None) -> str:
"""Decode HTML entities Woo puts in taxonomy display names."""
return html.unescape(name or "")
def _scoped_tags(tags: list[dict]) -> list[dict]:
"""Keep product tags whose slug starts with fig- or feat- (hyphen required).
Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl.
Filtering by name prefix kept 0 live tags; slug is authoritative.
"""
out = []
for t in tags:
slug = t.get("slug") or ""
if slug.startswith("fig-") or slug.startswith("feat-"):
out.append(t)
return out
def _normalize_category(c: dict) -> tuple:
"""Map a Woo REST category row into woo_categories insert values.
WooCommerce REST v3 returns `parent` (0 = top-level). Older fixtures may
already use `parent_id`. Never invent ids — only store what the payload carries.
"""
raw_parent = c.get("parent_id", c.get("parent"))
if raw_parent in (0, "0", None, ""):
parent_id = None
else:
parent_id = int(raw_parent)
woo_id = c.get("woo_id", c.get("id"))
if woo_id is not None:
woo_id = int(woo_id)
return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id)
def _subset_hash(categories: list[dict], attributes: list[dict],
terms_by_attr: dict, scoped_tags: list[dict]) -> str:
subset = {
"categories": sorted(categories, key=lambda c: c["id"]),
"attributes": sorted(attributes, key=lambda a: a["id"]),
"terms": {str(k): sorted(v, key=lambda t: t["id"]) for k, v in terms_by_attr.items()},
"tags": sorted(scoped_tags, key=lambda t: t["id"]),
}
return hashlib.sha256(json.dumps(subset, sort_keys=True).encode("utf-8")).hexdigest()
def sync(client: WooClient, conn) -> dict:
"""Rebuild the taxonomy cache from the store. Raises WooError on failure (caller
maps to 502; prior cache is retained because we only commit on success)."""
categories = client.get_categories()
tags = client.get_tags()
attributes = client.get_attributes()
terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes}
scoped_tags = _scoped_tags(tags)
# Attributes/tags/terms are not referenced by slabs FKs — safe to rebuild.
conn.execute("DELETE FROM woo_attribute_terms")
conn.execute("DELETE FROM woo_attributes")
conn.execute("DELETE FROM woo_tags")
# Categories: upsert then drop stale rows. A full DELETE fails when slabs or
# pricing_rules still reference species_id (live UAT IntegrityError on test-woo).
cat_rows = [_normalize_category(c) for c in categories]
cat_ids = [r[0] for r in cat_rows]
for row in cat_rows:
conn.execute(
"INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET "
"name=excluded.name, slug=excluded.slug, parent_id=excluded.parent_id, woo_id=excluded.woo_id",
row,
)
if cat_ids:
placeholders = ",".join("?" * len(cat_ids))
conn.execute(
f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) "
"AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
"AND id NOT IN (SELECT species_id FROM pricing_rules)",
cat_ids,
)
else:
conn.execute(
"DELETE FROM woo_categories "
"WHERE id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
"AND id NOT IN (SELECT species_id FROM pricing_rules)"
)
conn.executemany(
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
[(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes],
)
for attr_id, terms in terms_by_attr.items():
conn.executemany(
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
[(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms],
)
conn.executemany(
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
[(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags],
)
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
now = _now_iso()
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_last_sync_at', ?, NULL)", (now,))
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_anchor', ?, NULL)", (anchor,))
conn.commit()
return {"taxonomy_anchor": anchor, "taxonomy_last_sync_at": now}
def get_cached(conn) -> dict:
"""Return the cached taxonomy grouped for the admin view."""
categories = [dict(r) for r in conn.execute("SELECT * FROM woo_categories ORDER BY name").fetchall()]
attributes = [dict(r) for r in conn.execute("SELECT * FROM woo_attributes ORDER BY name").fetchall()]
terms = [dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms ORDER BY name").fetchall()]
tags = [dict(r) for r in conn.execute("SELECT * FROM woo_tags ORDER BY name").fetchall()]
anchor_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_anchor'").fetchone()
sync_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()
terms_by_attr: dict[int, list] = {a["id"]: [] for a in attributes}
for t in terms:
terms_by_attr.setdefault(t["attribute_id"], []).append(t)
grouped_attrs = [
{
"id": a["id"],
"name": a["name"],
"slug": a["slug"],
"terms": [{"id": t["id"], "name": t["name"], "slug": t["slug"]} for t in terms_by_attr.get(a["id"], [])],
}
for a in attributes
]
return {
"categories": categories,
"attributes": grouped_attrs,
"tags": tags,
"taxonomy_anchor": anchor_row[0] if anchor_row else None,
"taxonomy_last_sync_at": sync_row[0] if sync_row else None,
}
View File
+45
View File
@@ -0,0 +1,45 @@
"""Shared test fixtures for all backend tests.
App factory pattern (FUND-8): each test gets a fresh app instance.
FUND-9: TestClient manages lifespan.
FUND-10: dependency_overrides cleared after every test.
"""
import pytest
from app.config import Settings
from app.db import engine
from app.main import create_app
@pytest.fixture(autouse=True)
def _test_db() -> None:
"""Set up a fresh in-memory SQLite database for each test."""
engine.set_test_url("sqlite+aiosqlite:///:memory:")
yield
engine._test_url = None
engine._engine = None
engine._session_factory = None
@pytest.fixture
def settings() -> Settings:
"""Create test settings with in-memory SQLite."""
return Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk", # 32 bytes base64
woo_base_url="https://test.example.com",
app_hostname="test.local",
)
@pytest.fixture
def app(settings: Settings) -> None:
"""Yield a fresh FastAPI app with test settings, then clean up.
Each test gets its own app instance (FUND-8).
TestClient manages the lifespan (FUND-9).
dependency_overrides are cleared after each test (FUND-10).
"""
app_instance = create_app(settings)
yield app_instance
app_instance.dependency_overrides.clear()
View File
+113
View File
@@ -0,0 +1,113 @@
"""H-1: Health endpoint contract test.
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
H-1: GET /api/health returns 200 {status: ok} with woo_reachable false
when credentials are not configured.
FUND-8: App factory creates fresh instance per test.
FUND-9: TestClient context manager manages lifespan.
FUND-10: dependency_overrides cleared after every test.
"""
from fastapi.testclient import TestClient
from app.main import create_app
class TestHealthEndpoint:
"""GET /api/health contract tests."""
def test_returns_200(self) -> None:
"""H-1: Health endpoint returns 200."""
from app.config import Settings
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
client.close()
app.dependency_overrides.clear()
def test_returns_ok_status(self) -> None:
"""H-1: Health endpoint returns status ok."""
from app.config import Settings
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
client = TestClient(app)
response = client.get("/api/health")
data = response.json()
assert data["status"] == "ok"
client.close()
app.dependency_overrides.clear()
def test_woo_reachable_false_when_credentials_not_configured(self) -> None:
"""H-1: woo_reachable is false when Woo credentials not configured."""
from app.config import Settings
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
client = TestClient(app)
response = client.get("/api/health")
data = response.json()
assert data["woo_reachable"] is False
client.close()
app.dependency_overrides.clear()
def test_response_shape(self) -> None:
"""H-1: Response contains exactly status, version, and woo_reachable."""
from app.config import Settings
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
client = TestClient(app)
response = client.get("/api/health")
data = response.json()
assert "status" in data
assert "version" in data
assert "woo_reachable" in data
assert isinstance(data["status"], str)
assert isinstance(data["version"], str)
assert isinstance(data["woo_reachable"], bool)
client.close()
app.dependency_overrides.clear()
def test_with_test_client_lifespan(self) -> None:
"""FUND-9: TestClient with app manages lifespan correctly."""
from app.config import Settings
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
app.dependency_overrides.clear()
+520
View File
@@ -0,0 +1,520 @@
"""D-1: Draft store stub — POST /api/v1/slabs contract test.
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
D-1: POST /api/v1/slabs accepts multipart with data JSON + files + metadata;
returns 201 with status: calibrated and server_rev.
FUND-8: App factory creates fresh instance per test.
FUND-9: TestClient context manager manages lifespan.
FUND-10: dependency_overrides cleared after every test.
"""
import json
import uuid
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
class TestCreateSlab:
"""D-1: POST /api/v1/slabs multipart contract."""
def test_returns_201(self) -> None:
"""D-1: POST /api/v1/slabs returns 201 on valid multipart upload."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
slab_id = str(uuid.uuid4())
data_json = json.dumps({
"id": slab_id,
"sku": "BW-0042",
"length_in": 96.0,
"thickness_in": 1.5,
"sqft": 0.64,
"bdft": 0.96,
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3],
"grade_term_ids": [7],
"thickness_term_id": 12,
"moisture_term_id": 5,
"fig_tag_ids": [99],
"feat_tag_ids": [42],
"price_per_bdft": 12.50,
"price": 12.00,
"price_source": "recommendation",
"title": None,
"short_title": None,
"description": None,
"content_source": None,
"client_rev": 1,
})
with TestClient(app) as client:
response = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response.status_code == 201
app.dependency_overrides.clear()
def test_returns_status_calibrated(self) -> None:
"""D-1: Response contains status: calibrated."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
slab_id = str(uuid.uuid4())
data_json = json.dumps({
"id": slab_id,
"sku": "BW-0099",
"length_in": 96.0,
"thickness_in": 1.5,
"sqft": 0.64,
"bdft": 0.96,
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3],
"grade_term_ids": [7],
"thickness_term_id": 12,
"moisture_term_id": 5,
"fig_tag_ids": [99],
"feat_tag_ids": [42],
"price_per_bdft": 12.50,
"price": 12.00,
"price_source": "recommendation",
"title": None,
"short_title": None,
"description": None,
"content_source": None,
"client_rev": 1,
})
with TestClient(app) as client:
response = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response.status_code == 201
body = response.json()
assert body["status"] == "calibrated"
app.dependency_overrides.clear()
def test_returns_server_rev(self) -> None:
"""D-1: Response contains server_rev."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
slab_id = str(uuid.uuid4())
data_json = json.dumps({
"id": slab_id,
"sku": "BW-0077",
"length_in": 96.0,
"thickness_in": 1.5,
"sqft": 0.64,
"bdft": 0.96,
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3],
"grade_term_ids": [7],
"thickness_term_id": 12,
"moisture_term_id": 5,
"fig_tag_ids": [99],
"feat_tag_ids": [42],
"price_per_bdft": 12.50,
"price": 12.00,
"price_source": "recommendation",
"title": None,
"short_title": None,
"description": None,
"content_source": None,
"client_rev": 1,
})
with TestClient(app) as client:
response = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response.status_code == 201
body = response.json()
assert "server_rev" in body
assert body["server_rev"] == 1
app.dependency_overrides.clear()
def test_response_shape(self) -> None:
"""D-1: Response contains required fields."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
slab_id = str(uuid.uuid4())
data_json = json.dumps({
"id": slab_id,
"sku": "BW-0033",
"length_in": 96.0,
"thickness_in": 1.5,
"sqft": 0.64,
"bdft": 0.96,
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3],
"grade_term_ids": [7],
"thickness_term_id": 12,
"moisture_term_id": 5,
"fig_tag_ids": [99],
"feat_tag_ids": [42],
"price_per_bdft": 12.50,
"price": 12.00,
"price_source": "recommendation",
"title": None,
"short_title": None,
"description": None,
"content_source": None,
"client_rev": 1,
})
with TestClient(app) as client:
response = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response.status_code == 201
body = response.json()
# Required fields per OPENAPI.md Slab read model
required_fields = [
"id", "sku", "status", "length_in", "thickness_in",
"sqft", "bdft", "width_min_in", "width_max_in", "width_avg_in",
"species_id", "wood_category_ids", "edge_type_term_id",
"figure_term_ids", "grade_term_ids", "thickness_term_id",
"moisture_term_id", "fig_tag_ids", "feat_tag_ids",
"price_per_bdft", "price", "price_source", "title",
"short_title", "description", "content_source",
"client_rev", "server_rev", "created_at", "updated_at",
"photos", "inference_status",
]
for field in required_fields:
assert field in body, f"Missing field: {field}"
# Verify stored values match sent values (server stores as-is)
assert body["id"] == slab_id
assert body["sku"] == "BW-0033"
assert body["sqft"] == 0.64
assert body["bdft"] == 0.96
assert body["width_min_in"] == 14.0
assert body["width_max_in"] == 22.0
assert body["width_avg_in"] == 18.2
assert body["price"] == 12.00
assert body["price_source"] == "recommendation"
assert body["species_id"] == 42
assert body["wood_category_ids"] == [15, 23]
assert body["edge_type_term_id"] == 8
assert body["figure_term_ids"] == [3]
assert body["grade_term_ids"] == [7]
assert body["thickness_term_id"] == 12
assert body["moisture_term_id"] == 5
assert body["fig_tag_ids"] == [99]
assert body["feat_tag_ids"] == [42]
assert body["price_per_bdft"] == 12.50
app.dependency_overrides.clear()
def test_multiple_photos(self) -> None:
"""D-1: Multiple files[] and meta[] pairs are accepted."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
slab_id = str(uuid.uuid4())
data_json = json.dumps({
"id": slab_id,
"sku": "BW-0055",
"length_in": 96.0,
"thickness_in": 1.5,
"sqft": 0.64,
"bdft": 0.96,
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3],
"grade_term_ids": [7],
"thickness_term_id": 12,
"moisture_term_id": 5,
"fig_tag_ids": [99],
"feat_tag_ids": [42],
"price_per_bdft": 12.50,
"price": 12.00,
"price_source": "recommendation",
"title": None,
"short_title": None,
"description": None,
"content_source": None,
"client_rev": 1,
})
with TestClient(app) as client:
response = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
("files[]", ("extra1.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
# Note: httpx TestClient with multiple files[] + single meta[]
# will pair them. The meta[] for the second file defaults.
assert response.status_code == 201
body = response.json()
assert len(body["photos"]) >= 2
app.dependency_overrides.clear()
def test_missing_data_field_returns_400(self) -> None:
"""D-1: Missing data field returns 400."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response.status_code == 400
body = response.json()
assert body["detail"]["code"] == "validation"
app.dependency_overrides.clear()
def test_duplicate_sku_returns_409(self) -> None:
"""D-1: Duplicate SKU returns 409."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
slab_id = str(uuid.uuid4())
sku = "DUPLICATE-TEST"
data_json = json.dumps({
"id": slab_id,
"sku": sku,
"length_in": 96.0,
"thickness_in": 1.5,
"sqft": 0.64,
"bdft": 0.96,
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3],
"grade_term_ids": [7],
"thickness_term_id": 12,
"moisture_term_id": 5,
"fig_tag_ids": [99],
"feat_tag_ids": [42],
"price_per_bdft": 12.50,
"price": 12.00,
"price_source": "recommendation",
"title": None,
"short_title": None,
"description": None,
"content_source": None,
"client_rev": 1,
})
with TestClient(app) as client:
# Create first slab
response1 = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response1.status_code == 201
# Try to create second slab with same SKU
response2 = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response2.status_code == 409
body = response2.json()
assert body["detail"]["code"] == "duplicate_sku"
app.dependency_overrides.clear()
def test_with_test_client_lifespan(self) -> None:
"""FUND-9: TestClient with app manages lifespan correctly for slabs."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://test.example.com",
)
app = create_app(settings)
slab_id = str(uuid.uuid4())
data_json = json.dumps({
"id": slab_id,
"sku": "BW-0011",
"length_in": 96.0,
"thickness_in": 1.5,
"sqft": 0.64,
"bdft": 0.96,
"width_min_in": 14.0,
"width_max_in": 22.0,
"width_avg_in": 18.2,
"species_id": 42,
"wood_category_ids": [15, 23],
"edge_type_term_id": 8,
"figure_term_ids": [3],
"grade_term_ids": [7],
"thickness_term_id": 12,
"moisture_term_id": 5,
"fig_tag_ids": [99],
"feat_tag_ids": [42],
"price_per_bdft": 12.50,
"price": 12.00,
"price_source": "recommendation",
"title": None,
"short_title": None,
"description": None,
"content_source": None,
"client_rev": 1,
})
with TestClient(app) as client:
response = client.post(
"/api/v1/slabs",
files=[
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
],
data={
"data": data_json,
"meta[]": json.dumps({
"kind": "inventory",
"role": "topdown",
"seq": 1,
}),
},
)
assert response.status_code == 201
assert response.json()["status"] == "calibrated"
app.dependency_overrides.clear()
+140
View File
@@ -0,0 +1,140 @@
"""GC-4/GC-5: WOO_BASE_URL contract tests.
GC-4: WOO_BASE_URL is compose-only HTTPS; Settings GET returns it read-only;
PUT rejects write attempts with 422.
GC-5: FastAPI refuses to start when WOO_BASE_URL is missing or http://.
FUND-8: App factory creates fresh instance per test.
FUND-9: TestClient context manager manages lifespan.
FUND-10: dependency_overrides cleared after every test.
"""
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
class TestWooBaseUrlValidation:
"""GC-5: FastAPI refuses invalid WOO_BASE_URL at startup."""
def test_refuses_http_url(self) -> None:
"""GC-5: Settings.validate() raises ValueError for http:// URL."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="http://insecure.example.com",
)
with pytest.raises(ValueError, match="(?i)https"):
settings.validate()
def test_refuses_missing_url(self) -> None:
"""GC-5: Settings.validate() raises ValueError when woo_base_url is None."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
)
with pytest.raises(ValueError):
settings.validate()
def test_accepts_https_url(self) -> None:
"""GC-5: Settings.validate() accepts HTTPS URL."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://secure.example.com",
)
settings.validate() # Should not raise
class TestWooBaseUrlReadOnly:
"""GC-4: Settings endpoints handle woo_base_url correctly."""
def test_get_settings_returns_woo_base_url(self) -> None:
"""GC-4: GET /api/v1/settings returns woo_base_url from compose env."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert data["woo_base_url"] == "https://store.example.com"
app.dependency_overrides.clear()
def test_put_settings_rejects_woo_base_url(self) -> None:
"""GC-4: PUT /api/v1/settings rejects woo_base_url field with 422."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.put(
"/api/v1/settings",
json={"woo_base_url": "https://hacked.example.com"},
)
assert response.status_code == 422
data = response.json()
assert "detail" in data
app.dependency_overrides.clear()
def test_put_settings_allows_other_fields(self) -> None:
"""GC-4: PUT /api/v1/settings still accepts other settings fields."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.put(
"/api/v1/settings",
json={"woo_create_status": "publish"},
)
assert response.status_code == 200
data = response.json()
assert data["woo_create_status"] == "publish"
app.dependency_overrides.clear()
def test_get_settings_never_returns_app_password(self) -> None:
"""GC-4: GET /api/v1/settings never returns woo_app_password."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert "woo_app_password" not in data
app.dependency_overrides.clear()
def test_put_settings_rejects_consumer_keys(self) -> None:
"""GC-4: PUT /api/v1/settings rejects consumer key fields with 422."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.put(
"/api/v1/settings",
json={
"woo_consumer_key": "ck_test",
"woo_consumer_secret": "cs_test",
},
)
assert response.status_code == 422
app.dependency_overrides.clear()
-52
View File
@@ -1,52 +0,0 @@
"""TDD red tests for S1 crypto (QUALITY-125, SETTINGS-20/25)."""
import base64
import os
import tempfile
import pytest
from backend import crypto
def _valid_key_b64() -> str:
# 32 raw bytes base64
raw = b"0123456789abcdef0123456789abcdef" # exactly 32
return base64.b64encode(raw).decode()
def test_crypto_roundtrip_encrypt_decrypt(monkeypatch):
"""Given: valid SLAB_AES_KEY; When: encrypt then decrypt; Then: original value (QUALITY-125)."""
key_b64 = _valid_key_b64()
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
plaintext = "my-secret-app-password-123"
ct_b64 = crypto.encrypt(plaintext)
assert isinstance(ct_b64, str)
recovered = crypto.decrypt(ct_b64)
assert recovered == plaintext
def test_crypto_decrypt_tampered_raises_clean_error(monkeypatch):
"""Given: valid key; When: tamper the blob (flip a byte); Then: raises detectable error, no crash, no raw leak."""
key_b64 = _valid_key_b64()
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
plaintext = "secret"
ct_b64 = crypto.encrypt(plaintext)
# tamper: flip last byte
blob = bytearray(base64.b64decode(ct_b64))
blob[-1] ^= 0xFF
tampered_b64 = base64.b64encode(blob).decode()
with pytest.raises(Exception) as exc: # will be specific like InvalidTag or our wrapper
crypto.decrypt(tampered_b64)
# must not contain the raw blob or secret
msg = str(exc.value).lower()
assert "secret" not in msg
assert plaintext not in msg
# detectable, e.g. contains "decrypt" or "tag" or "corrupt"
assert any(k in msg for k in ("decrypt", "tag", "invalid", "corrupt", "fail"))
def test_crypto_missing_key_raises(monkeypatch):
"""Given: no SLAB_AES_KEY; When: encrypt; Then: clear runtime error."""
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
crypto.encrypt("x")
-131
View File
@@ -1,131 +0,0 @@
"""Exit wave (QUALITY-20, QUALITY-120): full create-to-publish happy path with
inference OFF, asserting zero inference calls and no raw codes reach the owner.
"""
import base64
import json
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
@pytest.fixture
def env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="exit_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "e.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
conn = db_mod.init_db()
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("pw"),))
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','false',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','false',NULL)")
from datetime import datetime, timezone
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
(datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
conn.commit()
from backend.app import app
yield TestClient(app), conn
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def _mock(monkeypatch, sku_exists=0, product_id=777):
from backend.woo import WooClient
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, json.dumps([{"id": 1}] * sku_exists).encode()
if url.endswith("/wp-json/wp/v2/media"):
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products"):
return 201, {}, json.dumps({"id": product_id}).encode()
return 200, {}, b"[]"
orig = WooClient.__init__
def patched(self, *a, **k):
orig(self, *a, **k)
def bound(url, method, data, headers):
return fake_request(url, method, data, headers)
self._request = bound
monkeypatch.setattr(WooClient, "__init__", patched)
def test_full_happy_path_inference_off(env, monkeypatch):
"""Create slab -> attach photo -> review -> publish, inference OFF.
Zero inference_log rows; all responses carry plain messages (QUALITY-120)."""
client, conn = env
_mock(monkeypatch)
# 1. create slab
r = client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-UAT-7", "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
"bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
"species_id": 1, "wood_category_ids": [2], "edge_type_term_id": 1, "figure_term_ids": [],
"grade_term_id": 1, "thickness_term_id": 1, "moisture_term_id": 1,
"fig_tag_ids": [], "feat_tag_ids": [], "price": 184.0, "price_source": "recommendation",
"title": "Black Walnut Live Edge Slab", "short_description": "slab, 9ft 6in",
"description": "A fine slab.", "content_source": "manual",
"species_confidence": None, "figure_confidence": None, "grade_confidence": None,
"inference_status": None, "calibration_photo_id": None,
})
assert r.status_code in (201, 200), r.text
# 2. attach photo
r = client.post("/api/v1/slabs/s1/photos", data={"photo_id": "p1"},
files={"original": ("o.png", b"\x89PNG", "image/png")})
assert r.status_code == 201, r.text
# 3. review (deterministic copy)
r = client.post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200
# 4. publish
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True and body["woo_product_id"] == 777 and body["draft"] is True
# QUALITY-20: zero inference calls in off mode
assert conn.execute("SELECT COUNT(*) c FROM inference_log").fetchone()["c"] == 0
# QUALITY-120: response has no raw status codes / stack traces in owner-facing message
assert "Traceback" not in json.dumps(body)
# success log
assert conn.execute("SELECT status FROM sync_log").fetchone()["status"] == "success"
def test_not_ready_publish_422_plain_message(env, monkeypatch):
"""Failure path returns a plain field-level message, no raw code (QUALITY-120)."""
client, conn = env
_mock(monkeypatch)
client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-01", "length_in": 100, "thickness_in": 2, "sqft": 20, "bdft": 8,
"width_min_in": 10, "width_max_in": 12, "width_avg_in": 11, "species_id": 1,
"wood_category_ids": [2], "edge_type_term_id": 1, "figure_term_ids": [], "grade_term_id": 1,
"thickness_term_id": 1, "moisture_term_id": 1, "fig_tag_ids": [], "feat_tag_ids": [],
"price": None, "price_source": None, "title": "T", "short_description": "S",
"description": "D", "content_source": "manual",
"species_confidence": None, "figure_confidence": None, "grade_confidence": None,
"inference_status": None, "calibration_photo_id": None,
})
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 422
body = r.json()
assert "missing" in body["error"]["detail"]
assert "Traceback" not in json.dumps(body)
assert "price" in body["error"]["detail"]["missing"]
-73
View File
@@ -1,73 +0,0 @@
"""TDD red tests for S0 health endpoint and startup guards (APP-30, APP-45, SETTINGS-10, SETTINGS-25)."""
import importlib
import os
import tempfile
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _reload_app():
import backend.app as app_mod
return importlib.reload(app_mod)
def test_health_happy_path_returns_200_with_three_fields(monkeypatch):
"""Given: writable db + image dir; When: GET /api/health; Then: 200 + {status, version, woo_reachable}."""
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
monkeypatch.setenv("LOG_LEVEL", "INFO")
from backend.app import app
client = TestClient(app)
resp = client.get("/api/health")
assert resp.status_code == 200
body = resp.json()
assert set(body.keys()) == {"status", "version", "woo_reachable"}
assert body["status"] == "ok"
db_mod._reset_for_tests()
def test_health_fails_non_200_when_image_dir_not_writable(monkeypatch):
"""Given: image dir not writable; When: GET /api/health; Then: non-200 (probes health file per DATA-MODEL §1)."""
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
# force probe fail without relying on chmod (win32 owner can still write)
with patch("backend.app.probe_images_writable", return_value=False):
from backend.app import app
client = TestClient(app)
resp = client.get("/api/health")
assert resp.status_code != 200
db_mod._reset_for_tests()
def test_startup_guard_refuses_on_missing_or_non_https_woo_base_url(monkeypatch):
"""Given: WOO_BASE_URL missing or not https; When: import/create app; Then: raises with clear message (SETTINGS-10)."""
monkeypatch.delenv("WOO_BASE_URL", raising=False)
with pytest.raises(RuntimeError, match="WOO_BASE_URL"):
_reload_app()
monkeypatch.setenv("WOO_BASE_URL", "http://insecure.example.com")
with pytest.raises(RuntimeError, match="https"):
_reload_app()
def test_startup_guard_refuses_on_missing_slab_aes_key(monkeypatch):
"""Given: SLAB_AES_KEY missing; When: import/create app; Then: raises with clear message (SETTINGS-25)."""
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
_reload_app()
-641
View File
@@ -1,641 +0,0 @@
"""S7 inference tests (METADATA-105/106, 45, 55, 70, SETTINGS-45)."""
import base64
import json
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
@pytest.fixture
def env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="inf_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://x.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
db_mod._reset_for_tests()
from backend.crypto import encrypt
conn = db_mod.init_db()
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_base_url',NULL,?)", (encrypt("https://infer.example.com"),))
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_api_key',NULL,?)", (encrypt("sk-test"),))
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_model','gpt-test',NULL)")
conn.commit()
from backend.app import app
yield TestClient(app), conn
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def test_prompt_seed_and_revision(env):
client, conn = env
r = client.get("/api/v1/prompts/call1")
assert r.status_code == 200
body = r.json()
assert body["active_content"] is not None
assert len(body["revisions"]) == 1 # seed only
r = client.post("/api/v1/prompts/call1/revisions", json={"content": "revised prompt v2"})
assert r.status_code == 201
new_rev = r.json()["id"]
# new revision becomes active
r = client.get("/api/v1/prompts/call1")
assert r.json()["active_revision_id"] == new_rev
assert r.json()["active_content"] == "revised prompt v2"
assert len(r.json()["revisions"]) == 2
# activate back to seed
seed_rev = 1
r = client.post("/api/v1/prompts/call1/activate", json={"revision_id": seed_rev})
assert r.status_code == 200
assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None
def test_call1_seed_is_locked_expert_prompt(env):
"""#86: shipped Call 1 seed is the locked expert prompt (no free-text bags, no grade)."""
client, _conn = env
from backend.inference import CALL1_SEED
body = client.get("/api/v1/prompts/call1").json()
assert body["active_content"] == CALL1_SEED
assert "wood identification expert" in CALL1_SEED
assert "feature_tag_ids" in CALL1_SEED
assert "Decide wood categories before edge type" in CALL1_SEED
assert "highest-confidence" in CALL1_SEED
assert "Wild edge" in CALL1_SEED
assert "cookies and burl" in CALL1_SEED.lower() or "cookies and burl" in CALL1_SEED
assert "grade_term_id" not in CALL1_SEED
assert '"character"' not in CALL1_SEED
assert '"inclusions"' not in CALL1_SEED
assert "character\":" not in CALL1_SEED.replace(" ", "")
def test_call2_seed_has_short_long_structure(env):
"""Call 2 seed: plain-text short+long, woodworker tone, JSON dual output (not brand voice)."""
client, _conn = env
from backend.inference import CALL2_SEED
body = client.get("/api/v1/prompts/call2").json()
assert body["active_content"] == CALL2_SEED
assert "SHORT DESCRIPTION" in CALL2_SEED
assert "LONG DESCRIPTION" in CALL2_SEED
assert "PLAIN TEXT ONLY" in CALL2_SEED
assert "plain text only" in CALL2_SEED.lower()
assert "No HTML tags" in CALL2_SEED
assert "woodworkers choosing stock" in CALL2_SEED
assert "elevate" in CALL2_SEED # banned-phrase guidance
assert "stunning" in CALL2_SEED
assert "Lead with a practical outcome" in CALL2_SEED
assert "Hook" in CALL2_SEED
assert "Brand voice and Geography sections when provided" in CALL2_SEED
assert "Whidbey" not in CALL2_SEED
assert "Pacific Northwest" not in CALL2_SEED
assert '"short_description"' in CALL2_SEED
assert '"description"' in CALL2_SEED
assert "<HTML long description>" not in CALL2_SEED
assert "plain text long description" in CALL2_SEED
# No Full-specs / attribute metadata dump in long desc (specs live on Woo fields).
assert "Hook + Benefits only" in CALL2_SEED
assert "Do NOT restate DECLARED" in CALL2_SEED
assert 'no "Full specs"' in CALL2_SEED or "Do NOT add a Full specs" in CALL2_SEED
assert "Category:/Figure:/Edge:" in CALL2_SEED
assert "1–2 key traits" in CALL2_SEED
assert "labeled catalog" in CALL2_SEED or "labeled metadata list" in CALL2_SEED
# Must not instruct a Full-specs numbered part (old 4-part structure).
assert "3. Full specs" not in CALL2_SEED
assert "Four parts, in order" not in CALL2_SEED
def test_taxonomy_lists_call1_closed_shape():
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
import tempfile, os, shutil
tmp = tempfile.mkdtemp(prefix="tax_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ.setdefault("WOO_BASE_URL", "https://x.com")
os.environ.setdefault(
"SLAB_AES_KEY",
base64.b64encode(b"0123456789abcdef0123456789abcdef").decode(),
)
db_mod._reset_for_tests()
conn = db_mod.init_db()
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (10,'Species','species',NULL,10)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Walnut','walnut',10,11)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (20,'Wcat','wcat',NULL,20)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (21,'Live edge wood','liveedge',20,21)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (6,'Edge type','pa_etype',6)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Figure','pa_fig',8)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Grade','pa_gr',9)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Thickness','pa_thk',10)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (7,'Moisture','pa_mst',7)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (321,6,'Square edge','sqe',321)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (328,8,'Plain','pl',328)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (342,9,'Fancy','fnc',342)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (350,10,'1\"- 1 1/2\"','4-6qtr',350)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,7,'Kiln dried','kd',326)")
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)")
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (2,'fig-curl','fig-curl',2)")
conn.commit()
from backend.inference import _taxonomy_lists
tax = _taxonomy_lists(conn)
assert set(tax.keys()) == {"species", "wood_categories", "edge_terms", "figure_terms", "feature_tags"}
assert tax["species"] == [{"id": 11, "name": "Walnut"}]
assert tax["wood_categories"] == [{"id": 21, "name": "Live edge wood"}]
assert tax["edge_terms"] == [{"id": 321, "name": "Square edge"}]
assert tax["figure_terms"] == [{"id": 328, "name": "Plain"}]
# feat- prefix stripped for the model; fig- tags are not feature_tags
assert tax["feature_tags"] == [{"id": 1, "name": "knot"}]
assert "grade_terms" not in tax
assert "thickness_terms" not in tax
assert "moisture_terms" not in tax
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def test_gate_feature_tag_ids_not_free_text():
"""#86: gate keeps feature_tag_ids at threshold; drops free-text bags and grade."""
from backend.inference import _gate
parsed = {
"species_id": 11, "species_confidence": 0.9,
"wood_category_ids": [21], "wood_category_confidence": 0.8,
"edge_type_term_id": 321, "edge_confidence": 0.85,
"figure_term_ids": [328, 335], "figure_confidence": 0.75,
"feature_tag_ids": [1], "feature_tag_confidence": 0.8,
"grade_term_id": 342, "grade_confidence": 0.99,
"character": ["rustic"], "inclusions": ["bark"], "voids": [], "checks": ["end"],
}
out = _gate(parsed)
assert out["species_id"] == 11
assert out["wood_category_ids"] == [21]
assert out["edge_type_term_id"] == 321
assert out["figure_term_ids"] == [328, 335]
assert out["feature_tag_ids"] == [1]
assert "grade_term_id" not in out
assert "character" not in out
assert "inclusions" not in out
assert "voids" not in out
assert "checks" not in out
# below threshold feature tags dropped
low = {**parsed, "feature_tag_confidence": 0.2}
assert "feature_tag_ids" not in _gate(low)
def test_call1_hard_disabled_410(env, monkeypatch):
"""overnight-whole-app: Call 1 OUT — POST always 410, never chats the model."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
from backend.inference import InferenceClient
called = {"n": 0}
def fake_chat(self, messages):
called["n"] += 1
return "{}"
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
r = client.post("/api/v1/slabs/s1/inference/call1")
assert r.status_code == 410, r.text
body = r.json()
assert body["error"]["code"] == "call1_disabled"
assert called["n"] == 0
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
def test_call2_stores_short_and_description(env, monkeypatch):
"""Call 2 persists both fields as plain text (HTML from model is stripped)."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
"status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','old short','ready',1,1,'t','t')"
)
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
from backend.inference import InferenceClient
import json as _json
payload = _json.dumps({
"short_description": "Kiln-dried walnut live-edge stock for a dining table — clear midspan, usable width. Ready to finish.",
"description": "<p><strong>Dining table stock.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>",
})
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: payload)
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
body = r.json()
assert "Dining table stock" in body["description"]
assert "kiln dried" in body["description"]
assert "<p>" not in body["description"]
assert "<strong>" not in body["description"]
assert body["short_description"].startswith("Kiln-dried walnut")
row = conn.execute(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone()
assert row["short_description"].startswith("Kiln-dried walnut")
assert "<p>" not in row["description"]
assert "- Stable:" in row["description"] or "Stable:" in row["description"]
assert row["content_source"] == "llm"
def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
"""Non-JSON Call 2 body becomes plain-text description; prior short is left alone."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
"status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','keep me','ready',1,1,'t','t')"
)
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
from backend.inference import InferenceClient
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>A fine walnut slab.</p>")
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
assert r.json()["description"] == "A fine walnut slab."
assert "short_description" not in r.json()
row = conn.execute(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone()
assert row["short_description"] == "keep me"
assert row["description"] == "A fine walnut slab."
assert row["content_source"] == "llm"
def test_parse_call2_response_json_and_fenced():
from backend.inference import parse_call2_response, _html_to_plain
import json as _json
short, desc = parse_call2_response(
_json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "})
)
assert short == "Benefit first."
assert desc == "Long"
fenced = "```json\n" + _json.dumps({"short_description": "S", "description": "<p>D</p>"}) + "\n```"
short2, desc2 = parse_call2_response(fenced)
assert short2 == "S"
assert desc2 == "D"
short3, desc3 = parse_call2_response("<p>only long</p>")
assert short3 is None
assert desc3 == "only long"
# Defensive strip: lists become "- " bullets; entities unescaped; no tags left.
plain = _html_to_plain(
"<p>Hook.</p><ul><li><strong>Stable:</strong> kiln dried</li>"
"<li>Live edge</li></ul><p>Trust &amp; ship.</p>"
)
assert "<" not in plain
assert "Hook." in plain
assert "- Stable: kiln dried" in plain
assert "- Live edge" in plain
assert "Trust & ship." in plain
# Already-plain text is unchanged aside from trim.
assert _html_to_plain(" Kiln-dried walnut for a bar top. ") == "Kiln-dried walnut for a bar top."
def test_strip_attribute_dump_trailing_and_header():
"""Defensive: drop labeled Category:/Length: dumps from long description."""
from backend.inference import _strip_attribute_dump, parse_call2_response
import json as _json
hook_benefits = (
"Kiln-dried maple suited to a compact bar top.\n"
"- Stable stock: kiln dried, ready to mill.\n"
"- Live edge on one side for a natural apron.\n"
"- Character grade with usable burl figure."
)
dump = (
"Maple Slab\n"
"- Category: Live edge wood\n"
"- Figure: Burl, Curl, Figured\n"
"- Edge: Live Edge 1 side\n"
"- Grade: Character\n"
"- Moisture: Kiln dried\n"
"- Length: 45.0in\n"
"- Board Feet: 12.4"
)
trust = "Ships from the mill; ask about returns in Brand voice."
full = f"{hook_benefits}\n\n{dump}\n\n{trust}"
cleaned = _strip_attribute_dump(full)
assert "Category:" not in cleaned
assert "Board Feet:" not in cleaned
assert "Maple Slab" not in cleaned
assert "Kiln-dried maple" in cleaned
assert "Live edge on one side" in cleaned
assert "Ships from the mill" in cleaned
# Mid-body Full specs header also stripped; benefits bullets without Label: kept.
mid = (
"Hook line.\n"
"- Good for a bench seat.\n"
"\n"
"Full specs\n"
"- Species: Maple\n"
"- Length: 45.0in\n"
"- Thickness: 2.0in\n"
"\n"
"Neutral trust close."
)
mid_clean = _strip_attribute_dump(mid)
assert "Full specs" not in mid_clean
assert "Species:" not in mid_clean
assert "Good for a bench seat" in mid_clean
assert "Neutral trust close" in mid_clean
# Fewer than 3 attr lines: leave alone (not a dump).
short_list = "Hook.\n- Length: 45.0in\n- Thickness: 2in\nTrust."
assert _strip_attribute_dump(short_list) == short_list.strip()
# parse_call2_response applies strip on description (not short).
payload = _json.dumps({
"short_description": "Short with - Length: 1in - Width: 2in - Thickness: 3in ok",
"description": full,
})
short, desc = parse_call2_response(payload)
assert short and "Length: 1in" in short # short untouched by attr-dump strip
assert "Category:" not in desc
assert "Board Feet:" not in desc
assert "Kiln-dried maple" in desc
def test_call1_disabled_even_when_inference_off(env):
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01','calibrated',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/inference/call1")
assert r.status_code == 410
assert r.json()["error"]["code"] == "call1_disabled"
def test_server_description_inference_off(env):
from backend.inference import server_description
d = server_description({"title": "Figured Walnut Slab", "length_in": 114.0, "width_avg_in": 24.0, "thickness_in": 1.75, "bdft": 18.4})
assert "114.0in" in d and "18.4" in d
def test_test_inference_unconfigured(env):
client, conn = env
conn.execute("UPDATE settings SET value_enc=NULL WHERE key='inference_api_key'")
conn.commit()
r = client.post("/api/v1/admin/test-inference")
assert r.status_code == 200
assert r.json()["ok"] is False and r.json()["mode"] == "fail"
def _rgba_png(w: int, h: int) -> bytes:
"""RGBA PNG: opaque body + transparent top-left block (capture-like alpha)."""
from PIL import Image
import io
im = Image.new("RGBA", (w, h), (10, 20, 30, 255))
for y in range(min(80, h)):
for x in range(min(80, w)):
im.putpixel((x, y), (0, 0, 0, 0))
buf = io.BytesIO()
im.save(buf, format="PNG")
return buf.getvalue()
def test_fit_long_edge_png_downscales_over_1024():
"""METADATA-35: Call 1 images long edge <= 1024; never upscale; keep alpha."""
from backend.inference import fit_long_edge_png
from PIL import Image
import io
src = _rgba_png(800, 1067) # inventory 3:4, long edge 1067
out = fit_long_edge_png(src, max_long_edge=1024)
im = Image.open(io.BytesIO(out)).convert("RGBA")
assert max(im.size) == 1024
assert min(im.size) < 1024
w, h = im.size
assert abs((w / h) - (800 / 1067)) < 0.02
# Transparent block still transparent near origin after downscale
assert im.getpixel((0, 0))[3] == 0
assert im.getpixel((5, 5))[3] == 0
def test_fit_long_edge_png_leaves_small_unchanged_dims():
from backend.inference import fit_long_edge_png
from PIL import Image
import io
src = _rgba_png(400, 300)
out = fit_long_edge_png(src, max_long_edge=1024)
im = Image.open(io.BytesIO(out))
assert im.size == (400, 300)
def test_redact_request_for_log_strips_data_urls_keeps_text():
"""#87: log sanitizer replaces data-URL images with photo refs; keeps text."""
from backend.inference import _redact_request_for_log
b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64 * 20}"}},
{"type": "text", "text": "LISTS:\n{\"species\":[]}\n\nSKU=X length_in=1 thickness_in=2"},
],
}]
meta = [
{"photo_id": "p1", "seq": 1, "bytes": 100, "infer_long_edge_px": 64},
{"photo_id": "p2", "seq": 2, "bytes": 200, "infer_long_edge_px": 128},
]
out = _redact_request_for_log(messages, meta)
blob = json.dumps(out)
assert "data:image" not in blob
assert "base64" not in blob.lower()
assert "LISTS:" in blob
assert "SKU=X" in blob
parts = out[0]["content"]
assert parts[0]["type"] == "image_ref"
assert parts[0]["photo_id"] == "p1"
assert parts[0]["seq"] == 1
assert parts[1]["photo_id"] == "p2"
assert parts[2]["type"] == "text"
assert "LISTS:" in parts[2]["text"]
def test_call1_log_skipped_while_disabled(env):
"""#87 Call 1 log path dormant while Call 1 is hard-disabled (overnight-whole-app)."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
)
conn.commit()
r = client.post("/api/v1/slabs/s1/inference/call1")
assert r.status_code == 410
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
def test_call2_log_redacts_images(env, monkeypatch):
"""#87: Call 2 log also redacts image data-URLs."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut','ready',1,1,'t','t')"
)
png = _rgba_png(200, 200)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p2','s1',1,?, 't')",
(png,),
)
conn.commit()
from backend.inference import InferenceClient
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>ok</p>")
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
payload = conn.execute(
"SELECT request_payload FROM inference_log WHERE call=2 ORDER BY id DESC LIMIT 1"
).fetchone()["request_payload"]
assert "data:image" not in payload
assert "image_ref" in payload
assert "p2" in payload
assert "DIMENSION FACT BLOCK" in payload
def test_call1_sends_long_edge_dormant_while_disabled(env):
"""METADATA-35 Call 1 sizing path dormant while Call 1 is hard-disabled."""
client, _conn = env
r = client.post("/api/v1/slabs/missing/inference/call1")
assert r.status_code == 410
assert r.json()["error"]["code"] == "call1_disabled"
def test_assemble_call2_includes_declared_attrs_and_dims(env):
"""Call 2 text = seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; shared assembler."""
client, conn = env
conn.execute(
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES "
"(10,'Species','species',NULL,10),"
"(11,'Black Walnut','black-walnut',10,11),"
"(20,'Wcat','wcat',NULL,20),"
"(21,'Slabs & Lumber','snl',20,21),"
"(22,'Live edge wood','liveedge',21,22)"
)
conn.execute(
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES "
"(6,'Edge type','pa_etype',6),(8,'Figure','pa_fig',8),"
"(9,'Grade','pa_gr',9),(7,'Moisture','pa_mst',7)"
)
conn.execute(
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES "
"(321,6,'Live Edge 1 side','le1',321),"
"(328,8,'Curl','curl',328),"
"(342,9,'Fancy','fnc',342),"
"(326,7,'Kiln dried','kd',326)"
)
conn.execute(
"INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)"
)
conn.execute(
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
"('brand_voice','Warm and precise.',NULL),"
"('geo_context','Pacific Northwest mill.',NULL)"
)
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,moisture_term_id,"
"feat_tag_ids,title,short_description,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',114.0,1.75,24.3,18.0,24.3,18.4,11,?,321,?,342,326,?,"
"'Figured Black Walnut Live Edge Slab','short line','ready',1,1,'t','t')",
(json.dumps([22]), json.dumps([328]), json.dumps([1])),
)
png = _rgba_png(200, 200)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p9','s1',1,?, 't')",
(png,),
)
conn.commit()
from backend.inference import assemble_call2, assemble_call2_text, CALL2_SEED
assembled = assemble_call2(conn, "s1")
assert assembled is not None
text = assembled["text"]
assert "DECLARED ATTRIBUTES" in text
assert "species: Black Walnut" in text
assert "primary wood category: Live edge wood" in text
assert "edge: Live Edge 1 side" in text
assert "figure: Curl" in text
assert "grade: Fancy" in text
assert "moisture: Kiln dried" in text
assert "features: knot" in text
assert "DIMENSION FACT BLOCK" in text
assert "length=114.0in" in text
assert "Brand voice: Warm and precise." in text
assert "Geo: Pacific Northwest mill." in text
assert CALL2_SEED.split("\n")[0] in text
assert "SHORT DESCRIPTION" in text
assert "LONG DESCRIPTION" in text
assert "short_description" in CALL2_SEED
assert '"description"' in CALL2_SEED or "\"description\"" in CALL2_SEED
# Prior short must not be fed back into the prompt (Call 2 writes it).
assert "short_description: short line" not in text
assert "Write BOTH" in CALL2_SEED or "BOTH the short" in CALL2_SEED
# No raw base64 in View-prompt shape
assert "photos" in assembled
assert assembled["photos"][0]["id"] == "p9"
assert assembled["photos"][0]["seq"] == 1
assert assembled["photos"][0]["bytes"] > 0
assert "base64" not in json.dumps(assembled["photos"])
# View prompt GET mirrors assembler (no model)
r = client.get("/api/v1/slabs/s1/inference/call2/prompt")
assert r.status_code == 200, r.text
body = r.json()
assert body["text"] == text
assert body["photos"][0]["id"] == "p9"
assert "data:image" not in json.dumps(body)
def test_call2_post_uses_same_assembler_text(env, monkeypatch):
"""POST call2 sends the same assembled text View prompt returns."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute(
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
"('brand_voice','voice',NULL),('geo_context','geo',NULL)"
)
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
"title,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,20,18,22,18,'Walnut Slab','ready',1,1,'t','t')"
)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
(_rgba_png(64, 64),),
)
conn.commit()
from backend.inference import InferenceClient, assemble_call2
expected = assemble_call2(conn, "s1")["text"]
captured = {}
def fake_chat(self, messages):
captured["text"] = messages[0]["content"][-1]["text"]
return "<p>ok</p>"
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
assert captured["text"] == expected
assert "DECLARED ATTRIBUTES" in captured["text"]
assert "DIMENSION FACT BLOCK" in captured["text"]
-151
View File
@@ -1,151 +0,0 @@
"""Integration (QUALITY-35): Woo mock covering success, error, duplicate SKU,
stale field, and taxonomy anchor.
"""
import base64
import json
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _mock(monkeypatch, *, product_ok=True, sku_count=0, cat_subset="A", media_ok=True):
from backend.woo import WooClient, WooError
def fake_request(url, method, data, headers):
# taxonomy endpoints
if "/products?sku=" in url:
return 200, {}, json.dumps([{"id": i} for i in range(sku_count)]).encode()
if url.endswith("/wp-json/wp/v2/media") and media_ok:
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
if product_ok:
return 201, {}, json.dumps({"id": 555}).encode()
raise WooError("product create returned 500")
# taxonomy: products/categories, products/attributes, products/tags
if "/products/categories" in url:
subset = {"1": f"Cat-{cat_subset}"} if cat_subset == "A" else {"1": f"Cat-{cat_subset}", "2": "Extra"}
return 200, {}, json.dumps([{"id": 1, "name": f"Cat-{cat_subset}", "slug": f"c-{cat_subset}", "parent": 0}]).encode()
if "/products/attributes" in url:
return 200, {}, json.dumps([{"id": 8, "name": "Edge Type", "slug": "edge-type"}]).encode()
if "/products/attributes/8/terms" in url:
return 200, {}, json.dumps([{"id": 1, "name": "Flat", "slug": "flat"}]).encode()
if "/products/tags" in url:
return 200, {}, json.dumps([{"id": 1, "name": "Figured", "slug": "fig-f"}, {"id": 2, "name": "Knot", "slug": "feat-knot"}]).encode()
return 200, {}, b"[]"
orig = WooClient.__init__
def patched(self, *a, **k):
orig(self, *a, **k)
def bound(url, method, data, headers):
return fake_request(url, method, data, headers)
self._request = bound
monkeypatch.setattr(WooClient, "__init__", patched)
@pytest.fixture
def env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="integ_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
conn = db_mod.init_db()
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("pw"),))
from datetime import datetime, timezone
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
(datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
conn.commit()
from backend.app import app
yield TestClient(app), conn
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def test_taxomony_anchor_bumps_on_change(env, monkeypatch):
from backend.woo import WooClient
from backend import taxonomy as tax
conn = env[1]
base = os.environ["WOO_BASE_URL"]
user = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0]
enc = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
_mock(monkeypatch, cat_subset="A")
client1 = WooClient.from_settings(base, user, enc)
r1 = tax.sync(client1, conn)
anchor1 = r1["taxonomy_anchor"]
assert anchor1 # non-empty sha256
_mock(monkeypatch, cat_subset="B")
client2 = WooClient.from_settings(base, user, enc)
r2 = tax.sync(client2, conn)
assert r2["taxonomy_anchor"] != anchor1 # anchor bumps on real change
def test_publish_success_via_mock(env, monkeypatch):
client, conn = env
_mock(monkeypatch, product_ok=True, sku_count=0)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-UAT-9',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert r.json()["woo_product_id"] == 555
def test_publish_error_via_mock(env, monkeypatch):
client, conn = env
_mock(monkeypatch, product_ok=False)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-E',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 502
assert r.json()["error"]["detail"]["recoverable"] is True
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
def test_duplicate_sku_via_mock(env, monkeypatch):
client, conn = env
_mock(monkeypatch, sku_count=1)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-DUP',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 409
assert r.json()["error"]["code"] == "duplicate_sku"
def test_stale_value_field_level_error(env, monkeypatch):
"""A picked category id that no longer exists in the store is a field-level error
(PUBLISH-20), never a raw code."""
client, conn = env
_mock(monkeypatch, product_ok=True)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
# wood_category_ids references category 99 which does not exist in the store -> stale
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-STALE',114,1.75,30,18.4,18,24.3,24.3,1,'[99]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
# publish proceeds (stale category id resolved to nothing) or errors; never a raw code/traceback
assert r.status_code in (200, 409, 422, 502)
assert "Traceback" not in r.text
-178
View File
@@ -1,178 +0,0 @@
"""Prototype: X-Woo-* headers required on HTTP Woo routes; never clobber Settings."""
import base64
import os
import shutil
import tempfile
import pytest
from starlette.datastructures import Headers
import backend.db as db_mod
from backend.crypto import encrypt, decrypt
from backend.woo import (
WooClient,
WooDeviceCredsRequired,
client_for_request,
request_woo_override,
settings_woo_creds,
HEADER_WOO_USERNAME,
HEADER_WOO_APP_PASSWORD,
)
@pytest.fixture
def db_dir():
tmp = tempfile.mkdtemp(prefix="per_user_woo_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ.pop("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", None)
db_mod._reset_for_tests()
conn = db_mod.init_db()
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_wp_username','shop-default',NULL)"
)
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_app_password',NULL,?)",
(encrypt("shop-secret"),),
)
conn.commit()
yield conn
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def _auth_pair(client: WooClient) -> tuple[str, str]:
raw = base64.b64decode(client._auth).decode("utf-8")
user, password = raw.split(":", 1)
return user, password
def test_override_headers_produce_distinct_auth_without_clobbering_settings(db_dir):
conn = db_dir
before = settings_woo_creds(conn)
assert before[1] == "shop-default"
assert decrypt(before[2]) == "shop-secret"
h_a = Headers({HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "alice-app-pw"})
h_b = Headers({HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "bob-app-pw"})
client_a = client_for_request(conn, h_a)
client_b = client_for_request(conn, h_b)
assert _auth_pair(client_a) == ("alice", "alice-app-pw")
assert _auth_pair(client_b) == ("bob", "bob-app-pw")
assert client_a._auth != client_b._auth
after = settings_woo_creds(conn)
assert after == before
assert decrypt(after[2]) == "shop-secret"
row_u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0]
row_p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
assert row_u == "shop-default"
assert decrypt(row_p) == "shop-secret"
def test_missing_headers_raise_device_creds_required(db_dir):
conn = db_dir
with pytest.raises(WooDeviceCredsRequired):
client_for_request(conn, Headers({}))
with pytest.raises(WooDeviceCredsRequired):
client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"}))
assert request_woo_override(Headers({HEADER_WOO_USERNAME: "alice"})) is None
def test_env_escape_hatch_allows_settings_fallback(db_dir, monkeypatch):
conn = db_dir
monkeypatch.setenv("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "1")
client = client_for_request(conn, Headers({}))
assert _auth_pair(client) == ("shop-default", "shop-secret")
def test_test_woo_endpoint_requires_headers(db_dir, monkeypatch):
"""API: POST /settings/test-woo with X-Woo-* builds client; missing → 401."""
from fastapi.testclient import TestClient
from backend import app as app_mod
seen: list[str] = []
def fake_sync(client, conn):
seen.append(_auth_pair(client)[0])
return {"taxonomy_anchor": "x", "taxonomy_last_sync_at": "2026-01-01T00:00:00"}
monkeypatch.setattr(app_mod.taxonomy_mod, "sync", fake_sync)
tc = TestClient(app_mod.app)
r1 = tc.post(
"/api/v1/settings/test-woo",
headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw-a"},
)
r2 = tc.post(
"/api/v1/settings/test-woo",
headers={HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "pw-b"},
)
r3 = tc.post("/api/v1/settings/test-woo")
assert r1.status_code == 200 and r1.json()["ok"] is True
assert r2.status_code == 200 and r2.json()["ok"] is True
assert r3.status_code == 401
assert r3.json()["error"]["code"] == "woo_device_creds_required"
assert seen == ["alice", "bob"]
assert settings_woo_creds(db_dir)[1] == "shop-default"
def test_taxonomy_sync_requires_headers(db_dir, monkeypatch):
from fastapi.testclient import TestClient
from backend import app as app_mod
monkeypatch.setattr(
app_mod.taxonomy_mod,
"sync",
lambda client, conn: {
"taxonomy_anchor": "x",
"taxonomy_last_sync_at": "2026-01-01T00:00:00",
},
)
tc = TestClient(app_mod.app)
missing = tc.post("/api/v1/admin/taxonomy/sync")
assert missing.status_code == 401
assert missing.json()["error"]["code"] == "woo_device_creds_required"
ok = tc.post(
"/api/v1/admin/taxonomy/sync",
headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw"},
)
assert ok.status_code == 200 and ok.json()["ok"] is True
def test_publish_requires_headers(db_dir):
"""POST publish without X-Woo-* → 401 woo_device_creds_required."""
from fastapi.testclient import TestClient
from backend import app as app_mod
import json
conn = db_dir
conn.execute(
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)"
)
conn.execute(
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)"
)
conn.execute(
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)"
)
conn.execute(
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)"
)
conn.execute(
"""INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,
species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,
moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,
content_source,status,server_rev,client_rev,created_at,updated_at) VALUES
('s1','SLAB-01',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[1]',1,1,1,'[]','[]',184,
'recommendation','T','S','D','manual','ready',1,1,'t','t')"""
)
conn.commit()
tc = TestClient(app_mod.app)
r = tc.post("/api/v1/slabs/s1/publish")
assert r.status_code == 401, r.text
assert r.json()["error"]["code"] == "woo_device_creds_required"
-135
View File
@@ -1,135 +0,0 @@
"""S3 photos BLOB storage tests: multipart upload, original/processed GET, 410 after purge."""
import base64
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
@pytest.fixture
def photos_env():
tmp = tempfile.mkdtemp(prefix="photos_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
db_mod._reset_for_tests()
from backend.app import app
conn = db_mod.init_db()
# seed a slab
conn.execute("INSERT INTO slabs(id, sku, status, server_rev, created_at, updated_at) VALUES ('s1','SLAB-01','calibrated',1,'t','t')")
conn.commit()
yield TestClient(app), conn
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
PNG_ORIG = b"\x89PNG-orig-bytes"
PNG_PROC = b"\x89PNG-proc-bytes"
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
files = {"original": ("orig.png", original, "image/png")}
if processed is not None:
files["processed"] = ("proc.png", processed, "image/png")
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
if seq is not None:
data["seq"] = str(seq)
return client.post(
f"/api/v1/slabs/{slab_id}/photos",
data=data,
files=files,
)
def test_upload_and_get_original_processed(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "p1")
assert r.status_code == 201, r.text
body = r.json()
assert body["original_retained"] is True and body["processed_retained"] is True
r = client.get("/api/v1/slabs/s1/photos/p1/original")
assert r.status_code == 200
assert r.content == PNG_ORIG
assert r.headers["content-type"] == "image/png"
r = client.get("/api/v1/slabs/s1/photos/p1/processed")
assert r.status_code == 200
assert r.content == PNG_PROC
def test_processed_410_when_purged(photos_env):
client, conn = photos_env
_multipart(client, "s1", "p2")
# simulate purge (publish clears blobs)
conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE id='p2'")
conn.commit()
assert client.get("/api/v1/slabs/s1/photos/p2/original").status_code == 410
assert client.get("/api/v1/slabs/s1/photos/p2/processed").status_code == 410
def test_upload_requires_slab(photos_env):
client, _ = photos_env
r = _multipart(client, "nope", "p1")
assert r.status_code == 404
def test_reupload_replaces(photos_env):
client, _ = photos_env
_multipart(client, "s1", "p3", original=PNG_ORIG, processed=PNG_PROC)
r = _multipart(client, "s1", "p3", original=b"NEW-ORIG", processed=None)
assert r.status_code == 201
assert r.json()["processed_retained"] is False
assert client.get("/api/v1/slabs/s1/photos/p3/original").content == b"NEW-ORIG"
assert client.get("/api/v1/slabs/s1/photos/p3/processed").status_code == 410
def test_list_photos(photos_env):
client, _ = photos_env
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
_multipart(client, "s1", "pb", role="overlay")
r = client.get("/api/v1/slabs/s1/photos")
assert r.status_code == 200
ids = [row["id"] for row in r.json()]
assert "pa" in ids and "pb" in ids
def test_upload_stores_and_returns_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r1", role="overlay")
assert r.status_code == 201
assert r.json()["role"] == "overlay"
r = client.get("/api/v1/slabs/s1/photos")
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
assert row["role"] == "overlay"
def test_upload_rejects_bad_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r2", role="not-a-role")
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
def test_delete_photo(photos_env):
client, _ = photos_env
_multipart(client, "s1", "d1")
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
r = client.delete("/api/v1/slabs/s1/photos/d1")
assert r.status_code == 204
# Gone from the list and blob no longer served.
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
assert "d1" not in ids
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
def test_delete_photo_not_found(photos_env):
client, _ = photos_env
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
-63
View File
@@ -1,63 +0,0 @@
"""S5 pricing rules tests (SETTINGS-35, REVIEW-25)."""
import base64
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
@pytest.fixture
def env():
tmp = tempfile.mkdtemp(prefix="pricing_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
db_mod._reset_for_tests()
from backend.app import app
conn = db_mod.init_db()
for cid in (1, 7):
conn.execute("INSERT OR IGNORE INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (?,?,?,NULL,?)", (cid, f"Sp{cid}", f"sp{cid}", cid))
conn.commit()
yield TestClient(app)
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def test_crud_cycle(env):
r = env.post("/api/v1/admin/pricing", json={"species_id": 1, "price_per_bdft": 12.5})
assert r.status_code == 201, r.text
rule = r.json()
rid = rule["id"]
assert rule["price_per_bdft"] == 12.5
r = env.get("/api/v1/admin/pricing")
assert r.status_code == 200
assert [x["species_id"] for x in r.json()] == [1]
r = env.put(f"/api/v1/admin/pricing/{rid}", json={"price_per_bdft": 15.0})
assert r.status_code == 200
assert r.json()["price_per_bdft"] == 15.0
r = env.delete(f"/api/v1/admin/pricing/{rid}")
assert r.status_code == 204
assert env.get("/api/v1/admin/pricing").json() == []
def test_duplicate_species(env):
env.post("/api/v1/admin/pricing", json={"species_id": 7, "price_per_bdft": 1.0})
r = env.post("/api/v1/admin/pricing", json={"species_id": 7, "price_per_bdft": 2.0})
assert r.status_code == 409
def test_validation(env):
r = env.post("/api/v1/admin/pricing", json={"species_id": 1})
assert r.status_code == 400
r = env.post("/api/v1/admin/pricing", json={"species_id": 1, "price_per_bdft": -5})
assert r.status_code == 400
-363
View File
@@ -1,363 +0,0 @@
"""S6 publish sequence tests (PUBLISH-5/10/15/25/30/31). Woo transport is mocked."""
import base64
import json
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _full_slab(sku="SLAB-01", **over):
base = {
"id": "s1", "sku": sku, "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
"bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
"species_id": 1, "wood_category_ids": json.dumps([2]), "edge_type_term_id": 1,
"figure_term_ids": json.dumps([1]), "grade_term_id": 1, "thickness_term_id": 1,
"moisture_term_id": 1, "fig_tag_ids": json.dumps([]), "feat_tag_ids": json.dumps([]),
"price": 184.0, "price_source": "recommendation",
"title": "Figured Black Walnut Live Edge Slab", "short_description": "slab, 9ft 6in",
"description": "A fine slab.", "content_source": "manual", "status": "ready",
"client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t",
"woo_product_id": None,
}
base.update(over)
return base
@pytest.fixture
def env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="pub_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
conn = db_mod.init_db()
# taxonomy
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'), (348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'), (350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'), (352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
(tid, name, slug, tid),
)
# Moisture keeps its own attribute so the five locked attributes stay distinct.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
# creds
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
# fresh sync stamp so we skip re-sync
from datetime import datetime, timezone
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
(datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
conn.commit()
from backend.app import app
yield TestClient(app), conn
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def _insert_slab(conn, s):
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,"
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,"
"moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,"
"content_source,status,server_rev,client_rev,created_at,updated_at) VALUES "
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(s["id"], s["sku"], s["length_in"], s["thickness_in"], s["sqft"], s["bdft"], s["width_min_in"],
s["width_max_in"], s["width_avg_in"], s["species_id"], s["wood_category_ids"], s["edge_type_term_id"],
s["figure_term_ids"], s["grade_term_id"], s["thickness_term_id"], s["moisture_term_id"],
s["fig_tag_ids"], s["feat_tag_ids"], s["price"], s["price_source"], s["title"], s["short_description"],
s["description"], s["content_source"], s["status"], 1, 1, "t", "t"),
)
def _install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,)):
from backend.woo import WooClient
state = {"media_n": 0}
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
body = json.dumps([{"id": 1}] * sku_exists).encode()
return 200, {}, body
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
mid = media_ids[min(state["media_n"], len(media_ids) - 1)]
state["media_n"] += 1
return 201, {}, json.dumps({"id": mid}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": product_id}).encode()
return 200, {}, b"[]"
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
def test_publish_success(env, monkeypatch):
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
conn.commit()
_install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,))
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True and body["woo_product_id"] == 999 and body["draft"] is False
row = conn.execute("SELECT status, woo_product_id FROM slabs WHERE id='s1'").fetchone()
assert row["status"] == "published" and row["woo_product_id"] == 999
# photos purged
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
def test_publish_sends_derived_thickness_band(env, monkeypatch):
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
The owner has no thickness-band control, so nothing client-side carries it: the
server derives it from the measurement on create/update, and publish must send the
live synced term name on the product payload. This pins that chain end to end.
"""
client, conn = env
create = client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75,
"sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
})
assert create.status_code == 201, create.text
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
assert create.json()["thickness_term_id"] == 351
put = client.put("/api/v1/slabs/s1", json={
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
"description": "D", "content_source": "manual",
})
assert put.status_code == 200, put.text
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
conn.commit()
products = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
products.append(json.loads(data))
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(products) == 1, "expected exactly one product create"
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
assert attrs[9]["options"] == ['1 1/2" - 2"']
def test_publish_woo_image_order_by_role(env, monkeypatch):
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
# role, not by stored seq (the Review strip stores the owner's keep order).
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
# overlay(3). The publish query must reorder to cal, overlay, batch.
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
conn.commit()
media_calls = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
media_calls.append(data)
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(media_calls) == 3
# The photo id is embedded in the multipart filename (slab.py publish
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
# batch (PUBLISH-45), not stored seq.
def photo_id(body: bytes) -> str:
import re
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
return m.group(1).decode() if m else ""
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
def test_uat_draft(env, monkeypatch):
client, conn = env
s = _full_slab(sku="SLAB-UAT-1")
_insert_slab(conn, s)
conn.commit()
_install_mock(monkeypatch)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200
assert r.json()["draft"] is True
def test_not_ready_422(env, monkeypatch):
client, conn = env
s = _full_slab(price=None, price_source=None)
_insert_slab(conn, s)
conn.commit()
_install_mock(monkeypatch)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 422
assert "price" in r.json()["error"]["detail"]["missing"]
def test_duplicate_sku_409(env, monkeypatch):
client, conn = env
s = _full_slab(sku="SLAB-DUP")
_insert_slab(conn, s)
conn.commit()
_install_mock(monkeypatch, sku_exists=1)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 409
assert r.json()["error"]["code"] == "duplicate_sku"
def test_partial_media_failure_502(env, monkeypatch):
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
conn.commit()
from backend.woo import WooClient, WooError
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media"):
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products"):
raise WooError("product create returned 500")
return 200, {}, b"[]"
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 502
assert r.json()["error"]["detail"]["partial_media"] is True
# sync_log written, status failed
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
seen = []
def fake_request(url, method, data, headers):
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient, _USER_AGENT
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
assert sku_hops and create_hops
for hop in sku_hops + create_hops:
assert hop["headers"].get("User-Agent") == _USER_AGENT
def test_publish_403_is_unrecoverable(env, monkeypatch):
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
from backend.woo import WooClient, WooForbiddenError
def fake_request(url, method, data, headers):
raise WooForbiddenError("request forbidden (403)")
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 502
body = r.json()
assert body["error"]["code"] == "woo_forbidden"
assert body["error"]["detail"]["recoverable"] is False
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
-246
View File
@@ -1,246 +0,0 @@
"""S5 deterministic copy + ready derivation tests (METADATA-80/85/90, REVIEW-5/25)."""
import base64
import json
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
from backend import review as review_mod
@pytest.fixture
def env():
tmp = tempfile.mkdtemp(prefix="review_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "r.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
db_mod._reset_for_tests()
conn = db_mod.init_db()
# taxonomy: species + a live-edge-wood leaf + figure term
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (1,'Figure','figure',200)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,1,'Figured','figured',300)")
conn.commit()
from backend.app import app
yield TestClient(app), conn
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def _slab(sku="SLAB-01", **over):
base = {
"id": "s1", "sku": sku, "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
"bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
"species_id": 1, "wood_category_ids": json.dumps([2]), "edge_type_term_id": None,
"figure_term_ids": json.dumps([1]), "grade_term_id": None, "thickness_term_id": None,
"moisture_term_id": None, "fig_tag_ids": "[]", "feat_tag_ids": "[]",
"price": None, "price_source": None, "title": None, "short_description": None,
"description": "A fine slab.", "content_source": "manual", "status": "calibrated",
"client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t",
}
base.update(over)
return base
def test_title_live_edge_no_dims(env):
_, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: the review always returns the deterministic title/short_description
# built from the current metadata (the Create button's source of truth).
r = env[0].post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in")
assert body["title_built"] is True
assert body["title_build_missing"] == []
assert "18.4 bdft" in body["short_description"]
def test_ready_requires_full_set(env):
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["ready"] is False
# price is empty (no rate) and title/short_description generated -> missing includes price, price_source, grade/thickness/moisture terms
assert "price" in body["missing"]
def test_price_suggestion_from_rate(env):
client, conn = env
conn.execute("INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,10.0,'t','t')")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["price_suggestion"] == 184.0 # 18.4 bdft * $10
assert body["price"] == 184.0
def test_price_suggestion_tv8_quality_90(env):
"""QUALITY-90 / TV-8: 0.96 bdft × $12.50 → 12.00."""
client, conn = env
conn.execute(
"INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,12.50,'t','t')"
)
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
"s1",
"SLAB-01",
114.0,
1.75,
30.0,
0.96,
18.0,
24.3,
24.3,
1,
json.dumps([2]),
json.dumps([1]),
None,
None,
None,
None,
"A slab",
"manual",
"calibrated",
1,
1,
"t",
"t",
),
)
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["price_suggestion"] == 12.00
assert body["price"] == 12.00
def test_knife_blank_carries_dims(env):
client, conn = env
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (3,'Knife blanks','knife-blanks',NULL,102)")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: the deterministic build runs on every review.
r = client.post("/api/v1/slabs/s1/review", json={})
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
def test_review_always_reports_deterministic_title(env):
"""#89: the review always returns the deterministic title/short_description
built from the current metadata. The client's Create button writes these into
the fields; the server does not persist them from the review payload."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
# The review does NOT persist title/short_description from the payload.
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] is None
assert got["short_description"] is None
def test_review_no_species_reports_stored_title(env):
"""With no species, there is no deterministic pattern; the review reports the
stored title (None here) and lists title as missing."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] is None
assert "title" in body["missing"]
def test_review_persists_owner_overrides(env):
"""#80: regular review persists price/sku overrides from the payload to the DB,
so the client doesn't need a separate PUT for each. (title/short_description
are NOT persisted here — they come from the deterministic build, not the payload.)"""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# Review with owner overrides for price + sku.
r = client.post("/api/v1/slabs/s1/review", json={
"price": 250.0,
"sku": "SLAB-99",
})
assert r.status_code == 200
body = r.json()
assert body["price"] == 250.0
# The overrides are persisted: GET reflects them.
got = client.get("/api/v1/slabs/s1").json()
assert got["price"] == 250.0
assert got["sku"] == "SLAB-99"
# server_rev was incremented (the UPDATE fired).
assert got["server_rev"] == 2
def test_ready_requires_figure_term_ids(env):
"""UX requires ≥1 figure — figure_term_ids is in _REQUIRED."""
from backend.review import missing_required, compute_ready
slab = _slab(figure_term_ids=[])
miss = missing_required(slab)
assert "figure_term_ids" in miss
ready, missing = compute_ready({**slab, "price": 1.0, "price_source": "manual",
"title": "T", "short_description": "S",
"edge_type_term_id": 1, "grade_term_id": 1,
"thickness_term_id": 1, "moisture_term_id": 1})
assert ready is False
assert "figure_term_ids" in missing
ready2, missing2 = compute_ready({
**slab,
"figure_term_ids": [1],
"price": 1.0, "price_source": "manual",
"title": "T", "short_description": "S",
"edge_type_term_id": 1, "grade_term_id": 1,
"thickness_term_id": 1, "moisture_term_id": 1,
})
# May still miss other fields depending on fixture; figure must not be listed
assert "figure_term_ids" not in missing2
def test_review_title_built_false_without_species_or_primary(env):
"""Create-title silent-fail fix: report title_built=false when taxonomy cannot resolve."""
client, conn = env
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,"
"species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,"
"content_source,status,server_rev,client_rev,created_at,updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1", None, 114.0, 1.75, 30.0, 18.4, 18.0, 24.3, 24.3, None, "[]", "[]", None, None,
"Typed Title", None, None, "manual", "calibrated", 1, 1, "t", "t"),
)
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["title_built"] is False
assert "species" in body["title_build_missing"]
assert "primary_wood_category" in body["title_build_missing"]
assert body["title"] == "Typed Title"
assert body["short_description"] is None
-46
View File
@@ -1,46 +0,0 @@
"""TDD red test for S0 schema load (DATA-MODEL.md contract)."""
import os
import sqlite3
import tempfile
import pytest
SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "..", "db", "schema.sql")
def test_schema_loads_cleanly_and_all_tables_exist():
"""Given: schema.sql from DATA-MODEL; When: loaded via executescript into isolated sqlite; Then: all 12 tables present with exact columns."""
expected_tables = {
"slabs",
"slab_photos",
"pricing_rules",
"woo_categories",
"woo_attributes",
"woo_attribute_terms",
"woo_tags",
"settings",
"prompts",
"prompt_revisions",
"sync_log",
"inference_log",
}
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "isolated.db")
assert os.path.exists(SCHEMA_PATH), "schema.sql must exist for test"
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
schema_sql = f.read()
conn = sqlite3.connect(db_path)
try:
conn.executescript(schema_sql)
conn.commit()
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
actual = {r[0] for r in rows}
assert expected_tables.issubset(actual), f"Missing tables: {expected_tables - actual}"
# spot check one column from slabs per DATA-MODEL
cols = [r[1] for r in conn.execute("PRAGMA table_info(slabs)").fetchall()]
assert "id" in cols and "status" in cols and "client_rev" in cols
finally:
conn.close()
-62
View File
@@ -1,62 +0,0 @@
import os
import shutil
import sqlite3
import tempfile
import pytest
import backend.db as db_mod
@pytest.fixture
def drift_db_dir():
# Persistent temp dir; on Windows the global WAL connection can outlive the
# test frame, so we avoid TemporaryDirectory's immediate rmtree race and clean
# up best-effort after the handle is released.
tmp = tempfile.mkdtemp(prefix="slab_drift_")
yield tmp
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def _setup_temp_db_with_m2_schema(tmp_dir: str) -> str:
db_path = os.path.join(tmp_dir, "test.db")
# M2-era settings shape: NO `value` column (this is the drift signature).
# Use a raw connection (DELETE journal) exactly as an old M2 db would be on disk.
conn = sqlite3.connect(db_path)
# M2-era settings: NO `value` column (drift signature).
conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value_enc BLOB, updated_at TEXT)")
conn.execute("INSERT INTO settings (key, value_enc, updated_at) VALUES ('woo_wp_username', X'00', '2026-01-01')")
# M2-era slabs: NO deleted_at / server_rev (drift signature).
conn.execute("CREATE TABLE slabs (id TEXT PRIMARY KEY, sku TEXT, status TEXT)")
conn.execute("INSERT INTO slabs (id, sku, status) VALUES ('old', 'OLD-1', 'calibrated')")
conn.commit()
conn.close()
# Force any journal sidecars to merge before the WAL-mode factory opens the file.
for suffix in ("-journal", "-wal", "-shm"):
try:
os.remove(db_path + suffix)
except OSError:
pass
return db_path
def test_drift_detection_and_rebuild(monkeypatch, drift_db_dir):
db_path = _setup_temp_db_with_m2_schema(drift_db_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
# init_db must detect pre-M3 drift and rebuild to the M3 settings shape
conn = db_mod.init_db()
cur = conn.execute("SELECT version FROM schema_version").fetchone()
assert cur is not None
assert cur[0] == 1
info = conn.execute("PRAGMA table_info(settings)").fetchall()
cols = {row[1] for row in info}
assert "value" in cols
assert "value_enc" in cols
# slabs must also be rebuilt to the M3 shape (deleted_at present)
slab_cols = {row[1] for row in conn.execute("PRAGMA table_info(slabs)").fetchall()}
assert "deleted_at" in slab_cols and "server_rev" in slab_cols
# M2 rows are disposable (M3 supersedes M2 on this dev volume)
assert conn.execute("SELECT key FROM settings").fetchall() == []
assert conn.execute("SELECT id FROM slabs").fetchall() == []
-140
View File
@@ -1,140 +0,0 @@
"""TDD red tests for S1 Settings API (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6)."""
import base64
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _valid_key_b64() -> str:
raw = b"0123456789abcdef0123456789abcdef"
return base64.b64encode(raw).decode()
def _setup_env(monkeypatch, tmp):
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", _valid_key_b64())
monkeypatch.setenv("LOG_LEVEL", "INFO")
db_mod._reset_for_tests()
def test_settings_get_masks_secrets_and_shows_woo_base_url(monkeypatch):
"""Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
# seed some data via direct DB (will be replaced by service later)
conn = db_mod.get_connection()
db_mod.init_db()
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')")
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')") # will be decrypt fail path
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')")
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')")
conn.commit()
resp = client.get("/api/v1/settings")
assert resp.status_code == 200
body = resp.json()
assert body["woo_base_url"] == "https://example.com"
assert body["woo_wp_username"] == "wpuser"
assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured
assert body["inference_model"] == "gpt-4o"
assert body["inference_enabled"] is True
assert "inference_api_key" in body and body["inference_api_key"] == {"configured": False}
# never leak
assert "value" not in str(body)
assert "badbase64" not in str(body)
db_mod._reset_for_tests()
def test_settings_put_rejects_woo_base_url(monkeypatch):
"""Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 validation (SETTINGS-85)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"})
assert resp.status_code == 400
err = resp.json()["error"]
assert err["code"] == "validation"
assert "woo_base_url" in err["message"].lower()
db_mod._reset_for_tests()
def test_settings_put_stores_encrypted_secrets(monkeypatch):
"""Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not plaintext), plain fields direct (SETTINGS-20)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
payload = {
"woo_app_password": "super-secret-pass",
"inference_base_url": "https://api.openai.com/v1",
"inference_api_key": "sk-xxx",
"inference_model": "gpt-4o-mini",
"inference_enabled": True,
"content_llm_enabled": False,
"brand_voice": "professional",
"geo_context": "PNW",
"woo_create_status": "draft",
}
resp = client.put("/api/v1/settings", json=payload)
assert resp.status_code == 200
# check DB
conn = db_mod.get_connection()
rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()}
assert rows["woo_app_password"][0] is None
assert rows["woo_app_password"][1] is not None
# value_enc must be valid base64 and >12 bytes when decoded (nonce+ct)
enc = rows["woo_app_password"][1]
blob = base64.b64decode(enc)
assert len(blob) > 12
assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None
assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None
assert rows["inference_enabled"][0] == "true"
assert rows.get("content_llm_enabled", (None, None))[0] == "false"
db_mod._reset_for_tests()
def test_settings_flags_default_false_when_absent(monkeypatch):
"""Given: no flag rows; When: GET; Then: inference_enabled/content_llm_enabled == false (SETTINGS-90)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
resp = client.get("/api/v1/settings")
assert resp.status_code == 200
body = resp.json()
assert body["inference_enabled"] is False
assert body["content_llm_enabled"] is False
db_mod._reset_for_tests()
def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
"""Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500 (SETTINGS-20/15 spirit)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
conn = db_mod.get_connection()
# insert corrupt (not even base64 or short)
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')")
conn.commit()
resp = client.get("/api/v1/settings")
assert resp.status_code == 200 # not 500
body = resp.json()
assert body["inference_api_key"] == {"configured": False}
db_mod._reset_for_tests()

Some files were not shown because too many files have changed in this diff Show More