Compare commits
136 Commits
M1-dev-rev4
..
m4
| Author | SHA1 | Date | |
|---|---|---|---|
| f725e04ae6 | |||
| 8673ae0ecd | |||
| a756cf58bb | |||
| d50d55aea9 | |||
| 1882fa3842 | |||
| 7908a99ec3 | |||
| 6000ac2b0f | |||
| d49ff2fab5 | |||
| 4ca2ef57a7 | |||
| 6fb92362a4 | |||
| 818874e65c | |||
| 0c00d25cc7 | |||
| 96a697a50a | |||
| 67aa3d6b0d | |||
| 8ee3da9dcf | |||
| 1a50fad0a5 | |||
| e61303e2d8 | |||
| bdd03afce4 | |||
| e5acef4c9b | |||
| 204bc5e960 | |||
| 191b507934 | |||
| 0329052d37 | |||
| e1b928ccbd | |||
| 76fcb36a2b | |||
| 5a1800df91 | |||
| cd326d1ec9 | |||
| 502314b338 | |||
| 0612ce66a6 | |||
| 7b7adbdb34 | |||
| d9af8420bc | |||
| fe82133ca4 | |||
| 693719e85f | |||
| 41401a7266 | |||
| 7287a55707 | |||
| 81be17d5cd | |||
| cec03c5bd3 | |||
| e36979617b | |||
| c8d6d5eda0 | |||
| 1133201f30 | |||
| 2e2be48154 | |||
| 18c262b5b4 | |||
| b99cbdf194 | |||
| d1e862770d | |||
| d614123503 | |||
| 34ee7eff60 | |||
| e51753b6d0 | |||
| 7575471397 | |||
| 8a8a89ed3e | |||
| 5e03f45660 | |||
| 732310fdbe | |||
| 861c5e0dd1 | |||
| 714a227e57 | |||
| d83c9d5c21 | |||
| 38eebbc8bb | |||
| cc5241f7da | |||
| a0032ddd2d | |||
| 60bc2fb9d0 | |||
| 7074b82ced | |||
| e63c964844 | |||
| 6aaad81583 | |||
| 39ee82bdf8 | |||
| 3fb35e8957 | |||
| b3380f3848 | |||
| 0eb2b0f697 | |||
| 9b181a937b | |||
| b166e796ad | |||
| 2d12cbcfbd | |||
| 5a210c47b0 | |||
| 35848fe0e6 | |||
| f0f891f80e | |||
| d6aa79b593 | |||
| 120e0be871 | |||
| 1dfff0b7c9 | |||
| 2ba5428269 | |||
| 6435199f08 | |||
| 90e862cfb0 | |||
| 20f08bbc25 | |||
| 63a99e8154 | |||
| 1391219e48 | |||
| 01aa4c52c3 | |||
| a8cc2f7596 | |||
| 84d7681c6f | |||
| 1603467747 | |||
| 8ac534c613 | |||
| 238fcc4f15 | |||
| b42e3cb8ce | |||
| d754cac9ef | |||
| 40080aee3a | |||
| eaf96d36e1 | |||
| 22599c924f | |||
| df40c4269f | |||
| 03d3fcf460 | |||
| 037a4b870a | |||
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 | |||
| 2be2e7d615 | |||
| 6d182db81b | |||
| dc19cffcc0 | |||
| 05447c92fc | |||
| 415158d4d3 | |||
| 9bc35f7edd | |||
| 15895cae0c | |||
| 5650255e49 | |||
| 2be400e455 | |||
| 2c9a50ddf9 | |||
| 4c3c8b49eb | |||
| a22525023a | |||
| c674931f5c | |||
| 3936c575b2 | |||
| 9404d0130c | |||
| 8ff008f85c | |||
| d2ffa4cad2 | |||
| d18b96c10b | |||
| 4fa8126471 | |||
| ff4bb0f31e | |||
| e919fe9a35 | |||
| ab5bdafbd8 | |||
| 2eb825c84e | |||
| 4ab68d6b68 | |||
| eaedcb53dd | |||
| 11c8124674 | |||
| 44d428b6f4 | |||
| f20d84c543 | |||
| fbcd05169f | |||
| b3a9fdf330 | |||
| 114e0b5116 | |||
| 81e9184c7f | |||
| b328871549 | |||
| b781ffeb66 | |||
| 0e94b1fd2a | |||
| c6a48c8d91 | |||
| 0679e1cef7 | |||
| 503449452c | |||
| bb78e35ac3 | |||
| d86fc93ed6 |
@@ -0,0 +1,76 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,211 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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 | — |
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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 | — |
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,152 @@
|
||||
# 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
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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 | — |
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.svelte-kit
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/README.md
|
||||
.audit
|
||||
docs/archive
|
||||
@@ -35,3 +35,11 @@ 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/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Shipped milestones
|
||||
|
||||
Role: history. Historical log of shipped milestones. Cites requirement IDs; defines none.
|
||||
|
||||
Not yet written.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project
|
||||
|
||||
Role: vision. Overall project vision and current state. Cites requirement IDs; defines none.
|
||||
|
||||
Not yet written.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Roadmap
|
||||
|
||||
Role: roadmap. Phases across all milestones. Cites requirement IDs; defines none.
|
||||
|
||||
Not yet written.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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`).
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# M4 — Goals
|
||||
|
||||
Role: goals for Milestone 4. Cites requirement IDs and Gitea issue numbers; defines none
|
||||
new until accepted. Source of truth for scope will be `docs/MILESTONES.md` (M4 ID subset)
|
||||
and `docs/REQUIREMENTS.md` (rows) after acceptance.
|
||||
|
||||
**Base:** builds on branch `m4` (commit `818874e`, `feat(m4): crop_fill setting + tape-line
|
||||
prototype (#99)`), which is 1 ahead of `origin/main`. #99 is in scope.
|
||||
|
||||
**Objective:** Ship a polished, stable M3 experience with the tape-line calibration model,
|
||||
a **human-driven taxonomy review** (Call 1 deferred; owner picks everything on a thumb-first
|
||||
UI), **refined Call 2 long descriptions**, and a verified real WooCommerce draft submission.
|
||||
|
||||
**Exit criterion:** A real `SLAB-UAT-*` Woo **draft** matching the reviewed ready set exists
|
||||
in the store, created end-to-end through the M4 UI (tape-line calibration → human taxonomy
|
||||
review → Call 2 long description → publish), with **no Call 1** in the path (inference
|
||||
settings need not be vision-enabled). Call 2 is exercised on a live call at least once.
|
||||
Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
|
||||
|
||||
## Goals (accepted direction)
|
||||
|
||||
1. **Stabilize + polish the M3 experience (A) — includes the #99 tape-line calibration (B).**
|
||||
- **Tape-line calibration (#99) — SHIPPED (as-built 2026-09-25, UAT signed off):** the
|
||||
axis-rectangle calibration is replaced by a **fixed** black centre line with two end
|
||||
ticks. The line is a stage fixture and is **never an owner input** (not draggable, not
|
||||
focusable, not keyboard-nudgeable). The **source photo/slab is the only thing that
|
||||
moves** — pan/zoom/tilt it until it lines up with the fixed line at the place the tape
|
||||
measurement was taken. Declared Length = the real inches read off the tape, and the
|
||||
fixed line represents that same length; scale = fixed-line pixel span / declared
|
||||
Length, no `maskSpanAlong` check. The crop slider alone sets the line ends
|
||||
(`crop_fill=90` ⇒ each end 5% from its edge). The earlier `?tape-prototype` sandbox,
|
||||
`TapePrototype.svelte`, `frontend/src/png/crop.ts` and the `sveltedrag` dependency are
|
||||
all superseded/removed. Requirement rows CAPTURE-170/175/75 carry the as-built; see
|
||||
`tasks.md` T2 for detail.
|
||||
- **UX polish:** photo-picker look/feel (#91), photo filenames on submit (#92), capture
|
||||
gated on a tested Woo connection (#93), review UI is always the human-pick flow (#94),
|
||||
debug mode (#95), required-photo lock glyph (#100). (#96 Call 1/2 prompt viewer drops
|
||||
with Call 1.)
|
||||
- Post-launch issue work + the tape-line feature; no other new feature surface.
|
||||
|
||||
2. **Human-driven taxonomy review + thumb-first UI (Call 1 deferred; Call 2 kept).**
|
||||
- **Call 1 (vision) → deferred to future work.** Not in the MVP: no "Suggest with AI"
|
||||
button, no auto-pre-fill, no confidence pre-fill, no AI markers on fields. The
|
||||
inference settings need not be vision-enabled.
|
||||
- **Owner picks everything Call 1 used to propose**, via a thumb-first, on-phone usable
|
||||
UI: species (1 leaf), wood categories (primary + optional secondaries), Edge, Figure
|
||||
(multi), Grade (owner judgment), Thickness band (derived, no pick), Moisture, and
|
||||
`feat-*` / `fig-*` tags. All from the synced Woo taxonomy (METADATA-5/10/15/30).
|
||||
- **Call 2 (text) stays** — writes the long description from the human-picked values +
|
||||
dimension fact block; brand/geo context, primary + secondary categories. (Refining its
|
||||
output quality is its own goal, #3 below.)
|
||||
- **Title + short description remain deterministic** from the primary wood category +
|
||||
dimension fact block (METADATA-80/85) — they depend only on the human's primary pick
|
||||
+ fact block, not on Call 1.
|
||||
- **All METADATA requirements remain in scope** — the human collects the same data
|
||||
Call 1 previously suggested, just by hand.
|
||||
- **Wireframe first (a sprint task in this milestone):** before any review-UI coding,
|
||||
produce a wireframe (2-3 HTML variants) to align on the overall flow — how the
|
||||
thumb-first taxonomy collection, primary/secondary selection, and Call 2 generate
|
||||
control compose on a phone. This is the design gate for the review work.
|
||||
**Starting point (saved, recent):** `docs/design/issue-99-tape-line-overlay.html`
|
||||
(approved #99 tape-line overlay wireframe, committed 2026-09-24 on
|
||||
`issue-99-tape-line-overlay`). Do **not** build on the older `pre-simplification`
|
||||
wireframes or the 2026-09-18 `ux-design.md` coverage matrix — those are stale.
|
||||
- *To be scoped:* the exact thumb-first interaction model (wizard vs single-scroll,
|
||||
chips vs pickers) — settled by the wireframe.
|
||||
|
||||
3. **Refine Call 2 so it delivers better long descriptions.**
|
||||
- Call 2 (text) stays in the MVP and is the inference work of this milestone: improve
|
||||
how it turns the human-picked taxonomy + dimension fact block + brand/geo context into
|
||||
a long description that reads well for the store.
|
||||
- Levers (task-level, settled during the sprint): prompt revision (METADATA-105/106),
|
||||
the dimension fact block as the only numbers it may print (METADATA-65/90/95),
|
||||
primary + secondary category weighting, brand voice / geo context settings
|
||||
(SETTINGS-30), and recoverable/unrecoverable failure (METADATA-115).
|
||||
- Success is judged on the exit-walk description quality (Ty reads it), not on a proxy
|
||||
metric. Raw evidence recorded.
|
||||
|
||||
4. **Exit: real Woo draft submission.**
|
||||
- PUBLISH-5 sequence, PUBLISH-10 (`SLAB-UAT-*` force draft), PUBLISH-25 (woo_product_id,
|
||||
purge photos, discard local row, clear device draft). QUALITY-110 exit evidence.
|
||||
|
||||
## Non-goals (excluded from M4)
|
||||
|
||||
- **Call 1 (vision) inference** — deferred to future work. No auto-suggest, no confidence
|
||||
pre-fill, no AI markers. METADATA-35/40/45/100 rows are out of the M4 active set.
|
||||
- TLS on LAN transports (#78) — production-readiness, deferred.
|
||||
- Backup/restore (OPS-30/35) — deferred ("Later").
|
||||
- Instrumentation: Prometheus metrics, alerting, log retention (#7/#16) — deferred.
|
||||
- Store-admin species-leaf creation (#13), shared validation module (#17), 409/422
|
||||
field-level recovery (#18) — post-launch designs, deferred.
|
||||
- Offline capture (CAPTURE-15), U2Net server matting (CAPTURE-165) — "Later".
|
||||
- Browser e2e on all five clients (QUALITY-115) — "Later".
|
||||
- Prompt-revision auth beyond trusted network (METADATA-107) — "Later".
|
||||
- App login (relies on trusted LAN/Tailscale + HTTPS, OPS-55) — out of scope.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- M4 does **not** add an app-level login; the trusted-network model (OPS-55) holds.
|
||||
- The `m4` branch is the working base; `main` is the merge target once M4 is accepted and green.
|
||||
- Inference endpoint is a remote, configurable endpoint (OPS-45); M4 refines prompts/calls,
|
||||
not the endpoint transport.
|
||||
- Real Woo for the exit walk; Woo mock is a dishonest proxy (QUALITY-105/110).
|
||||
- Existing M3 requirement rows that the tape-line model supersedes (CAPTURE-60, CAPTURE-75)
|
||||
are updated in place; new rows are added for the tape-line behavior. No row is deleted.
|
||||
|
||||
## Requirements delta (draft — rows written at execution, after acceptance)
|
||||
|
||||
- **New rows:** tape-line calibration behavior (overlay, draggable ticks,
|
||||
owner-declared length, tick-distance scale). ~1–3 rows under CAPTURE.
|
||||
- **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood
|
||||
categories (primary + secondary) / edge / figure / grade / moisture / tags by hand,
|
||||
thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW.
|
||||
- **Superseded/updated rows (draft):** CAPTURE-60 (length axis), CAPTURE-75 (scale model) —
|
||||
rewritten to the tape-line model.
|
||||
- **Deferred rows (out of M4 active set):** METADATA-35/40/45/100 (Call 1 vision
|
||||
suggestions + confidence gate) — moved to "Later"/future work. The rows stay in
|
||||
REQUIREMENTS.md; only their M4 scope drops.
|
||||
- **Refined rows (draft):** METADATA-55/60/65 (Call 2) — if the human-picked flow changes
|
||||
normative Call 2 inputs (it shouldn't materially; Call 2 already receives the metadata
|
||||
confirmed in review).
|
||||
- **Refined rows (draft):** REVIEW-15/20/25/40 (review-workflow usability) — field layout,
|
||||
nudge behavior, primary/secondary category interaction, generate-text control placement,
|
||||
if the usability changes alter normative review behavior. #94 (conditional review UI)
|
||||
now means: the review screen is **always** the human-pick flow (no inference on/off
|
||||
branch for Call 1).
|
||||
- **Unchanged (in M4 ID subset):** all PUBLISH, REVIEW, SETTINGS, APP, OPS-40, QUALITY rows
|
||||
that M3 already shipped and M4 re-uses for the exit; METADATA-5/10/15/30/85 (the data the
|
||||
human collects + the deterministic title/short-desc).
|
||||
|
||||
## Acceptance criteria (draft)
|
||||
|
||||
1. **Tape-line calibration + M3 polish ship together (Goal 1):** the tape-line calibration
|
||||
ships as a **fixed** centre line the owner pans/zooms/tilts the slab onto (line is never
|
||||
an owner input; crop slider sets its ends; declared length = the real tape inches, and
|
||||
the fixed line represents that same length; no `maskSpanAlong` check), and the
|
||||
M3 polish issues (#91–#100, excluding #96 which drops with Call 1) are closed with
|
||||
passing tests and raw runner evidence.
|
||||
2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick
|
||||
species, wood categories (primary + secondary), edge, figure, grade, moisture, and tags
|
||||
by hand on a phone, with no "Suggest with AI" surface and no AI pre-fill. The wireframe
|
||||
was accepted before coding (design gate).
|
||||
3. **Call 2 (text) is exercised on a live call** during the exit walk and delivers a
|
||||
description that reads well (Ty judges it); content-gen control visibility (METADATA-110),
|
||||
recoverable/unrecoverable classification (METADATA-115), and prompt-revision API
|
||||
(METADATA-105/106) all pass their tests.
|
||||
4. **A real `SLAB-UAT-*` Woo draft** exists in the store matching the reviewed ready set
|
||||
(QUALITY-110), with `woo_product_id` set, photos purged, local row discarded, device
|
||||
draft cleared (PUBLISH-25).
|
||||
5. **Gates pass:** `python tools/check_requirements.py` and `python tools/check_coverage.py`
|
||||
exit 0; full suite passes with inference **and** off (QUALITY-20) — now including the
|
||||
human-pick flow with Call 1 removed; raw evidence recorded.
|
||||
|
||||
## Verification strategy (smallest that proves the claim)
|
||||
|
||||
- **Deterministic core:** unit tests for tape-line geometry + `crop_fill` (existing
|
||||
`frontend/src/png/*` test surface).
|
||||
- **Integration:** Woo mock for the PUBLISH-5 sequence (success, duplicate SKU, stale field,
|
||||
taxonomy anchor) — QUALITY-35/50.
|
||||
- **Exit walk:** `deploy/uat_walk.sh` on the real store for the `SLAB-UAT-*` draft
|
||||
(QUALITY-105/110) — the only proof that counts for the submission claim.
|
||||
- **Ty owns gate exit** (QUALITY-30); agents record raw evidence, never declare green.
|
||||
|
||||
## Task-level decisions (settled during the sprint, not at planning)
|
||||
|
||||
The only plan-level open item is the exact thumb-first interaction model
|
||||
(wizard vs single-scroll, chips vs pickers) — settled by the wireframe.
|
||||
Settled at kickoff (not reopened here): full #91–#100 polish wave (only #96 drops
|
||||
with Call 1); #99 confined to the calibration step (CAPTURE-130 lineup untouched);
|
||||
exit walk is a single slab carrying multiple photos.
|
||||
|
||||
## Task breakdown detail bar
|
||||
|
||||
The task breakdown is not a list of goal names. Every task carries, at minimum:
|
||||
|
||||
- **Goal it serves** (G1–G4) and the requirement IDs / issue numbers it touches.
|
||||
- **Skills to load** for that task — named and ordered (e.g. UI task: `sketch` +
|
||||
`wireframe-review-iteration` → `omh-frontend` + `omh-design-quality-gate` →
|
||||
`omh-visual-qa` + `omh-accessibility-audit`).
|
||||
- **Starting point / references** — the specific saved artifact, file, or branch the task
|
||||
builds from (e.g. `docs/design/issue-99-tape-line-overlay.html`, the live
|
||||
`Review.svelte`, the M3 `MultiSelect.svelte`).
|
||||
- **Verification** — the smallest check that proves the task's claim (unit test paths,
|
||||
`tsc --noEmit`, Woo mock, `deploy/uat_walk.sh`, 375/768/1280 visual captures, keyboard /
|
||||
contrast audit). Ty owns gate exit.
|
||||
- **Sequence / dependencies** — what must land first (wireframe accepted before review-UI
|
||||
coding; requirements rows before the code that implements them; Call 2 refinement after
|
||||
the human-pick flow feeds it real data).
|
||||
- **Stop condition** — when the task is done (e.g. "wireframe accepted by Ty," "gates
|
||||
green + raw evidence recorded," "real `SLAB-UAT-*` draft matches the ready set").
|
||||
- **Evidence boundary** — what is prepared vs observed; no task claims done without the
|
||||
raw runner / capture / store evidence named above.
|
||||
@@ -0,0 +1,205 @@
|
||||
# M4 Task Breakdown
|
||||
|
||||
Companion to `goals.md` (accepted direction). Detail bar per task: goal, skills,
|
||||
starting point, verification, sequence, stop condition, evidence boundary.
|
||||
|
||||
Phase order: P0 (requirements first) → P1 (G1 polish + #99, parallel-safe) → P2 (G2
|
||||
review-UI, wireframe-gated) → P3 (G3 Call 2) → P4 (G4 exit walk). Ty owns every gate
|
||||
exit (QUALITY-30); agents record raw evidence, never declare green.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Requirements baseline (must land before code it enables)
|
||||
|
||||
### T1. Requirements delta for M4
|
||||
- **Goal:** G2/G4 (unblocks G3). **IDs:** REQS (whole doc), REQS-30. **Issues:** —.
|
||||
- **Skills:** `document-to-action-items` (extract obligations), `ocr-and-documents`
|
||||
(read current REQUIREMENTS.md), OMH `omh-routing` (evidence boundaries).
|
||||
- **Starting point:** `docs/REQUIREMENTS.md` (current), `goals.md` requirements-delta
|
||||
section.
|
||||
- **Work:** add rows for the human-pick flow (REVIEW-*, CAPTURE-*, METADATA-*), the
|
||||
Call 2 taxonomy-input row, and the `SLAB-UAT-*` exit-walk row; note the Call 1
|
||||
deferral (METADATA-40/45/55) and drop #96; keep the "draft only" PUBLISH-40 invariant;
|
||||
confirm #99 is tracked. Every new/changed row is the gate for the code that implements it.
|
||||
- **Verification:** `python tools/check_requirements.py` exit 0; coverage matrix
|
||||
consistent.
|
||||
- **Sequence:** first task of the sprint.
|
||||
- **Stop:** rows merged + gate green. **Evidence:** runner output.
|
||||
|
||||
### T2. #99 tape-line calibration (folded into G1) — AS-BUILT, shipped
|
||||
- **Goal:** G1. **IDs:** CAPTURE-170, CAPTURE-175, CAPTURE-75. **Issues:** #99 (issue
|
||||
body still describes the superseded draggable-tick model — this entry is the as-built).
|
||||
- **Skills:** `systematic-debugging`, `svelte-core-bestpractices`,
|
||||
`docker-uat-deploy` (isolate-and-verify the review preview).
|
||||
- **As-built:** a **fixed** black centre line with two end ticks, drawn on the stage
|
||||
fixture. The line is **never an owner input** — not draggable, not focusable, not
|
||||
keyboard-nudgeable, and not part of the published pose. The **source photo/slab is the
|
||||
only thing that moves**: drag to pan, wheel/pinch to zoom, the ↻ handle or arrow keys
|
||||
(Shift = fine step) to tilt. The owner aligns the *slab* to the fixed line at the place
|
||||
the tape measurement was taken. Typed Length is the real inches read off the tape, and
|
||||
the fixed line represents that same length. Scale = fixed-line pixel span / declared
|
||||
Length; `maskSpanAlong` is not the scale source. The crop slider is the **sole** control
|
||||
of the line ends: `crop_fill=90` puts each end 5% from its corresponding edge.
|
||||
Calibration rotation applies to the calibration clean PNG + overlay only; batch
|
||||
siblings stay unrotated.
|
||||
- **Superseded (do not restore):** draggable ticks; `TapePrototype.svelte` and the
|
||||
`?tape-prototype` sandbox (both deleted); `frontend/src/png/crop.ts` (removed in
|
||||
6000ac2 — crop geometry now lives in `frontend/src/capture/tapeLine.ts`
|
||||
`cropEnds(fillPercent)`); the `sveltedrag` dependency (404s on npm, never added).
|
||||
The wireframe `docs/design/issue-99-tape-line-overlay.html` lives on branch
|
||||
`issue-99-tape-line-overlay` (63b8879), not on `m4`.
|
||||
- **Verification:** unit tests in `frontend/src/capture/tapeLine.test.ts` (ends fixed by
|
||||
the crop slider, never an owner input) + `frontend/src/png/{inventory,overlay}.test.ts`
|
||||
(calibration rotation reaches the calibration PNG/overlay only); `tsc --noEmit`;
|
||||
`npm run build`; e2e `frontend/tests/e2e/tape-fixed-line.spec.ts` (no tick drag; a tick
|
||||
drag behaves identically to a pan; ends follow the crop slider).
|
||||
- **Sequence:** parallel with T3–T5; no dependency on T1.
|
||||
- **Stop:** SHIPPED. UAT-signed off by Ty 2026-09-25 on the M4 review preview
|
||||
(`http://192.168.1.202:10883/?noload=1`, build `a756cf5`, branch `m4`).
|
||||
- **Evidence:** `npm test` 102/102; requirements + coverage gates green; live e2e on the
|
||||
deployed preview (0°/20° rotation produces differing clean PNG + overlay bytes); render
|
||||
gate clean at 1440/768/375 with no overflow or clipped text; Ty's UAT sign-off.
|
||||
**Not covered:** `svelte-check` still reports its known 42-error baseline.
|
||||
|
||||
---
|
||||
|
||||
## P1 — M3 polish (G1), parallel-safe once T1 lands
|
||||
|
||||
### T3. Polish wave (full set #91–#100)
|
||||
- **Goal:** G1. **IDs:** UX (various). **Issues:** all of #91–#100 — #91 (photo-picker
|
||||
look/feel), #92 (photo filenames on submit), #93 (capture gated on tested Woo
|
||||
connection), #94 (review layout), #97 (settings), #98 (publish), #100 (UX general).
|
||||
**Excluded:** #96 (drops with Call 1). Ty confirmed: full polish wave, no triage.
|
||||
- **Skills:** `svelte-core-bestpractices`, `omh-frontend` (component states),
|
||||
`omh-visual-qa` (rendered evidence per screen).
|
||||
- **Starting point:** the live screens named in each issue; `frontend/src/create/*`.
|
||||
- **Work:** per-issue fixes. Each is a small, individually-verifiable unit; sequence
|
||||
by dependency (e.g. #93 gate before #98 publish flow touches it).
|
||||
- **Verification:** per-issue acceptance (capture at 375/768/1280, or the named
|
||||
behavior), full suite green.
|
||||
- **Sequence:** after T1; independent of T2.
|
||||
- **Stop:** each issue's acceptance captured. **Evidence:** captures + test run.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Human-driven taxonomy review + thumb-first UI (G2)
|
||||
|
||||
### T4. Wireframe (design gate)
|
||||
- **Goal:** G2. **IDs:** REVIEW-*, UX (phone). **Issues:** #100 (UX).
|
||||
- **Skills:** `sketch` (variants) + `wireframe-review-iteration` (paradigms, serve on
|
||||
LAN IP, iterate on Ty's feedback) → `omh-frontend` + `omh-design-quality-gate`
|
||||
(lock the chosen variant).
|
||||
- **Starting point:** `docs/design/issue-99-tape-line-overlay.html` (approved overlay
|
||||
wireframe, 2026-09-24) for the visual language + the interaction model.
|
||||
- **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms:
|
||||
*no tap-bubble chip clouds for long lists → collapsible searchable checklist*;
|
||||
leftmost-kept-is-primary for wood (primary + optional secondaries);
|
||||
no AI-assist surface (Call 1 decommissioned); every metadata requirement present (primary
|
||||
wood-category required, secondaries optional).
|
||||
- **Verification:** Ty reviews the served variants and accepts one (375/768/1280).
|
||||
- **Sequence:** before T5/T6. **Stop:** Ty accepts a variant.
|
||||
**Evidence:** the accepted `docs/design/*` file + Ty's acceptance.
|
||||
|
||||
### T5. Review-UI implementation
|
||||
- **Goal:** G2. **IDs:** REVIEW-*, CAPTURE-*. **Issues:** #100.
|
||||
- **Skills:** `svelte-core-bestpractices`, `svelte-code-writer` (Svelte 5 lookups),
|
||||
`omh-frontend`, `omh-visual-qa`, `omh-accessibility-audit` (keyboard, 44px targets,
|
||||
contrast, 320px reflow).
|
||||
- **Starting point:** accepted T4 wireframe; `frontend/src/create/review/Review.svelte`
|
||||
(current single-column form); the M3 `MultiSelect.svelte` pattern (reused, not the
|
||||
tap-bubble pattern).
|
||||
- **Work:** replace the review form with the accepted thumb-first flow; primary
|
||||
wood-category + optional secondaries; remove the "Suggest with AI" bar (#96 drops
|
||||
with Call 1); keep all metadata requirements present and editable.
|
||||
- **Verification:** `tsc --noEmit`; `omh-visual-qa` at 375/768/1280 (PASS);
|
||||
`omh-accessibility-audit` (keyboard-reachable, 44px, contrast, 320px reflow);
|
||||
full suite green with inference on **and** off (QUALITY-20).
|
||||
- **Sequence:** after T4 accepted. **Stop:** flow renders, all requirements present,
|
||||
audits pass. **Evidence:** captures + audit output.
|
||||
|
||||
### T6. Taxonomy → title/shortdesc (server-side)
|
||||
- **Goal:** G2. **IDs:** METADATA-*. **Issues:** —.
|
||||
- **Skills:** `systematic-debugging` (verify the title/shortdesc builders),
|
||||
`evidence-based-code-review`.
|
||||
- **Starting point:** the title/short description builders in `backend/` (review.py /
|
||||
publish.py path); the new human-pick fields from T5.
|
||||
- **Work:** ensure title + short description are built from the human-picked primary
|
||||
wood + form (not from the removed Call 1). Confirm the server_description
|
||||
(inference-off) path stays intact.
|
||||
- **Verification:** unit tests for title/shortdesc from human picks; inference-off
|
||||
path covered. **Sequence:** with/after T5. **Stop:** tests green.
|
||||
**Evidence:** test output.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Refine Call 2 long description (G3)
|
||||
|
||||
### T7. Wire human-picked taxonomy into the Call 2 prompt
|
||||
- **Goal:** G3. **IDs:** METADATA-*. **Issues:** —.
|
||||
- **Skills:** `systematic-debugging`, `evidence-based-code-review`,
|
||||
`local-llm-benchmarking` (endpoint behavior), `omh-ask` (prompt-engineering consult
|
||||
if the owner wants an external pass).
|
||||
- **Starting point:** `backend/inference.py` `call2` (line 467) — the prompt today is
|
||||
the **dimension fact block + brand voice + geo only; taxonomy is NOT in the prompt**.
|
||||
`CALL2_SEED` (line 112), `_active_prompt`, `brand_voice`/`geo_context` settings.
|
||||
- **Work:** add the human-picked primary/secondary wood + form to the Call 2 fact
|
||||
block so the long description is grounded in what the owner picked; keep the
|
||||
"invent no numbers" constraint; keep the dimension block exact.
|
||||
- **Verification:** `test_inference.py` updated for the new input; a recorded call2
|
||||
with a known taxonomy set shows the wood/form in the output. **Sequence:** after T5
|
||||
(needs the human-pick data shape). **Stop:** test passes + sample output grounded.
|
||||
**Evidence:** test output + a sample Call 2 response.
|
||||
|
||||
### T8. Exit-walk quality gate
|
||||
- **Goal:** G3. **IDs:** QUALITY-105/110. **Issues:** —.
|
||||
- **Skills:** `omh-verification-gate` (record the gate), `omh-visual-qa`.
|
||||
- **Starting point:** `deploy/uat_walk.sh`; the refined Call 2 from T7.
|
||||
- **Work:** Ty reads the Call 2 long description on the exit walk for 2–3 real slabs;
|
||||
pass = "sounds like a good product description." This is the only proof that counts
|
||||
for the Call 2 quality claim.
|
||||
- **Verification:** Ty's judgment on 2–3 outputs, recorded. **Sequence:** after T7.
|
||||
**Stop:** Ty passes the gate. **Evidence:** Ty's recorded judgment.
|
||||
|
||||
---
|
||||
|
||||
## P4 — Exit: real Woo draft (G4)
|
||||
|
||||
### T9. `SLAB-UAT-*` draft submission + walk
|
||||
- **Goal:** G4. **IDs:** PUBLISH-*, QUALITY-105/110. **Issues:** —.
|
||||
- **Skills:** `omh-deploy-and-monitor` (release checklist + monitor),
|
||||
`omh-verification-gate`, `docker-uat-deploy` (if the UAT host needs staging).
|
||||
- **Starting point:** `backend/publish.py` (draft create path), the Woo mock
|
||||
(`test_integration_woo_mock.py`) for the PUBLISH-5 sequence, `deploy/uat_walk.sh`.
|
||||
- **Work:** run the full create → review (human pick) → Call 2 → publish path on the
|
||||
real store, producing **one** `SLAB-UAT-*` draft; walk it (draft only, PUBLISH-40);
|
||||
confirm the draft matches the ready set. **Shape (Ty confirmed):** single slab, and
|
||||
the draft carries **multiple photos** of that slab — not a QUALITY-110 pair.
|
||||
- **Verification:** `deploy/uat_walk.sh` on the real store; draft present in Woo with
|
||||
the correct fields — including **multiple photos** on the single `SLAB-UAT-*` draft;
|
||||
full suite green inference on **and** off (QUALITY-20).
|
||||
- **Sequence:** last; after T3–T8. **Stop:** real draft matches the ready set + Ty
|
||||
passes the walk. **Evidence:** store screenshot + walk transcript + suite output.
|
||||
|
||||
---
|
||||
|
||||
## Dependency map
|
||||
|
||||
```
|
||||
T1 ──┬─ T3 (polish)
|
||||
├─ T4 ─ T5 ─┬─ T6
|
||||
│ └─ T7 ─ T8 ─ T9
|
||||
└─ T2 (tape-line, parallel)
|
||||
T9 is the milestone exit.
|
||||
```
|
||||
|
||||
- T2 is independent of T1 (prototype already on `m4`).
|
||||
- T4 must be accepted before T5.
|
||||
- T7 needs T5's data shape; T8/T9 need T7.
|
||||
- Nothing in P2/P3 blocks T2/T3 (polish + tape-line ship independently).
|
||||
|
||||
## Resolved at kickoff (deep-interview, 2026-09-24)
|
||||
- **Polish scope:** full wave — all of #91–#100, no triage (only #96 drops with Call 1).
|
||||
- **Tape-line scope:** confined to the calibration step; photos-ready lineup
|
||||
(CAPTURE-130) untouched.
|
||||
- **Exit-walk shape:** single slab; the `SLAB-UAT-*` draft carries multiple photos of
|
||||
that slab (not a QUALITY-110 pair).
|
||||
Vendored
-454
@@ -1,454 +0,0 @@
|
||||
|
||||
// this file is generated — do not edit it
|
||||
|
||||
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
|
||||
*
|
||||
* **_Private_ access:**
|
||||
*
|
||||
* - This module cannot be imported into client-side code
|
||||
* - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
|
||||
*
|
||||
* For example, given the following build time environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://site.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private';
|
||||
*
|
||||
* console.log(ENVIRONMENT); // => "production"
|
||||
* console.log(PUBLIC_BASE_URL); // => throws error during build
|
||||
* ```
|
||||
*
|
||||
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
||||
*/
|
||||
declare module '$env/static/private' {
|
||||
export const LESSOPEN: string;
|
||||
export const TMUX: string;
|
||||
export const HERMES_DELEGATED_CHILD_CONTEXT: string;
|
||||
export const timezone: string;
|
||||
export const CUDACXX: string;
|
||||
export const BROWSERBASE_PROXIES: string;
|
||||
export const HERMES_RESTART_DRAIN_TIMEOUT: string;
|
||||
export const _HERMES_GATEWAY: string;
|
||||
export const AI_AGENT: string;
|
||||
export const USER: string;
|
||||
export const SSH_CLIENT: string;
|
||||
export const npm_config_user_agent: string;
|
||||
export const TERMINAL_CONTAINER_CPU: string;
|
||||
export const AGENT_BROWSER_EXECUTABLE_PATH: string;
|
||||
export const TERMINAL_DOCKER_ENV: string;
|
||||
export const TERMINAL_CWD: string;
|
||||
export const HERMES_REAL_HOME: string;
|
||||
export const XDG_SESSION_TYPE: string;
|
||||
export const npm_node_execpath: string;
|
||||
export const SHLVL: string;
|
||||
export const HERMES_PROFILE: string;
|
||||
export const npm_config_noproxy: string;
|
||||
export const HOME: string;
|
||||
export const OLDPWD: string;
|
||||
export const TERMINAL_DOCKER_FORWARD_ENV: string;
|
||||
export const TERM_PROGRAM_VERSION: string;
|
||||
export const BROWSER_SESSION_TIMEOUT: string;
|
||||
export const HERMES_KANBAN_BOARD: string;
|
||||
export const SSH_TTY: string;
|
||||
export const HERMES_HOME: string;
|
||||
export const npm_package_json: string;
|
||||
export const TERMINAL_DOCKER_EXTRA_ARGS: string;
|
||||
export const HF_HUB_DISABLE_XET: string;
|
||||
export const IM_CONFIG_ENTRY: string;
|
||||
export const TERMINAL_DOCKER_IMAGE: string;
|
||||
export const PAGER: string;
|
||||
export const TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string;
|
||||
export const TERMINAL_CONTAINER_MEMORY: string;
|
||||
export const SSL_CERT_FILE: string;
|
||||
export const TERMINAL_CONTAINER_PERSISTENT: string;
|
||||
export const npm_config_userconfig: string;
|
||||
export const npm_config_local_prefix: string;
|
||||
export const HERMES_TURN_LEASE_TIMEOUT: string;
|
||||
export const HERMES_MEDIA_DELIVERY_STRICT: string;
|
||||
export const TERMINAL_MODAL_IMAGE: string;
|
||||
export const DBUS_SESSION_BUS_ADDRESS: string;
|
||||
export const COLORTERM: string;
|
||||
export const TERMINAL_DOCKER_NETWORK: string;
|
||||
export const file_read_max_chars: string;
|
||||
export const COLOR: string;
|
||||
export const DEBUGINFOD_URLS: string;
|
||||
export const TERMINAL_DOCKER_VOLUMES: string;
|
||||
export const TERMINAL_ENV: string;
|
||||
export const TERMINAL_VERCEL_RUNTIME: string;
|
||||
export const LOGNAME: string;
|
||||
export const HERMES_KANBAN_DB: string;
|
||||
export const IMAGE_TOOLS_DEBUG: string;
|
||||
export const _: string;
|
||||
export const npm_config_prefix: string;
|
||||
export const npm_config_npm_version: string;
|
||||
export const group_sessions_per_user: string;
|
||||
export const HERMES_KANBAN_WORKSPACES_ROOT: string;
|
||||
export const HERMES_SINGLE_QUERY_SESSION: string;
|
||||
export const XDG_SESSION_CLASS: string;
|
||||
export const BROWSERBASE_ADVANCED_STEALTH: string;
|
||||
export const paste_collapse_threshold: string;
|
||||
export const TERM: string;
|
||||
export const TERMINAL_DAYTONA_IMAGE: string;
|
||||
export const XDG_SESSION_ID: string;
|
||||
export const npm_config_cache: string;
|
||||
export const TERMINAL_TEMP_DIR: string;
|
||||
export const TERMINAL_LIFETIME_SECONDS: string;
|
||||
export const TERMINAL_TIMEOUT: string;
|
||||
export const HERMES_GATEWAY_BUSY_INPUT_MODE: string;
|
||||
export const paste_collapse_char_threshold: string;
|
||||
export const TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string;
|
||||
export const npm_config_node_gyp: string;
|
||||
export const PATH: string;
|
||||
export const HERMES_AGENT_TIMEOUT: string;
|
||||
export const NODE: string;
|
||||
export const TERMINAL_DOCKER_SHM_SIZE: string;
|
||||
export const HERMES_KANBAN_WORKSPACE: string;
|
||||
export const XDG_RUNTIME_DIR: string;
|
||||
export const TERMINAL_PERSISTENT_SHELL: string;
|
||||
export const LANG: string;
|
||||
export const HERMES_QUIET: string;
|
||||
export const MOA_TOOLS_DEBUG: string;
|
||||
export const HERMES_MEDIA_TRUST_RECENT_FILES: string;
|
||||
export const LS_COLORS: string;
|
||||
export const HERMES_MEDIA_TRUST_RECENT_SECONDS: string;
|
||||
export const HERMES_MAX_ITERATIONS: string;
|
||||
export const TERM_PROGRAM: string;
|
||||
export const npm_lifecycle_script: string;
|
||||
export const HERMES_SESSION_ID: string;
|
||||
export const HERMES_AGENT_TIMEOUT_WARNING: string;
|
||||
export const prefill_messages_file: string;
|
||||
export const SHELL: string;
|
||||
export const HERMES_AGENT_NOTIFY_INTERVAL: string;
|
||||
export const TERMINAL_DEGRADED_MODE: string;
|
||||
export const TERMINAL_MODAL_MODE: string;
|
||||
export const npm_lifecycle_event: string;
|
||||
export const WEB_TOOLS_DEBUG: string;
|
||||
export const VISION_TOOLS_DEBUG: string;
|
||||
export const LESSCLOSE: string;
|
||||
export const TERMINAL_CONTAINER_DISK: string;
|
||||
export const TERMINAL_DOCKER_RUN_AS_HOST_USER: string;
|
||||
export const GIT_PAGER: string;
|
||||
export const paste_collapse_threshold_fallback: string;
|
||||
export const npm_config_globalconfig: string;
|
||||
export const npm_config_init_module: string;
|
||||
export const PWD: string;
|
||||
export const HERMES_INTERACTIVE: string;
|
||||
export const _config_version: string;
|
||||
export const HERMES_AUTO_CONTINUE_FRESHNESS: string;
|
||||
export const npm_execpath: string;
|
||||
export const SSH_CONNECTION: string;
|
||||
export const XDG_DATA_DIRS: string;
|
||||
export const npm_config_global_prefix: string;
|
||||
export const hooks_auto_accept: string;
|
||||
export const npm_command: string;
|
||||
export const TMUX_PLUGIN_MANAGER_PATH: string;
|
||||
export const BROWSER_INACTIVITY_TIMEOUT: string;
|
||||
export const HERMES_REDACT_SECRETS: string;
|
||||
export const HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string;
|
||||
export const TERMINAL_SINGULARITY_IMAGE: string;
|
||||
export const TMUX_PANE: string;
|
||||
export const TERMINAL_HOME_MODE: string;
|
||||
export const TERMINAL_DOCKER_SNAP_COMPAT: string;
|
||||
export const HERMES_AGENT: string;
|
||||
export const HERMES_SESSION_SOURCE: string;
|
||||
export const INIT_CWD: string;
|
||||
export const EDITOR: string;
|
||||
export const TEST: string;
|
||||
export const VITEST: string;
|
||||
export const NODE_ENV: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
|
||||
*
|
||||
* **_Public_ access:**
|
||||
*
|
||||
* - This module _can_ be imported into client-side code
|
||||
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
|
||||
*
|
||||
* For example, given the following build time environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://site.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public';
|
||||
*
|
||||
* console.log(ENVIRONMENT); // => throws error during build
|
||||
* console.log(PUBLIC_BASE_URL); // => "http://site.com"
|
||||
* ```
|
||||
*
|
||||
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
||||
*/
|
||||
declare module '$env/static/public' {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
|
||||
*
|
||||
* **_Private_ access:**
|
||||
*
|
||||
* - This module cannot be imported into client-side code
|
||||
* - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
|
||||
*
|
||||
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
|
||||
*
|
||||
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
|
||||
* >
|
||||
* > ```env
|
||||
* > MY_FEATURE_FLAG=
|
||||
* > ```
|
||||
* >
|
||||
* > You can override `.env` values from the command line like so:
|
||||
* >
|
||||
* > ```sh
|
||||
* > MY_FEATURE_FLAG="enabled" npm run dev
|
||||
* > ```
|
||||
*
|
||||
* For example, given the following runtime environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://site.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { env } from '$env/dynamic/private';
|
||||
*
|
||||
* console.log(env.ENVIRONMENT); // => "production"
|
||||
* console.log(env.PUBLIC_BASE_URL); // => undefined
|
||||
* ```
|
||||
*/
|
||||
declare module '$env/dynamic/private' {
|
||||
export const env: {
|
||||
LESSOPEN: string;
|
||||
TMUX: string;
|
||||
HERMES_DELEGATED_CHILD_CONTEXT: string;
|
||||
timezone: string;
|
||||
CUDACXX: string;
|
||||
BROWSERBASE_PROXIES: string;
|
||||
HERMES_RESTART_DRAIN_TIMEOUT: string;
|
||||
_HERMES_GATEWAY: string;
|
||||
AI_AGENT: string;
|
||||
USER: string;
|
||||
SSH_CLIENT: string;
|
||||
npm_config_user_agent: string;
|
||||
TERMINAL_CONTAINER_CPU: string;
|
||||
AGENT_BROWSER_EXECUTABLE_PATH: string;
|
||||
TERMINAL_DOCKER_ENV: string;
|
||||
TERMINAL_CWD: string;
|
||||
HERMES_REAL_HOME: string;
|
||||
XDG_SESSION_TYPE: string;
|
||||
npm_node_execpath: string;
|
||||
SHLVL: string;
|
||||
HERMES_PROFILE: string;
|
||||
npm_config_noproxy: string;
|
||||
HOME: string;
|
||||
OLDPWD: string;
|
||||
TERMINAL_DOCKER_FORWARD_ENV: string;
|
||||
TERM_PROGRAM_VERSION: string;
|
||||
BROWSER_SESSION_TIMEOUT: string;
|
||||
HERMES_KANBAN_BOARD: string;
|
||||
SSH_TTY: string;
|
||||
HERMES_HOME: string;
|
||||
npm_package_json: string;
|
||||
TERMINAL_DOCKER_EXTRA_ARGS: string;
|
||||
HF_HUB_DISABLE_XET: string;
|
||||
IM_CONFIG_ENTRY: string;
|
||||
TERMINAL_DOCKER_IMAGE: string;
|
||||
PAGER: string;
|
||||
TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string;
|
||||
TERMINAL_CONTAINER_MEMORY: string;
|
||||
SSL_CERT_FILE: string;
|
||||
TERMINAL_CONTAINER_PERSISTENT: string;
|
||||
npm_config_userconfig: string;
|
||||
npm_config_local_prefix: string;
|
||||
HERMES_TURN_LEASE_TIMEOUT: string;
|
||||
HERMES_MEDIA_DELIVERY_STRICT: string;
|
||||
TERMINAL_MODAL_IMAGE: string;
|
||||
DBUS_SESSION_BUS_ADDRESS: string;
|
||||
COLORTERM: string;
|
||||
TERMINAL_DOCKER_NETWORK: string;
|
||||
file_read_max_chars: string;
|
||||
COLOR: string;
|
||||
DEBUGINFOD_URLS: string;
|
||||
TERMINAL_DOCKER_VOLUMES: string;
|
||||
TERMINAL_ENV: string;
|
||||
TERMINAL_VERCEL_RUNTIME: string;
|
||||
LOGNAME: string;
|
||||
HERMES_KANBAN_DB: string;
|
||||
IMAGE_TOOLS_DEBUG: string;
|
||||
_: string;
|
||||
npm_config_prefix: string;
|
||||
npm_config_npm_version: string;
|
||||
group_sessions_per_user: string;
|
||||
HERMES_KANBAN_WORKSPACES_ROOT: string;
|
||||
HERMES_SINGLE_QUERY_SESSION: string;
|
||||
XDG_SESSION_CLASS: string;
|
||||
BROWSERBASE_ADVANCED_STEALTH: string;
|
||||
paste_collapse_threshold: string;
|
||||
TERM: string;
|
||||
TERMINAL_DAYTONA_IMAGE: string;
|
||||
XDG_SESSION_ID: string;
|
||||
npm_config_cache: string;
|
||||
TERMINAL_TEMP_DIR: string;
|
||||
TERMINAL_LIFETIME_SECONDS: string;
|
||||
TERMINAL_TIMEOUT: string;
|
||||
HERMES_GATEWAY_BUSY_INPUT_MODE: string;
|
||||
paste_collapse_char_threshold: string;
|
||||
TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string;
|
||||
npm_config_node_gyp: string;
|
||||
PATH: string;
|
||||
HERMES_AGENT_TIMEOUT: string;
|
||||
NODE: string;
|
||||
TERMINAL_DOCKER_SHM_SIZE: string;
|
||||
HERMES_KANBAN_WORKSPACE: string;
|
||||
XDG_RUNTIME_DIR: string;
|
||||
TERMINAL_PERSISTENT_SHELL: string;
|
||||
LANG: string;
|
||||
HERMES_QUIET: string;
|
||||
MOA_TOOLS_DEBUG: string;
|
||||
HERMES_MEDIA_TRUST_RECENT_FILES: string;
|
||||
LS_COLORS: string;
|
||||
HERMES_MEDIA_TRUST_RECENT_SECONDS: string;
|
||||
HERMES_MAX_ITERATIONS: string;
|
||||
TERM_PROGRAM: string;
|
||||
npm_lifecycle_script: string;
|
||||
HERMES_SESSION_ID: string;
|
||||
HERMES_AGENT_TIMEOUT_WARNING: string;
|
||||
prefill_messages_file: string;
|
||||
SHELL: string;
|
||||
HERMES_AGENT_NOTIFY_INTERVAL: string;
|
||||
TERMINAL_DEGRADED_MODE: string;
|
||||
TERMINAL_MODAL_MODE: string;
|
||||
npm_lifecycle_event: string;
|
||||
WEB_TOOLS_DEBUG: string;
|
||||
VISION_TOOLS_DEBUG: string;
|
||||
LESSCLOSE: string;
|
||||
TERMINAL_CONTAINER_DISK: string;
|
||||
TERMINAL_DOCKER_RUN_AS_HOST_USER: string;
|
||||
GIT_PAGER: string;
|
||||
paste_collapse_threshold_fallback: string;
|
||||
npm_config_globalconfig: string;
|
||||
npm_config_init_module: string;
|
||||
PWD: string;
|
||||
HERMES_INTERACTIVE: string;
|
||||
_config_version: string;
|
||||
HERMES_AUTO_CONTINUE_FRESHNESS: string;
|
||||
npm_execpath: string;
|
||||
SSH_CONNECTION: string;
|
||||
XDG_DATA_DIRS: string;
|
||||
npm_config_global_prefix: string;
|
||||
hooks_auto_accept: string;
|
||||
npm_command: string;
|
||||
TMUX_PLUGIN_MANAGER_PATH: string;
|
||||
BROWSER_INACTIVITY_TIMEOUT: string;
|
||||
HERMES_REDACT_SECRETS: string;
|
||||
HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string;
|
||||
TERMINAL_SINGULARITY_IMAGE: string;
|
||||
TMUX_PANE: string;
|
||||
TERMINAL_HOME_MODE: string;
|
||||
TERMINAL_DOCKER_SNAP_COMPAT: string;
|
||||
HERMES_AGENT: string;
|
||||
HERMES_SESSION_SOURCE: string;
|
||||
INIT_CWD: string;
|
||||
EDITOR: string;
|
||||
TEST: string;
|
||||
VITEST: string;
|
||||
NODE_ENV: string;
|
||||
[key: `PUBLIC_${string}`]: undefined;
|
||||
[key: `${string}`]: string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
|
||||
*
|
||||
* **_Public_ access:**
|
||||
*
|
||||
* - This module _can_ be imported into client-side code
|
||||
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
|
||||
*
|
||||
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
|
||||
*
|
||||
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
|
||||
* >
|
||||
* > ```env
|
||||
* > MY_FEATURE_FLAG=
|
||||
* > ```
|
||||
* >
|
||||
* > You can override `.env` values from the command line like so:
|
||||
* >
|
||||
* > ```sh
|
||||
* > MY_FEATURE_FLAG="enabled" npm run dev
|
||||
* > ```
|
||||
*
|
||||
* For example, given the following runtime environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://example.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { env } from '$env/dynamic/public';
|
||||
* console.log(env.ENVIRONMENT); // => undefined, not public
|
||||
* console.log(env.PUBLIC_BASE_URL); // => "http://example.com"
|
||||
* ```
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
declare module '$env/dynamic/public' {
|
||||
export const env: {
|
||||
[key: `PUBLIC_${string}`]: string | undefined;
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
// See https://svelte.dev/docs/kit/environment-variables for more information
|
||||
@@ -1,38 +0,0 @@
|
||||
// in dev, this makes Vite inject its client as this module's first dependency,
|
||||
// so that global constant replacements are installed before any other module
|
||||
// (including user hooks) evaluates. In build it's inert.
|
||||
import.meta.hot;
|
||||
|
||||
|
||||
|
||||
|
||||
export { matchers } from './matchers.js';
|
||||
|
||||
export const nodes = [
|
||||
() => import('./nodes/0'),
|
||||
() => import('./nodes/1')
|
||||
];
|
||||
|
||||
export const server_loads = [];
|
||||
|
||||
export const dictionary = {
|
||||
|
||||
};
|
||||
|
||||
export const hooks = {
|
||||
handleError: (({ error }) => { console.error(error) }),
|
||||
|
||||
reroute: (() => {}),
|
||||
transport: {}
|
||||
};
|
||||
|
||||
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
|
||||
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
|
||||
|
||||
export const hash = false;
|
||||
|
||||
export const decode = (type, value) => decoders[type](value);
|
||||
|
||||
export { default as root } from '../root.js';
|
||||
|
||||
export const get_error_template = () => import('../shared/error-template.js').then(m => m.default);
|
||||
@@ -1 +0,0 @@
|
||||
export const matchers = {};
|
||||
@@ -1 +0,0 @@
|
||||
export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/layout.svelte";
|
||||
@@ -1 +0,0 @@
|
||||
export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte";
|
||||
@@ -1 +0,0 @@
|
||||
export default ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n";
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"$app/types": [
|
||||
"./types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"rootDirs": [
|
||||
"..",
|
||||
"./types"
|
||||
],
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"lib": [
|
||||
"esnext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"module": "esnext",
|
||||
"noEmit": true,
|
||||
"target": "esnext"
|
||||
},
|
||||
"include": [
|
||||
"ambient.d.ts",
|
||||
"env.d.ts",
|
||||
"non-ambient.d.ts",
|
||||
"./types/**/$types.d.ts",
|
||||
"../vite.config.js",
|
||||
"../vite.config.ts",
|
||||
"../src/**/*.js",
|
||||
"../src/**/*.ts",
|
||||
"../src/**/*.svelte",
|
||||
"../test/**/*.js",
|
||||
"../test/**/*.ts",
|
||||
"../test/**/*.svelte",
|
||||
"../tests/**/*.js",
|
||||
"../tests/**/*.ts",
|
||||
"../tests/**/*.svelte"
|
||||
],
|
||||
"exclude": [
|
||||
"../node_modules/**",
|
||||
"../src/service-worker.js",
|
||||
"../src/service-worker/**/*.js",
|
||||
"../src/service-worker.ts",
|
||||
"../src/service-worker/**/*.ts",
|
||||
"../src/service-worker.d.ts",
|
||||
"../src/service-worker/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,28 +1,69 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Agent context
|
||||
`docs/REQUIREMENTS.md` is the only normative text. All other docs cite IDs; none define requirements.
|
||||
|
||||
Read this file and `docs/CAPTURE.md`.
|
||||
Implement Milestone 1 only. Specification: `docs/CAPTURE.md`.
|
||||
PRD: `docs/PRODUCT.md` for destination and field names. Do not implement Milestone 2, 3, or 4.
|
||||
## Structure contract (read before creating or moving any doc)
|
||||
|
||||
## Milestone 1
|
||||
- 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.
|
||||
|
||||
Capture.
|
||||
Acceptance: on a phone, one real slab, approved mask, approved length-axis rectangle, user length and thickness, inventory PNG on screen, widths and board feet on screen.
|
||||
## Agent runtime rules
|
||||
|
||||
Milestone 1 is inference-free. Background removal, rectangle, measurements, and PNG encode run in the browser as ordinary code. There is no inference URL and no model call in this milestone.
|
||||
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
|
||||
|
||||
## Invariants
|
||||
## Build & test (verified commands)
|
||||
|
||||
- Quantity per listing is one.
|
||||
- User enters length, thickness, and SKU.
|
||||
- App proposes remaining listing fields. User reviews before create.
|
||||
- Non-math fields remain editable until create.
|
||||
- `bdft = sqft × thickness_in`
|
||||
- Price proposal is `bdft × species $/bdft`. User may replace it. Missing rate leaves price empty.
|
||||
- User-entered length, thickness, SKU, price, and taxonomy override computed or inferred values.
|
||||
- SKUs matching `SLAB-UAT-*` create as Woo draft.
|
||||
- Create works with inference off.
|
||||
- Source below the PNG size floor requires a new photo.
|
||||
- Existing store SKU blocks create.
|
||||
Frontend (`frontend/`, Svelte 4 + Vite + TS, no router):
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- 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.
|
||||
|
||||
## Gates (must pass before claiming work complete)
|
||||
|
||||
Run from repo root:
|
||||
|
||||
```
|
||||
python tools/check_requirements.py
|
||||
python tools/check_coverage.py
|
||||
```
|
||||
|
||||
Exit 0 on both = pass.
|
||||
|
||||
## 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.
|
||||
|
||||
## Pointers (read these for everything else)
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# M1-6 UAT Deploy — run these on 192.168.1.202
|
||||
|
||||
# 1. Copy the repo to .202 (from THIS machine, /home/ty):
|
||||
rsync -az --exclude node_modules --exclude .svelte-kit --exclude build \
|
||||
/home/ty/SlabUploader/ ty@192.168.1.202:SlabUploader/
|
||||
|
||||
# 2. Build the image on .202:
|
||||
ssh ty@192.168.1.202 "cd SlabUploader/frontend && docker build -t slabuploader-m1 . && \
|
||||
docker rm -f slabuploader-m1 2>/dev/null; \
|
||||
docker run -d --name slabuploader-m1 --restart unless-stopped -p 18080:80 slabuploader-m1 && \
|
||||
echo 'Container running at http://192.168.1.202:18080'"
|
||||
|
||||
# 3. Verify from your phone:
|
||||
# Open http://192.168.1.202:18080 in Safari/Chrome
|
||||
# Flow: Add photo → Mask (green sheet, sensitivity) → Approve → Axis → Approve → Measure → Calculate
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
Lists one-of-a-kind wood slabs on Whidbey Wood Store.
|
||||
|
||||
Start at [`docs/README.md`](docs/README.md) for goals, what already shipped, and Milestone 3.
|
||||
|
||||
| Artifact | File |
|
||||
|---|---|
|
||||
| PRD | `docs/PRODUCT.md` |
|
||||
| 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 | `docs/CAPTURE.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/` |
|
||||
|
||||
Owner: Ty. Store: https://www.whidbeywoodstore.com/
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# SlabUploader M1 — shared build context (for :202 worker tasks)
|
||||
|
||||
REPO: /home/ty/SlabUploader — work ONLY inside /home/ty/SlabUploader/frontend.
|
||||
Branch: M1-dev-rev4 (already checked out; DO NOT switch branches, DO NOT push).
|
||||
This is Milestone 1 (Capture) of a slab-measuring web app. Browser-only, NO backend,
|
||||
NO inference, NO OpenCV/ONNX. Pure JS (not TS). Svelte 5 + SvelteKit + adapter-node.
|
||||
|
||||
TEST COMMANDS (run these, show real output):
|
||||
unit: cd /home/ty/SlabUploader/frontend && npx vitest run
|
||||
e2e: npx playwright test --list (collection only; full e2e needs the M1-6 container)
|
||||
|
||||
EXISTING (already committed, green, DO NOT break):
|
||||
src/lib/core/mask.js -> keySheet(data,{sheet,sensitivity}), maskStats(mask)
|
||||
src/lib/core/math.js -> scale, sqft, bdft, roundHalfUp, sampledWidths,
|
||||
sampledWidthsFrom, snapToEighth
|
||||
tests/unit/mask.test.js, tests/unit/math.test.js (24 tests, all pass)
|
||||
src/routes/+page.svelte (placeholder home — M1-3 replaces it)
|
||||
src/routes/+layout.svelte, src/routes/api/health/+server.js
|
||||
|
||||
TDD IS MANDATORY (test-driven-development skill):
|
||||
1. Write the failing test(s) FIRST.
|
||||
2. Run them and WATCH them fail for the right reason (feature missing, not a typo).
|
||||
3. Write the MINIMAL code to pass.
|
||||
4. Run them and confirm they pass.
|
||||
5. Run the FULL unit suite (npx vitest run) and confirm no regressions + pristine output.
|
||||
6. Only then are you done. Commit is done by the coordinator — you do NOT commit.
|
||||
|
||||
CONVENTIONS:
|
||||
- Tab indentation (the repo uses tabs).
|
||||
- Pure logic lives in src/lib/core/*.js (DOM-free, unit-testable under node).
|
||||
- Svelte components live under src/lib/ (e.g. src/lib/components/).
|
||||
- Every new exported function needs a unit test that you SAW fail first.
|
||||
- No console.log left in production code.
|
||||
- Do not add dependencies.
|
||||
- Report at the end: files created/changed, the exact test command output, and
|
||||
any place you had to make a judgment call.
|
||||
@@ -0,0 +1,24 @@
|
||||
# S0 minimal backend Dockerfile (OPS-25: pinned, not latest)
|
||||
FROM python:3.14.3-slim-bookworm
|
||||
|
||||
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 /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
ENV PYTHONPATH=/app \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=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"]
|
||||
@@ -0,0 +1,2 @@
|
||||
"""SlabUploader backend package (S0 foundation)."""
|
||||
__all__ = ["app", "db"]
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
"""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,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
)
|
||||
|
||||
|
||||
def _woo_credentials() -> tuple[str, str | None, str | None]:
|
||||
conn = get_connection()
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
|
||||
|
||||
|
||||
@app.post("/api/v1/settings/test-woo")
|
||||
async def test_woo() -> dict[str, Any]:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
try:
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
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() -> dict[str, Any]:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
try:
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
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__"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
@@ -0,0 +1,162 @@
|
||||
-- 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)
|
||||
);
|
||||
@@ -0,0 +1,646 @@
|
||||
"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2
|
||||
content prose, test-inference, inference-off server description, inference_log.
|
||||
Cites METADATA-35..115, SETTINGS-30/45/90.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from PIL import Image
|
||||
|
||||
from .db import get_connection
|
||||
from .crypto import decrypt
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
# METADATA-35: Call 1 (and Call 2) send clean processed photos at 1024 px long edge or less.
|
||||
_INFER_MAX_LONG_EDGE = 1024
|
||||
|
||||
|
||||
def fit_long_edge_png(png_bytes: bytes, max_long_edge: int = _INFER_MAX_LONG_EDGE) -> bytes:
|
||||
"""Downscale a PNG so max(width, height) <= max_long_edge. Never upscale. Keep alpha.
|
||||
|
||||
Used only for the inference request payload; stored processed_blob / Woo media stay full size.
|
||||
Unreadable or non-image bytes are returned unchanged so a bad blob cannot crash Call 1.
|
||||
"""
|
||||
if not png_bytes or max_long_edge < 1:
|
||||
return png_bytes
|
||||
try:
|
||||
im = Image.open(io.BytesIO(png_bytes))
|
||||
im.load()
|
||||
except Exception:
|
||||
return png_bytes
|
||||
w, h = im.size
|
||||
long_edge = max(w, h)
|
||||
if long_edge <= max_long_edge:
|
||||
# Already within budget; re-encode only if we need a stable PNG (caller always base64s).
|
||||
return png_bytes
|
||||
scale = max_long_edge / float(long_edge)
|
||||
nw = max(1, int(round(w * scale)))
|
||||
nh = max(1, int(round(h * scale)))
|
||||
# Preserve alpha: convert palette/etc. to RGBA when needed, LANCZOS downscale.
|
||||
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
||||
im = im.convert("RGBA")
|
||||
out = im.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
out.save(buf, format="PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _image_part_from_processed(png_bytes: bytes) -> dict:
|
||||
"""Build one OpenAI-style image_url part from a processed PNG (METADATA-35 sized)."""
|
||||
fitted = fit_long_edge_png(png_bytes)
|
||||
b64 = base64.b64encode(fitted).decode()
|
||||
return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||
|
||||
# Locked Call 1 seed (#86 / METADATA-40): expert helper + closed Woo lists only.
|
||||
CALL1_SEED = (
|
||||
"You are a wood identification expert for retail lumber slabs. Judge only from the photos "
|
||||
"and the LISTS below. Use list ids only — never invent a name or id.\n"
|
||||
"\n"
|
||||
"Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, "
|
||||
"(5) feature tags. Decide wood categories before edge type — edge rules depend on product form.\n"
|
||||
"\n"
|
||||
"For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). "
|
||||
"Prefer null or [] over a weak guess.\n"
|
||||
"\n"
|
||||
"Species — which tree species is this slab. Match grain, color, and figure to the species "
|
||||
"list. If more than one entry is plausible, return only the single highest-confidence match "
|
||||
"(one id). Null only when none are credible.\n"
|
||||
"\n"
|
||||
"Wood categories — product form / how it will be sold (live edge, dimensional, cookies & "
|
||||
"burl slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; "
|
||||
"often one, sometimes more. [] if none fit. Finish this field before edge type.\n"
|
||||
"\n"
|
||||
"Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.\n"
|
||||
" Meanings (match the closest list name):\n"
|
||||
" • Square / square edge — both long edges milled straight (2 milled long edges).\n"
|
||||
" • Live one side / Live Edge 1 side — one long edge milled, the other natural bark or "
|
||||
"waney (1 milled long edge).\n"
|
||||
" • Live both / Live edge 2 sides — neither long edge milled; both natural "
|
||||
"(0 milled long edges).\n"
|
||||
" • Wild edge — use when the three mill-count cases do not fit, including cookies and burl "
|
||||
"cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.\n"
|
||||
"\n"
|
||||
"Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids "
|
||||
"from figure_terms that clearly apply.\n"
|
||||
"\n"
|
||||
"Feature tags — defects and character marks the store tags for shoppers (knots, checks, "
|
||||
"inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].\n"
|
||||
"\n"
|
||||
"Return one JSON object with exactly these keys:\n"
|
||||
'{"species_id": <id|null>, "species_confidence": <0-1>, '
|
||||
'"wood_category_ids": [<id>,...], "wood_category_confidence": <0-1>, '
|
||||
'"edge_type_term_id": <id|null>, "edge_confidence": <0-1>, '
|
||||
'"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, '
|
||||
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
|
||||
)
|
||||
CALL2_SEED = (
|
||||
"Write the long product description for the slab from the supplied photos and listing "
|
||||
"facts. The name and short description are already built, so do not repeat them. Use the "
|
||||
"brand voice and geo context given. The dimensions in the fact block are exact: use those "
|
||||
"strings and invent no numbers. Write prose a premium lumber buyer would read."
|
||||
)
|
||||
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
|
||||
_THRESHOLD = 0.7
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _seed_prompts(conn) -> None:
|
||||
"""Ensure each call has a prompt row and the shipped seed revision.
|
||||
|
||||
If the shipped seed hash is new and the active revision is still seed-authored
|
||||
(or missing), activate the new seed so deploys pick up CALL1_SEED / CALL2_SEED
|
||||
updates without clobbering an owner-activated API revision.
|
||||
"""
|
||||
for name, seed in _SEEDS.items():
|
||||
prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone()
|
||||
if prow is None:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
|
||||
(name, None, _now(), _now()),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
active_id = None
|
||||
else:
|
||||
pid = prow[0]
|
||||
active_id = prow[1]
|
||||
h = _hash(seed)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_revisions WHERE prompt_id=? AND content_hash=?",
|
||||
(pid, h),
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
cur2 = conn.execute(
|
||||
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(pid, seed, h, _now(), "seed"),
|
||||
)
|
||||
seed_rev_id = cur2.lastrowid
|
||||
else:
|
||||
seed_rev_id = existing[0]
|
||||
# Activate shipped seed when nothing active, or active is still a seed revision.
|
||||
activate = False
|
||||
if active_id is None:
|
||||
activate = True
|
||||
else:
|
||||
act = conn.execute(
|
||||
"SELECT created_by FROM prompt_revisions WHERE id=?", (active_id,)
|
||||
).fetchone()
|
||||
if act is None or act[0] == "seed":
|
||||
activate = True
|
||||
if activate and active_id != seed_rev_id:
|
||||
conn.execute(
|
||||
"UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?",
|
||||
(seed_rev_id, _now(), pid),
|
||||
)
|
||||
|
||||
|
||||
def _active_prompt(conn, call: str) -> tuple[int, str] | None:
|
||||
row = conn.execute(
|
||||
"SELECT p.id, pr.id, pr.content FROM prompts p LEFT JOIN prompt_revisions pr ON p.active_revision_id=pr.id WHERE p.name=?",
|
||||
(call,),
|
||||
).fetchone()
|
||||
if row is None or row[2] is None:
|
||||
return None
|
||||
return row[1], row[2]
|
||||
|
||||
|
||||
# ---------- Prompt revision REST (METADATA-106) ----------
|
||||
@router.post("/prompts/{call}/revisions", status_code=201)
|
||||
async def append_revision(call: str, payload: dict[str, Any]) -> dict:
|
||||
if call not in _SEEDS:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "unknown call"))
|
||||
content = payload.get("content")
|
||||
if not content:
|
||||
return JSONResponse(status_code=400, content=_error("validation", "content required"))
|
||||
conn = get_connection()
|
||||
_seed_prompts(conn)
|
||||
prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)",
|
||||
(prow[0], content, _hash(content), _now(), "api"),
|
||||
)
|
||||
conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (cur.lastrowid, _now(), prow[0]))
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "prompt": call, "content_hash": _hash(content)}
|
||||
|
||||
|
||||
@router.post("/prompts/{call}/activate")
|
||||
async def activate_revision(call: str, payload: dict[str, Any]) -> dict:
|
||||
if call not in _SEEDS:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "unknown call"))
|
||||
rev_id = payload.get("revision_id")
|
||||
conn = get_connection()
|
||||
_seed_prompts(conn)
|
||||
prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM prompt_revisions WHERE id=? AND prompt_id=?", (rev_id, prow[0])
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "revision not found"))
|
||||
conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (rev_id, _now(), prow[0]))
|
||||
conn.commit()
|
||||
return {"ok": True, "active_revision_id": rev_id}
|
||||
|
||||
|
||||
@router.get("/prompts/{call}")
|
||||
async def get_prompt(call: str) -> dict:
|
||||
if call not in _SEEDS:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "unknown call"))
|
||||
conn = get_connection()
|
||||
_seed_prompts(conn)
|
||||
prow = conn.execute("SELECT * FROM prompts WHERE name=?", (call,)).fetchone()
|
||||
revs = conn.execute(
|
||||
"SELECT id, content_hash, created_at, created_by FROM prompt_revisions WHERE prompt_id=? ORDER BY id",
|
||||
(prow[0],),
|
||||
).fetchall()
|
||||
active = conn.execute(
|
||||
"SELECT content FROM prompt_revisions WHERE id=?", (prow["active_revision_id"],)
|
||||
).fetchone()
|
||||
return {
|
||||
"name": call,
|
||||
"active_revision_id": prow["active_revision_id"],
|
||||
"active_content": active[0] if active else None,
|
||||
"revisions": [dict(r) for r in revs],
|
||||
}
|
||||
|
||||
|
||||
# ---------- Inference client (OpenAI-compatible, stdlib) ----------
|
||||
class InferenceClient:
|
||||
def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 60.0):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
|
||||
def chat(self, messages: list[dict]) -> str:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
body = json.dumps({"model": self.model, "messages": messages}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=body,
|
||||
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
raise InferenceError(f"inference HTTP {e.code}") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise InferenceConnectionError(f"inference unreachable: {e.reason}") from e
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
class InferenceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InferenceConnectionError(InferenceError):
|
||||
pass
|
||||
|
||||
|
||||
def _inference_creds(conn) -> tuple[str, str, str]:
|
||||
b = conn.execute("SELECT value_enc FROM settings WHERE key='inference_base_url'").fetchone()
|
||||
k = conn.execute("SELECT value_enc FROM settings WHERE key='inference_api_key'").fetchone()
|
||||
m = conn.execute("SELECT value FROM settings WHERE key='inference_model'").fetchone()
|
||||
return (decrypt(b[0]) if b and b[0] else ""), (decrypt(k[0]) if k and k[0] else ""), (m[0] if m and m[0] else "")
|
||||
|
||||
|
||||
def _enabled(conn, key: str) -> bool:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
return bool(row and row[0] in ("true", "True", "1"))
|
||||
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call1")
|
||||
async def call1(slab_id: str) -> 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"))
|
||||
if not _enabled(conn, "inference_enabled"):
|
||||
return JSONResponse(status_code=409, content=_error("inference_disabled", "Inference is off"))
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||
client = InferenceClient(base, key, model)
|
||||
_seed_prompts(conn)
|
||||
conn.commit()
|
||||
act = _active_prompt(conn, "call1")
|
||||
prompt_text = act[1] if act else CALL1_SEED
|
||||
# Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy.
|
||||
photos = conn.execute(
|
||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts = []
|
||||
photo_meta: list[dict] = []
|
||||
for p in photos:
|
||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||
image_parts.append(_image_part_from_processed(blob))
|
||||
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
||||
tax = _taxonomy_lists(conn)
|
||||
text = (f"{prompt_text}\n\nLISTS:\n{json.dumps(tax, ensure_ascii=False)}\n\n"
|
||||
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
|
||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = client.chat(messages)
|
||||
except InferenceConnectionError as e:
|
||||
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
|
||||
except InferenceError as e:
|
||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
parsed = _parse_json(raw)
|
||||
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
||||
# Apply confidence gate (METADATA-45).
|
||||
suggestions = _gate(parsed)
|
||||
return {"ok": True, "suggestions": suggestions}
|
||||
|
||||
|
||||
def _feat_display_name(name: str) -> str:
|
||||
"""Strip leading feat- / feat_ for the model (#86); Woo id stays authoritative."""
|
||||
n = (name or "").strip()
|
||||
low = n.lower()
|
||||
for prefix in ("feat-", "feat_"):
|
||||
if low.startswith(prefix):
|
||||
return n[len(prefix):].strip() or n
|
||||
return n
|
||||
|
||||
|
||||
def _taxonomy_lists(conn) -> dict:
|
||||
"""Closed Call 1 lists only (#86 / METADATA-40).
|
||||
|
||||
Keys match the seed prompt. No grade/thickness/moisture (owner fields).
|
||||
Feature tag names are shown without the feat- prefix; ids are Woo/local tag ids.
|
||||
Groupers resolved by slug only (QUALITY-105).
|
||||
"""
|
||||
out: dict[str, list] = {
|
||||
"species": [],
|
||||
"wood_categories": [],
|
||||
"edge_terms": [],
|
||||
"figure_terms": [],
|
||||
"feature_tags": [],
|
||||
}
|
||||
# species = leaves under the 'species' grouper
|
||||
sp = conn.execute("SELECT id FROM woo_categories WHERE slug='species'").fetchone()
|
||||
if sp:
|
||||
for r in conn.execute(
|
||||
"SELECT id,name FROM woo_categories WHERE parent_id=?", (sp[0],)
|
||||
).fetchall():
|
||||
out["species"].append({"id": r[0], "name": r[1]})
|
||||
# wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers).
|
||||
wc = conn.execute("SELECT id FROM woo_categories WHERE slug='wcat'").fetchone()
|
||||
if wc:
|
||||
kids = [r[0] for r in conn.execute(
|
||||
"SELECT id FROM woo_categories WHERE parent_id=?", (wc[0],)
|
||||
).fetchall()]
|
||||
leaf_ids = []
|
||||
for cid in kids:
|
||||
has_kids = conn.execute(
|
||||
"SELECT 1 FROM woo_categories WHERE parent_id=? LIMIT 1", (cid,)
|
||||
).fetchone()
|
||||
if has_kids:
|
||||
leaf_ids.extend(r[0] for r in conn.execute(
|
||||
"SELECT id FROM woo_categories WHERE parent_id=?", (cid,)
|
||||
).fetchall())
|
||||
else:
|
||||
leaf_ids.append(cid)
|
||||
if leaf_ids:
|
||||
q = ",".join("?" * len(leaf_ids))
|
||||
for r in conn.execute(
|
||||
"SELECT id,name FROM woo_categories WHERE id IN (%s)" % q, leaf_ids
|
||||
).fetchall():
|
||||
out["wood_categories"].append({"id": r[0], "name": r[1]})
|
||||
for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms")):
|
||||
rows = conn.execute(
|
||||
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id "
|
||||
"WHERE lower(a.name)=lower(?)",
|
||||
(attr,),
|
||||
).fetchall()
|
||||
out[key] = [{"id": r[0], "name": r[1]} for r in rows]
|
||||
for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall():
|
||||
out["feature_tags"].append({"id": r[0], "name": _feat_display_name(r[1])})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_json(raw: str) -> dict:
|
||||
try:
|
||||
s = raw.strip()
|
||||
if s.startswith("```"):
|
||||
s = s.split("```")[1]
|
||||
if s.startswith("json"):
|
||||
s = s[4:]
|
||||
return json.loads(s)
|
||||
except (ValueError, IndexError):
|
||||
return {}
|
||||
|
||||
|
||||
def _gate(parsed: dict) -> dict:
|
||||
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45).
|
||||
|
||||
Confidence is read from the flat '<field>_confidence' keys; as a safety net it also
|
||||
understands the nested 'confidence: {<field>: n}' shape some models emit, so a shape
|
||||
wobble cannot silently nuke every suggestion. Grade is never suggested (METADATA-40).
|
||||
Free-text observation bags are dropped — feature tags are ids only (#86).
|
||||
"""
|
||||
conf_key = {
|
||||
"species_id": "species_confidence",
|
||||
"wood_category_ids": "wood_category_confidence",
|
||||
"edge_type_term_id": "edge_confidence",
|
||||
"figure_term_ids": "figure_confidence",
|
||||
"feature_tag_ids": "feature_tag_confidence",
|
||||
}
|
||||
conf_obj = parsed.get("confidence") if isinstance(parsed.get("confidence"), dict) else {}
|
||||
|
||||
def _conf(field: str) -> Any:
|
||||
k = conf_key.get(field, f"{field}_confidence")
|
||||
if k in conf_obj:
|
||||
return conf_obj[k]
|
||||
return parsed.get(k)
|
||||
|
||||
def keep(field: str) -> Any:
|
||||
conf = _conf(field)
|
||||
if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed:
|
||||
return parsed[field]
|
||||
return None
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
if v := keep("species_id"):
|
||||
out["species_id"] = v
|
||||
if v := keep("wood_category_ids"):
|
||||
out["wood_category_ids"] = v
|
||||
if v := keep("edge_type_term_id"):
|
||||
out["edge_type_term_id"] = v
|
||||
if v := keep("figure_term_ids"):
|
||||
out["figure_term_ids"] = v
|
||||
if v := keep("feature_tag_ids"):
|
||||
out["feature_tag_ids"] = v
|
||||
# grade / free-text bags intentionally omitted (METADATA-40, #86).
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call2")
|
||||
async def call2(slab_id: str) -> 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"))
|
||||
if not _enabled(conn, "content_llm_enabled"):
|
||||
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||
client = InferenceClient(base, key, model)
|
||||
act = _active_prompt(conn, "call2")
|
||||
prompt_text = act[1] if act else CALL2_SEED
|
||||
photos = conn.execute(
|
||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts = []
|
||||
photo_meta: list[dict] = []
|
||||
for p in photos:
|
||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||
image_parts.append(_image_part_from_processed(blob))
|
||||
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
||||
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
||||
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
||||
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
||||
f"length={row['length_in']}in thickness={row['thickness_in']}in "
|
||||
f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in "
|
||||
f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}")
|
||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = client.chat(messages)
|
||||
except InferenceConnectionError as e:
|
||||
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
|
||||
except InferenceError as e:
|
||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
||||
# Store as description, mark content_source=llm.
|
||||
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||
(raw.strip(), _now(), slab_id))
|
||||
conn.commit()
|
||||
return {"ok": True, "description": raw.strip()}
|
||||
|
||||
|
||||
def server_description(slab: dict) -> str:
|
||||
"""Inference-off long description (METADATA-70)."""
|
||||
return (
|
||||
f"A {slab.get('title', 'slab')} from the mill, measured at "
|
||||
f"{slab.get('length_in')}in long, {slab.get('width_avg_in')}in average width, and "
|
||||
f"{slab.get('thickness_in')}in thick ({slab.get('bdft')} bdft). "
|
||||
"Ready to ship."
|
||||
)
|
||||
|
||||
|
||||
def _photo_meta_row(photo_id: str, seq: int, blob: bytes) -> dict[str, Any]:
|
||||
"""Metadata for one photo slot in the inference log (#87)."""
|
||||
edge = None
|
||||
try:
|
||||
im = Image.open(io.BytesIO(blob))
|
||||
im.load()
|
||||
edge = max(im.size)
|
||||
except Exception:
|
||||
edge = None
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"seq": seq,
|
||||
"bytes": len(blob) if blob is not None else 0,
|
||||
"infer_long_edge_px": edge,
|
||||
"label": f"photo seq={seq} id={photo_id}",
|
||||
}
|
||||
|
||||
|
||||
def _redact_request_for_log(messages: list, photo_meta: list[dict] | None = None) -> list:
|
||||
"""Replace image data-URLs with photo refs for inference_log (#87 / METADATA-66).
|
||||
|
||||
Wire messages still carry base64 for the model; only the logged copy is redacted.
|
||||
Text parts are kept in full. Image parts become type=image_ref using photo_meta by
|
||||
image-part index (same order as built from slab_photos).
|
||||
"""
|
||||
meta = list(photo_meta or [])
|
||||
out: list = []
|
||||
img_i = 0
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
out.append(msg)
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
out.append(msg)
|
||||
continue
|
||||
new_parts: list = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
new_parts.append(part)
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
new_parts.append({"type": "text", "text": part.get("text", "")})
|
||||
continue
|
||||
if ptype == "image_url":
|
||||
url = ""
|
||||
iu = part.get("image_url")
|
||||
if isinstance(iu, dict):
|
||||
url = str(iu.get("url") or "")
|
||||
if url.startswith("data:image") or "base64," in url:
|
||||
ref = {
|
||||
"type": "image_ref",
|
||||
"photo_id": None,
|
||||
"seq": None,
|
||||
"bytes": None,
|
||||
"infer_long_edge_px": None,
|
||||
"label": f"photo slot={img_i}",
|
||||
}
|
||||
if img_i < len(meta):
|
||||
m = meta[img_i]
|
||||
ref.update({
|
||||
"photo_id": m.get("photo_id"),
|
||||
"seq": m.get("seq"),
|
||||
"bytes": m.get("bytes"),
|
||||
"infer_long_edge_px": m.get("infer_long_edge_px"),
|
||||
"label": m.get("label") or ref["label"],
|
||||
})
|
||||
new_parts.append(ref)
|
||||
img_i += 1
|
||||
else:
|
||||
# Non-data URL (rare): keep URL string only, never invent bytes
|
||||
new_parts.append({
|
||||
"type": "image_ref",
|
||||
"url": url[:200],
|
||||
"label": f"external image slot={img_i}",
|
||||
})
|
||||
img_i += 1
|
||||
continue
|
||||
new_parts.append(part)
|
||||
out.append({**msg, "content": new_parts})
|
||||
return out
|
||||
|
||||
|
||||
_LOG_PAYLOAD_CAP = 200_000
|
||||
|
||||
|
||||
def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str,
|
||||
request: list, response: str, latency_ms: int,
|
||||
photo_meta: list[dict] | None = None) -> None:
|
||||
"""Append inference_log with redacted request (no image bytes) — #87 / METADATA-66."""
|
||||
redacted = _redact_request_for_log(request, photo_meta)
|
||||
payload = json.dumps(redacted, ensure_ascii=False)
|
||||
if len(payload) > _LOG_PAYLOAD_CAP:
|
||||
# Safety net only; after redaction normal calls are far under the cap.
|
||||
payload = payload[: _LOG_PAYLOAD_CAP - 20] + "\n/* truncated */"
|
||||
conn.execute(
|
||||
"INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(slab_id, call, rev_id, model, payload, response, latency_ms, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@router.post("/admin/test-inference")
|
||||
async def test_inference() -> dict:
|
||||
conn = get_connection()
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": "Inference is not configured"}
|
||||
client = InferenceClient(base, key, model, timeout=20.0)
|
||||
t0 = time.time()
|
||||
try:
|
||||
client.chat([{"role": "user", "content": "Reply with the single word: ok"}])
|
||||
except InferenceConnectionError as e:
|
||||
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
|
||||
except InferenceError as e:
|
||||
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
|
||||
detail = f"reachable in {int((time.time()-t0)*1000)}ms"
|
||||
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .db import get_connection
|
||||
from .woo import (
|
||||
WooAuthError,
|
||||
WooClient,
|
||||
WooConnectionError,
|
||||
WooCredentialsMissing,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
)
|
||||
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, 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) -> 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.
|
||||
base, user, enc = _woo_creds(conn)
|
||||
try:
|
||||
client = WooClient.from_settings(base, user, enc)
|
||||
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]:
|
||||
import os
|
||||
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (u[0] if u else None), (p[0] if p else None)
|
||||
|
||||
|
||||
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()),
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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
|
||||
@@ -0,0 +1,240 @@
|
||||
"""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",
|
||||
"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)
|
||||
primary_id = payload.get("primary_wood_category_id")
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
if not primary_id:
|
||||
primary_id = wood_cats[0] if wood_cats else None
|
||||
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
|
||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") 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 = (
|
||||
build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
if (primary_leaf and species_name)
|
||||
else slab.get("title") or None
|
||||
)
|
||||
short_desc = (
|
||||
build_short_description(slab, figure, species_name, primary_leaf, terms)
|
||||
if (primary_leaf and species_name)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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",
|
||||
"crop_fill",
|
||||
}
|
||||
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
||||
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
|
||||
NUMERIC_KEYS: set[str] = {"crop_fill"}
|
||||
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"inference_enabled": False,
|
||||
"content_llm_enabled": False,
|
||||
"woo_create_status": "draft",
|
||||
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
|
||||
"crop_fill": 90,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
if k in NUMERIC_KEYS:
|
||||
try:
|
||||
f = float(result[k])
|
||||
result[k] = int(f) if f.is_integer() else f
|
||||
except (TypeError, ValueError):
|
||||
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"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# #99: rules live here in the 4xx layer, not in the DB (structural only).
|
||||
if "crop_fill" in payload:
|
||||
raw = payload["crop_fill"]
|
||||
try:
|
||||
pct = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
pct = None
|
||||
if pct is None or not (1 <= pct <= 100):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "validation",
|
||||
"message": "crop_fill must be a number between 1 and 100 (percent)",
|
||||
"detail": {"field": "crop_fill"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,309 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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 (METADATA-15). taxonomy_anchor is a
|
||||
stable hash of the stored subset, recorded only when it changes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
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 _scoped_tags(tags: list[dict]) -> list[dict]:
|
||||
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
|
||||
|
||||
|
||||
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"], 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"], 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, 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"], 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,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""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()
|
||||
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"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""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_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_confidence_gate(env, monkeypatch):
|
||||
client, conn = env
|
||||
# enable inference
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
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 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
|
||||
|
||||
def fake_chat(self, messages):
|
||||
# Capture list header for #86 shape
|
||||
text = messages[0]["content"][-1]["text"]
|
||||
assert "LISTS:" in text
|
||||
assert "grade_terms" not in text
|
||||
assert "species_leaves" not in text
|
||||
return json.dumps({
|
||||
"species_id": 1, "species_confidence": 0.9,
|
||||
"wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold
|
||||
"grade_term_id": 7, "grade_confidence": 0.99, # never suggested
|
||||
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
|
||||
"feature_tag_ids": [9], "feature_tag_confidence": 0.9,
|
||||
"character": ["x"], "inclusions": ["y"],
|
||||
})
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
s = r.json()["suggestions"]
|
||||
assert s.get("species_id") == 1
|
||||
assert s.get("figure_term_ids") == [2]
|
||||
assert s.get("feature_tag_ids") == [9]
|
||||
assert "wood_category_ids" not in s # gated out
|
||||
assert "grade_term_id" not in s # never
|
||||
assert "character" not in s
|
||||
# inference_log written with call=1
|
||||
assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1
|
||||
|
||||
|
||||
def test_call2_stores_description(env, monkeypatch):
|
||||
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 Slab','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"] == "<p>A fine walnut slab.</p>"
|
||||
row = conn.execute("SELECT description, content_source FROM slabs WHERE id='s1'").fetchone()
|
||||
assert row["description"] == "<p>A fine walnut slab.</p>"
|
||||
assert row["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_inference_off_call1_409(env):
|
||||
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 == 409 # inference_enabled default off
|
||||
|
||||
|
||||
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_has_text_and_image_refs_no_base64(env, monkeypatch):
|
||||
"""#87 METADATA-66: inference_log stores text+refs, not image bytes."""
|
||||
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')"
|
||||
)
|
||||
png = _rgba_png(400, 300)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('photo-abc','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
def fake_chat(self, messages):
|
||||
# Wire path still has data-URL for the model
|
||||
content = messages[0]["content"]
|
||||
assert any(
|
||||
isinstance(p, dict) and p.get("type") == "image_url"
|
||||
and str(p.get("image_url", {}).get("url", "")).startswith("data:image")
|
||||
for p in content
|
||||
)
|
||||
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
row = conn.execute(
|
||||
"SELECT request_payload, length(request_payload) AS n FROM inference_log ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
payload = row["request_payload"]
|
||||
assert row["n"] < 50000
|
||||
assert "data:image" not in payload
|
||||
assert "LISTS:" in payload
|
||||
assert "image_ref" in payload
|
||||
assert "photo-abc" in payload
|
||||
assert '"seq": 1' in payload or '"seq":1' in payload
|
||||
logged = json.loads(payload)
|
||||
refs = [p for p in logged[0]["content"] if p.get("type") == "image_ref"]
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["photo_id"] == "photo-abc"
|
||||
assert refs[0]["bytes"] == len(png)
|
||||
|
||||
|
||||
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_at_most_1024(env, monkeypatch):
|
||||
"""METADATA-35 end-to-end: messages to the model use <=1024 long edge."""
|
||||
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')"
|
||||
)
|
||||
big = _rgba_png(800, 1067)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
|
||||
(big,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
captured["messages"] = messages
|
||||
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
content = captured["messages"][0]["content"]
|
||||
imgs = [p for p in content if isinstance(p, dict) and p.get("type") == "image_url"]
|
||||
assert len(imgs) == 1
|
||||
url = imgs[0]["image_url"]["url"]
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
raw = base64.b64decode(url.split(",", 1)[1])
|
||||
im = Image.open(io.BytesIO(raw))
|
||||
assert max(im.size) <= 1024
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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()
|
||||
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
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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
|
||||
@@ -0,0 +1,362 @@
|
||||
"""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()
|
||||
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()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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 "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
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,62 @@
|
||||
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() == []
|
||||
@@ -0,0 +1,188 @@
|
||||
"""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()
|
||||
|
||||
|
||||
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 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
|
||||
assert resp.json()["crop_fill"] == 90
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||
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={"crop_fill": 75})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 75
|
||||
conn = db_mod.get_connection()
|
||||
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||
assert row["value"] == "75" and row["value_enc"] is None
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
for bad in (0, 101, -5, "abc", None):
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||
assert resp.json()["error"]["code"] == "validation"
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||
# boundaries are valid
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||
db_mod._reset_for_tests()
|
||||
@@ -0,0 +1,384 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
# Helper to set environment for tests
|
||||
|
||||
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", "aGVsbGxvb2xvY2g=") # dummy base64
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
# Taxonomy seeding helper
|
||||
|
||||
def _seed_taxonomy(conn):
|
||||
# create some categories
|
||||
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (1, 'Species A', 'species-a', NULL, 10)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (2, 'Wood Cat A', 'wood-cat-a', NULL, 20)")
|
||||
# attribute terms
|
||||
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (1, 'Edge Type', 'edge-type', 30)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (1, 1, 'Edge A', 'edge-a', 40)")
|
||||
# tags
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
|
||||
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
|
||||
# live Woo attribute, because the derivation parses them.
|
||||
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
|
||||
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 OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
|
||||
"VALUES (?, 2, ?, ?, ?)",
|
||||
(tid, name, slug, tid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
conn = db_mod.get_connection()
|
||||
db_mod.init_db()
|
||||
_seed_taxonomy(conn)
|
||||
return conn
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
conn = db_mod.get_connection()
|
||||
db_mod.init_db()
|
||||
_seed_taxonomy(conn)
|
||||
return conn
|
||||
|
||||
# Helper to get client
|
||||
@pytest.fixture
|
||||
def client(db):
|
||||
from backend.app import app
|
||||
return TestClient(app)
|
||||
|
||||
# Utility to format now
|
||||
_now_iso = lambda: datetime.datetime.utcnow().replace(microsecond=0).isoformat()
|
||||
|
||||
# --- Tests ---
|
||||
|
||||
|
||||
def test_create_and_fetch(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id,
|
||||
"sku": "SKU001",
|
||||
"length_in": 10.0,
|
||||
"thickness_in": 2.0,
|
||||
"sqft": 20.0,
|
||||
"bdft": 5.0,
|
||||
"width_min_in": 1.0,
|
||||
"width_max_in": 2.0,
|
||||
"width_avg_in": 1.5,
|
||||
"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([1]),
|
||||
"feat_tag_ids": json.dumps([2]),
|
||||
"price": 100.0,
|
||||
"price_source": "recommendation",
|
||||
"title": "Test Slab",
|
||||
"short_description": "short",
|
||||
"description": "desc",
|
||||
"content_source": "manual",
|
||||
"species_confidence": 0.9,
|
||||
"figure_confidence": 0.8,
|
||||
"grade_confidence": 0.7,
|
||||
"inference_status": "inferring",
|
||||
"calibration_photo_id": None
|
||||
}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["status"] == "calibrated"
|
||||
assert body["client_rev"] == 1
|
||||
assert body["server_rev"] == 1
|
||||
for key in ["length_in","thickness_in","sqft","bdft","width_min_in","width_max_in","width_avg_in"]:
|
||||
assert body[key] == payload[key]
|
||||
|
||||
|
||||
def test_create_derives_thickness_band(client, db):
|
||||
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
|
||||
|
||||
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
|
||||
the UI has no band control, so the server's derivation is authoritative.
|
||||
"""
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
"thickness_term_id": 1,
|
||||
}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
|
||||
|
||||
|
||||
def test_update_rebands_when_thickness_changes(client, db):
|
||||
"""A thickness edit moves the slab to the band the new measurement rounds into."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
})
|
||||
assert create.status_code == 201
|
||||
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
|
||||
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
|
||||
})
|
||||
assert put.status_code == 200
|
||||
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
|
||||
|
||||
|
||||
def test_update_ignores_client_supplied_band(client, db):
|
||||
"""A stale client band cannot pin the slab to the wrong band."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
})
|
||||
assert create.status_code == 201
|
||||
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
|
||||
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
|
||||
})
|
||||
assert put.status_code == 200
|
||||
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
|
||||
|
||||
|
||||
def test_duplicate_sku(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
resp1 = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp1.status_code == 201
|
||||
resp2 = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp2.status_code == 409
|
||||
|
||||
|
||||
def test_list_and_exclude_deleted(client, db):
|
||||
# create two slabs
|
||||
for i in [1,2]:
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": f"SKU{i}", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# delete first created
|
||||
resp_list = client.get("/api/v1/slabs")
|
||||
ids = [r["id"] for r in resp_list.json()]
|
||||
client.delete(f"/api/v1/slabs/{ids[0]}")
|
||||
# list again
|
||||
resp2 = client.get("/api/v1/slabs")
|
||||
body = resp2.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["id"] == ids[1]
|
||||
|
||||
|
||||
def test_get_not_found(client, db):
|
||||
resp = client.get("/api/v1/slabs/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_update_success(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# update
|
||||
update_payload = {"client_rev":1, "sku":"SKU002"}
|
||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json=update_payload)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["sku"] == "SKU002"
|
||||
assert body["server_rev"] == 2
|
||||
|
||||
|
||||
def test_update_revision_conflict(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# wrong client_rev
|
||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev":99, "sku":"SKU002"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_update_duplicate_sku(client, db):
|
||||
# create two slabs
|
||||
id1, id2 = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
payload1 = {"id": id1, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
payload2 = {"id": id2, "sku": "SKU002", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload1)
|
||||
client.post("/api/v1/slabs", json=payload2)
|
||||
# update first to sku of second
|
||||
resp = client.put(f"/api/v1/slabs/{id1}", json={"client_rev":1, "sku":"SKU002"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_soft_delete(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
client.delete(f"/api/v1/slabs/{slab_id}")
|
||||
resp = client.get("/api/v1/slabs")
|
||||
body = resp.json()
|
||||
assert all(r["id"] != slab_id for r in body)
|
||||
resp_get = client.get(f"/api/v1/slabs/{slab_id}")
|
||||
assert resp_get.status_code == 404
|
||||
|
||||
|
||||
def test_create_empty_sku_stored_as_null(client, db):
|
||||
# Business rule (middle tier): empty SKU at create = "not set yet" -> stored as NULL,
|
||||
# not rejected by the DB CHECK. This is the UAT "Continue to Review" path.
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["sku"] is None
|
||||
# A second empty-SKU slab is allowed (NULLs are not unique-conflicts).
|
||||
resp2 = client.post("/api/v1/slabs", json={**payload, "id": str(uuid.uuid4())})
|
||||
assert resp2.status_code == 201
|
||||
|
||||
|
||||
def test_create_null_sku_allowed(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": None, "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["sku"] is None
|
||||
|
||||
|
||||
def test_gate_reads_flat_confidence_keys(client, db):
|
||||
# Regression: the gate's confidence keys are 'species_confidence' / 'edge_confidence'
|
||||
# etc., NOT f'{field}_confidence' (field 'species_id' would give the wrong key). A
|
||||
# high-confidence response must surface its picks; low-confidence ones must not.
|
||||
from backend.inference import _gate, _parse_json
|
||||
good = '```json\n{"species_id": 284, "species_confidence": 0.85, "wood_category_ids": [251], "wood_category_confidence": 1.0, "edge_type_term_id": 323, "edge_confidence": 0.95, "figure_term_ids": [332], "figure_confidence": 0.9, "feature_tag_ids": [1], "feature_tag_confidence": 0.8, "character": [], "inclusions": ["bark"], "voids": [], "checks": []}\n```'
|
||||
out = _gate(_parse_json(good))
|
||||
assert out["species_id"] == 284
|
||||
assert out["wood_category_ids"] == [251]
|
||||
assert out["edge_type_term_id"] == 323
|
||||
assert out["figure_term_ids"] == [332]
|
||||
assert out["feature_tag_ids"] == [1]
|
||||
# grade must never be gated in (METADATA-40)
|
||||
assert "grade_term_id" not in out
|
||||
# free-text bags dropped (#86)
|
||||
assert "character" not in out
|
||||
assert "inclusions" not in out
|
||||
# low-confidence fields are dropped (METADATA-45)
|
||||
low = '```json\n{"species_id": 284, "species_confidence": 0.4, "wood_category_ids": [251], "wood_category_confidence": 0.8, "edge_type_term_id": 323, "edge_confidence": 0.2, "figure_term_ids": [332], "figure_confidence": 0.9}\n```'
|
||||
out2 = _gate(_parse_json(low))
|
||||
assert out2["wood_category_ids"] == [251]
|
||||
assert out2["figure_term_ids"] == [332]
|
||||
assert "species_id" not in out2
|
||||
assert "edge_type_term_id" not in out2
|
||||
|
||||
|
||||
def test_ready_gate_rejects_malformed_sku(client, db):
|
||||
# REVIEW-45 enforced in the middle tier: a present-but-out-of-range/char-set SKU is not ready.
|
||||
from backend.review import compute_ready, sku_valid
|
||||
base = {"length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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,"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual"}
|
||||
# valid
|
||||
assert sku_valid("AB-1") is True
|
||||
ready, missing = compute_ready({**base, "sku": "AB-1"})
|
||||
assert ready is True and "sku" not in missing
|
||||
# too short
|
||||
assert sku_valid("AB") is False
|
||||
ready, missing = compute_ready({**base, "sku": "AB"})
|
||||
assert ready is False and "sku" in missing
|
||||
# bad char
|
||||
ready, missing = compute_ready({**base, "sku": "AB!"})
|
||||
assert ready is False and "sku" in missing
|
||||
# too long
|
||||
ready, missing = compute_ready({**base, "sku": "A" * 25})
|
||||
assert ready is False and "sku" in missing
|
||||
# missing (None)
|
||||
ready, missing = compute_ready({**base, "sku": None})
|
||||
assert ready is False and "sku" in missing
|
||||
|
||||
|
||||
def test_delete_published_conflict(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"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([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# manually set status to published
|
||||
conn = db_mod.get_connection()
|
||||
conn.execute("UPDATE slabs SET status = 'published' WHERE id = ?", (slab_id,))
|
||||
conn.commit()
|
||||
resp = client.delete(f"/api/v1/slabs/{slab_id}")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_create_rejects_unknown_fields(client, db):
|
||||
# #83: POST /slabs with extra (non-ALLOWED_FIELDS) keys → 400, not a silent drop.
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5, "bogus_field": 42}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["error"]["code"] == "validation"
|
||||
assert "bogus_field" in body["error"]["detail"]["fields"]
|
||||
|
||||
|
||||
def test_update_stale_field_returns_live_options(client, db):
|
||||
# #82: stale_field returns LIVE taxonomy options (id+name), not the submitted id.
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
}
|
||||
assert client.post("/api/v1/slabs", json=payload).status_code == 201
|
||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["error"]["code"] == "stale_field"
|
||||
opts = body["error"]["detail"]["current_options"]
|
||||
assert isinstance(opts, list) and opts
|
||||
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
|
||||
assert 999 not in [o["id"] for o in opts]
|
||||
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
|
||||
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
|
||||
base = {
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
"status": "published", # server-managed → unknown field 400
|
||||
}
|
||||
r = client.post("/api/v1/slabs", json=base)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "status" in r.json()["error"]["detail"]["fields"]
|
||||
# Bad taxonomy on create
|
||||
r2 = client.post("/api/v1/slabs", json={
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "species_id": 999,
|
||||
})
|
||||
assert r2.status_code == 422
|
||||
assert r2.json()["error"]["code"] == "stale_field"
|
||||
# Bad enum
|
||||
r3 = client.post("/api/v1/slabs", json={
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
|
||||
})
|
||||
assert r3.status_code == 400
|
||||
assert r3.json()["error"]["code"] == "validation"
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Thickness band resolution (METADATA-15 / QUALITY-95).
|
||||
|
||||
The owner never picks the Woo thickness band in the UI — the server derives it
|
||||
from the measured thickness_in against the live synced bands. These tests pin the
|
||||
derivation on the real live band names (from the UAT taxonomy) and on the
|
||||
quarter-inch slug fallback.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.thickness_band import (
|
||||
parse_inch_token,
|
||||
resolve_thickness_band_id,
|
||||
term_bounds,
|
||||
)
|
||||
|
||||
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
|
||||
LIVE_TERMS = [
|
||||
{"id": 347, "name": "Under 1/2\"", "slug": "xthin"},
|
||||
{"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"},
|
||||
{"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"},
|
||||
{"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"},
|
||||
{"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"},
|
||||
{"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"},
|
||||
{"id": 353, "name": "Over 3\"", "slug": "12qtr"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("1 1/2", 1.5),
|
||||
("3/4", 0.75),
|
||||
("2", 2.0),
|
||||
("1.75", 1.75),
|
||||
("2\"", 2.0),
|
||||
("1 1/2\"", 1.5),
|
||||
],
|
||||
)
|
||||
def test_parse_inch_token(text, expected):
|
||||
assert parse_inch_token(text) == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_parse_inch_token_rejects_junk():
|
||||
assert parse_inch_token("") is None
|
||||
assert parse_inch_token(" ") is None
|
||||
|
||||
|
||||
def test_term_bounds_from_live_names():
|
||||
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
|
||||
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
|
||||
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
|
||||
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
|
||||
|
||||
|
||||
def test_term_bounds_falls_back_to_qtr_slug():
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thickness,expected_id",
|
||||
[
|
||||
(0.25, 347), # Under 1/2"
|
||||
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
|
||||
(0.625, 348),
|
||||
(0.75, 349), # boundary rounds UP
|
||||
(0.875, 349),
|
||||
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
|
||||
(1.25, 350),
|
||||
(1.5, 351),
|
||||
(1.75, 351), # the UAT sample thickness
|
||||
(2.0, 352),
|
||||
(2.75, 352),
|
||||
(3.0, 353),
|
||||
(4.0, 353),
|
||||
],
|
||||
)
|
||||
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
|
||||
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
|
||||
|
||||
|
||||
def test_resolve_handles_missing_inputs():
|
||||
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
|
||||
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
|
||||
assert resolve_thickness_band_id(1.0, []) is None
|
||||
# A term with no parseable name/slug is skipped, never guessed at.
|
||||
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
|
||||
|
||||
|
||||
def test_resolve_is_id_order_independent():
|
||||
"""The result must not depend on the sync order of the terms."""
|
||||
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351
|
||||
@@ -0,0 +1,232 @@
|
||||
"""S4 Woo client + taxonomy sync tests (mocked transport; never hit the real store)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.db as db_mod
|
||||
from backend.woo import (
|
||||
WooAuthError,
|
||||
WooClient,
|
||||
WooConnectionError,
|
||||
WooCredentialsMissing,
|
||||
WooForbiddenError,
|
||||
WooServerError,
|
||||
)
|
||||
from backend import taxonomy as taxonomy_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_dir():
|
||||
tmp = tempfile.mkdtemp(prefix="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()
|
||||
db_mod._reset_for_tests()
|
||||
conn = db_mod.init_db()
|
||||
from backend.crypto import encrypt
|
||||
|
||||
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"),))
|
||||
conn.commit()
|
||||
yield conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _client(responses):
|
||||
"""Build a WooClient whose transport returns pre-canned (code, headers, body) per call."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
i = min(calls["n"], len(responses) - 1)
|
||||
calls["n"] += 1
|
||||
code, hdr, body = responses[i]
|
||||
return code, hdr, body
|
||||
|
||||
return WooClient("admin", "secret", "https://store.example.com", request=fake_request), calls
|
||||
|
||||
|
||||
def test_refuses_non_https(db_dir):
|
||||
with pytest.raises(WooCredentialsMissing):
|
||||
WooClient("admin", "secret", "http://insecure.example.com")
|
||||
|
||||
|
||||
def test_basic_auth_header_and_url(db_dir):
|
||||
seen = {}
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
seen["url"] = url
|
||||
seen["auth"] = headers["Authorization"]
|
||||
return 200, {"X-WP-TotalPages": "1"}, b"[]"
|
||||
|
||||
c = WooClient("admin", "secret", "https://store.example.com", request=fake_request)
|
||||
c.get_categories()
|
||||
assert seen["url"] == "https://store.example.com/wp-json/wc/v3/products/categories?per_page=100&page=1"
|
||||
expected = "Basic " + base64.b64encode(b"admin:secret").decode()
|
||||
assert seen["auth"] == expected
|
||||
|
||||
|
||||
def test_pagination_follows_total_pages(db_dir):
|
||||
p1 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":1}]')
|
||||
p2 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":2}]')
|
||||
c, calls = _client([p1, p2])
|
||||
data = c.get_tags()
|
||||
assert [d["id"] for d in data] == [1, 2]
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_missing_password_fails_closed(db_dir):
|
||||
with pytest.raises(WooCredentialsMissing):
|
||||
WooClient.from_settings("https://store.example.com", "admin", None)
|
||||
|
||||
|
||||
def test_401_raises_auth_error(db_dir):
|
||||
def fake_request(url, method, data, headers):
|
||||
raise WooAuthError("authentication rejected (401)")
|
||||
|
||||
c = WooClient("admin", "secret", "https://store.example.com", request=fake_request)
|
||||
with pytest.raises(WooAuthError):
|
||||
c.get_categories()
|
||||
|
||||
|
||||
def test_sync_populates_scoped(db_dir):
|
||||
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
|
||||
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||
{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20},
|
||||
{"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21},
|
||||
{"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped
|
||||
]).encode())
|
||||
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode())
|
||||
terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode())
|
||||
c, _ = _client([cats, tags, attrs, terms])
|
||||
result = taxonomy_mod.sync(c, db_mod.get_connection())
|
||||
conn = db_mod.get_connection()
|
||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1]
|
||||
tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")}
|
||||
assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped
|
||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6]
|
||||
assert result["taxonomy_anchor"]
|
||||
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
|
||||
|
||||
|
||||
def test_sync_failure_keeps_previous_cache(db_dir):
|
||||
conn = db_mod.get_connection()
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (99,'Old','old',NULL,1)")
|
||||
conn.commit()
|
||||
|
||||
def bad_request(url, method, data, headers):
|
||||
raise WooServerError("boom")
|
||||
|
||||
c = WooClient("admin", "secret", "https://store.example.com", request=bad_request)
|
||||
with pytest.raises(WooServerError):
|
||||
taxonomy_mod.sync(c, conn)
|
||||
# previous cache retained
|
||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [99]
|
||||
|
||||
|
||||
def test_anchor_stable_across_identical_syncs(db_dir):
|
||||
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
|
||||
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}]).encode())
|
||||
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||
c1, _ = _client([cats, tags, attrs])
|
||||
r1 = taxonomy_mod.sync(c1, db_mod.get_connection())
|
||||
c2, _ = _client([cats, tags, attrs])
|
||||
r2 = taxonomy_mod.sync(c2, db_mod.get_connection())
|
||||
assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"]
|
||||
|
||||
|
||||
def test_sync_maps_woo_parent_field_to_parent_id(db_dir):
|
||||
"""Woo REST categories use `parent` (0 = top-level), not `parent_id`.
|
||||
|
||||
Live UAT lost hierarchy when sync only read parent_id (SETTINGS-35 / METADATA-10).
|
||||
Fixture ids are local to this test — not production store ids.
|
||||
"""
|
||||
cats = (
|
||||
200,
|
||||
{"X-WP-TotalPages": "1"},
|
||||
json.dumps(
|
||||
[
|
||||
{"id": 10, "name": "Species", "slug": "species", "parent": 0},
|
||||
{"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10},
|
||||
{"id": 20, "name": "Wood category", "slug": "wcat", "parent": 0},
|
||||
{"id": 21, "name": "Live edge wood", "slug": "liveedge", "parent": 20},
|
||||
]
|
||||
).encode(),
|
||||
)
|
||||
tags = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||
c, _ = _client([cats, tags, attrs])
|
||||
taxonomy_mod.sync(c, db_mod.get_connection())
|
||||
conn = db_mod.get_connection()
|
||||
rows = {
|
||||
r[0]: {"name": r[1], "slug": r[2], "parent_id": r[3], "woo_id": r[4]}
|
||||
for r in conn.execute("SELECT id, name, slug, parent_id, woo_id FROM woo_categories")
|
||||
}
|
||||
assert rows[10]["parent_id"] is None
|
||||
assert rows[11]["parent_id"] == 10
|
||||
assert rows[20]["parent_id"] is None
|
||||
assert rows[21]["parent_id"] == 20
|
||||
# woo_id falls back to Woo category id when the payload has no woo_id field
|
||||
assert rows[11]["woo_id"] == 11
|
||||
|
||||
|
||||
def test_sync_with_slab_species_fk_does_not_fail(db_dir):
|
||||
"""Live UAT: DELETE woo_categories fails when a slab still points at species_id."""
|
||||
conn = db_mod.get_connection()
|
||||
conn.execute(
|
||||
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Old Cedar','old-cedar',NULL,11)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id, status, species_id, server_rev, created_at, updated_at) "
|
||||
"VALUES ('slab-fk-1', 'draft', 11, 1, 't', 't')"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
cats = (
|
||||
200,
|
||||
{"X-WP-TotalPages": "1"},
|
||||
json.dumps(
|
||||
[
|
||||
{"id": 10, "name": "Species", "slug": "species", "parent": 0},
|
||||
{"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10},
|
||||
]
|
||||
).encode(),
|
||||
)
|
||||
tags = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||
c, _ = _client([cats, tags, attrs])
|
||||
taxonomy_mod.sync(c, conn)
|
||||
rows = {
|
||||
r[0]: r[3]
|
||||
for r in conn.execute("SELECT id, name, slug, parent_id FROM woo_categories")
|
||||
}
|
||||
assert rows[11] == 10 # parent mapped; row kept for FK
|
||||
assert conn.execute("SELECT species_id FROM slabs WHERE id='slab-fk-1'").fetchone()[0] == 11
|
||||
|
||||
|
||||
def test_403_raises_forbidden_error(db_dir):
|
||||
def fake_request(url, method, data, headers):
|
||||
raise WooForbiddenError("request forbidden (403)")
|
||||
|
||||
c = WooClient("admin", "secret", "https://store.example.com", request=fake_request)
|
||||
with pytest.raises(WooForbiddenError):
|
||||
c.get_categories()
|
||||
|
||||
|
||||
def test_requests_include_user_agent(db_dir):
|
||||
seen_headers = {}
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
seen_headers.update(headers)
|
||||
return 200, {"X-WP-TotalPages": "1"}, b"[]"
|
||||
|
||||
c = WooClient("admin", "secret", "https://store.example.com", request=fake_request)
|
||||
c.get_categories()
|
||||
assert "User-Agent" in seen_headers
|
||||
assert "SlabUploader" in seen_headers["User-Agent"]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
|
||||
|
||||
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
|
||||
synced band term. We map thickness_in into the first half-open band [low, high)
|
||||
that contains it, derived only from the live synced term names/slugs — never a
|
||||
hardcoded Woo term id.
|
||||
|
||||
Round-up semantics: bands are half-open on the upper bound, so a value that sits
|
||||
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
|
||||
value (1"–1 1/2"), not the band that ends there (3/4"–1").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# Mixed number / fraction / decimal inch token, optional trailing quote.
|
||||
_INCH_RE = re.compile(
|
||||
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
|
||||
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
|
||||
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
|
||||
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_inch_token(text: str) -> Optional[float]:
|
||||
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
|
||||
t = (text or "").strip().rstrip("\"'").strip()
|
||||
if not t:
|
||||
return None
|
||||
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
|
||||
if not m:
|
||||
return None
|
||||
if m.group("a") is not None:
|
||||
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
|
||||
if m.group("n2") is not None:
|
||||
return float(m.group("n2")) / float(m.group("d2"))
|
||||
if m.group("dec") is not None:
|
||||
return float(m.group("dec"))
|
||||
return None
|
||||
|
||||
|
||||
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
|
||||
"""Return half-open [low, high) inches from a Woo thickness term name."""
|
||||
raw = (name or "").strip()
|
||||
low_s = raw.lower()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# Under X / less than X / < X
|
||||
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
|
||||
if m:
|
||||
hi = parse_inch_token(m.group(1))
|
||||
if hi is not None:
|
||||
return (0.0, hi)
|
||||
|
||||
# Over X / above X / > X / X+
|
||||
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
|
||||
if m:
|
||||
lo = parse_inch_token(m.group(1))
|
||||
if lo is not None:
|
||||
return (lo, float("inf"))
|
||||
m = re.match(r"^(.+?)\+\s*$", raw)
|
||||
if m:
|
||||
lo = parse_inch_token(m.group(1))
|
||||
if lo is not None:
|
||||
return (lo, float("inf"))
|
||||
|
||||
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
|
||||
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
lo = parse_inch_token(parts[0])
|
||||
hi = parse_inch_token(parts[1])
|
||||
if lo is not None and hi is not None and hi > lo:
|
||||
return (lo, hi)
|
||||
|
||||
# Single value band treated as [v, v+epsilon) — rare
|
||||
only = parse_inch_token(raw)
|
||||
if only is not None:
|
||||
return (only, only + 1e-9)
|
||||
return None
|
||||
|
||||
|
||||
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
|
||||
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
|
||||
s = (slug or "").strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if s in {"xthin", "under", "thin"}:
|
||||
return (0.0, 0.5)
|
||||
m = _QTR_RANGE_RE.match(s)
|
||||
if m:
|
||||
lo = int(m.group(1)) * 0.25
|
||||
hi = int(m.group(2)) * 0.25
|
||||
if hi > lo:
|
||||
return (lo, hi)
|
||||
m = _QTR_MIN_RE.match(s)
|
||||
if m:
|
||||
lo = int(m.group(1)) * 0.25
|
||||
return (lo, float("inf"))
|
||||
return None
|
||||
|
||||
|
||||
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
|
||||
"""Bounds for one synced thickness term; name first, slug fallback."""
|
||||
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
|
||||
str(term.get("slug") or "")
|
||||
)
|
||||
|
||||
|
||||
def resolve_thickness_band_id(
|
||||
thickness_in: float | int | None,
|
||||
terms: Iterable[dict[str, Any]],
|
||||
) -> Optional[int]:
|
||||
"""Pick the synced thickness term id for thickness_in, or None if unknown.
|
||||
|
||||
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
|
||||
"""
|
||||
if thickness_in is None:
|
||||
return None
|
||||
try:
|
||||
t = float(thickness_in)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if t < 0:
|
||||
return None
|
||||
|
||||
scored: list[tuple[float, float, int]] = [] # (low, high, id)
|
||||
for term in terms:
|
||||
b = term_bounds(term)
|
||||
if b is None:
|
||||
continue
|
||||
tid = term.get("id")
|
||||
if tid is None:
|
||||
continue
|
||||
lo, hi = b
|
||||
scored.append((lo, hi, int(tid)))
|
||||
|
||||
if not scored:
|
||||
return None
|
||||
|
||||
# Prefer the tightest band that contains t under half-open [low, high).
|
||||
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
|
||||
if matches:
|
||||
matches.sort() # tightest span first
|
||||
return matches[0][3]
|
||||
|
||||
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
|
||||
scored.sort(key=lambda x: x[0])
|
||||
for lo, hi, tid in scored:
|
||||
if t < hi:
|
||||
return tid
|
||||
return scored[-1][2]
|
||||
|
||||
|
||||
def load_thickness_terms(conn) -> list[dict[str, Any]]:
|
||||
"""Live Thickness attribute terms from the synced Woo cache."""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
|
||||
("thickness", "thickness"),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return []
|
||||
attr_id = row[0]
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
|
||||
(attr_id,),
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
|
||||
|
||||
Creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds
|
||||
raise WooCredentialsMissing (a fail-closed condition the caller maps to ok:false),
|
||||
distinct from WooAuthError (a 401 from the store).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
from .crypto import DecryptError, decrypt
|
||||
|
||||
# WordPress security plugins (Wordfence, WAF, CDN) commonly block the default
|
||||
# Python-urllib User-Agent with 403. Send a descriptive one.
|
||||
_USER_AGENT = "SlabUploader/1.0 (+https://www.whidbeywoodstore.com)"
|
||||
|
||||
|
||||
class WooError(Exception):
|
||||
"""Base for all Woo client failures."""
|
||||
|
||||
|
||||
class WooCredentialsMissing(WooError):
|
||||
"""Woo base URL or credentials are not configured (fail-closed)."""
|
||||
|
||||
|
||||
class WooAuthError(WooError):
|
||||
"""The store rejected the credentials (401)."""
|
||||
|
||||
|
||||
class WooForbiddenError(WooError):
|
||||
"""The store blocked the request (403) — usually a security plugin or WAF."""
|
||||
|
||||
|
||||
class WooNotFoundError(WooError):
|
||||
pass
|
||||
|
||||
|
||||
class WooServerError(WooError):
|
||||
pass
|
||||
|
||||
|
||||
class WooConnectionError(WooError):
|
||||
"""Store unreachable (DNS, TLS, timeout)."""
|
||||
|
||||
|
||||
def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]):
|
||||
req = urllib.request.Request(url, method=method, data=data)
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return r.getcode(), dict(r.headers.items()), r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
parsed = {}
|
||||
msg = parsed.get("message") if isinstance(parsed, dict) else None
|
||||
if e.code == 401:
|
||||
raise WooAuthError(msg or "authentication rejected (401)") from e
|
||||
if e.code == 403:
|
||||
raise WooForbiddenError(msg or "request forbidden (403)") from e
|
||||
if e.code == 404:
|
||||
raise WooNotFoundError(msg or "not found (404)") from e
|
||||
raise WooServerError(f"server error {e.code}: {msg}") from e
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
||||
raise WooConnectionError(f"store unreachable: {e}") from e
|
||||
|
||||
|
||||
class WooClient:
|
||||
def __init__(self, username: str, password: str, base_url: str,
|
||||
request: Callable[..., tuple[int, dict, bytes]] = _default_request) -> None:
|
||||
if not base_url or not base_url.startswith("https://"):
|
||||
raise WooCredentialsMissing("WOO_BASE_URL must be https:// (SETTINGS-10)")
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._request = request
|
||||
self._auth = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, base_url: str, username: str | None, encrypted_password: str | None,
|
||||
request: Callable[..., tuple[int, dict, bytes]] | None = None) -> "WooClient":
|
||||
"""Build from Settings values; raise WooCredentialsMissing if incomplete (fail-closed)."""
|
||||
if not username:
|
||||
raise WooCredentialsMissing("woo_wp_username not set (SETTINGS-15)")
|
||||
if not encrypted_password:
|
||||
raise WooCredentialsMissing("woo_app_password not configured (SETTINGS-15)")
|
||||
try:
|
||||
password = decrypt(encrypted_password)
|
||||
except DecryptError as e:
|
||||
raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from e
|
||||
if request is None:
|
||||
request = _default_request
|
||||
return cls(username, password, base_url, request)
|
||||
|
||||
def _get(self, path: str) -> tuple[int, dict, bytes]:
|
||||
url = f"{self.base_url}/wp-json/wc/v3{path}"
|
||||
return self._request(url, "GET", None, self._auth_headers())
|
||||
|
||||
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
||||
page = 1
|
||||
results: list[dict[str, Any]] = []
|
||||
while True:
|
||||
code, headers, body = self._get(f"{path}?per_page=100&page={page}")
|
||||
if code != 200:
|
||||
raise WooServerError(f"expected 200, got {code}")
|
||||
results.extend(json.loads(body))
|
||||
total_pages = int(headers.get("X-WP-TotalPages") or headers.get("x-wp-totalpages") or 1)
|
||||
if page >= total_pages:
|
||||
return results
|
||||
page += 1
|
||||
|
||||
def get_categories(self) -> list[dict[str, Any]]:
|
||||
return self._fetch_all("/products/categories")
|
||||
|
||||
def get_tags(self) -> list[dict[str, Any]]:
|
||||
return self._fetch_all("/products/tags")
|
||||
|
||||
def get_attributes(self) -> list[dict[str, Any]]:
|
||||
return self._fetch_all("/products/attributes")
|
||||
|
||||
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
|
||||
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
|
||||
|
||||
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
|
||||
h = {
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
}
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
|
||||
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
|
||||
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
|
||||
code, _h, body = self._request(
|
||||
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
|
||||
"GET",
|
||||
None,
|
||||
self._auth_headers(),
|
||||
)
|
||||
if code != 200:
|
||||
raise WooServerError(f"sku lookup returned {code}")
|
||||
return list(json.loads(body))
|
||||
|
||||
def create_product(self, payload: dict[str, Any]) -> int:
|
||||
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
code, _h, resp = self._request(
|
||||
f"{self.base_url}/wp-json/wc/v3/products",
|
||||
"POST",
|
||||
body,
|
||||
self._auth_headers({"Content-Type": "application/json"}),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"product create returned {code}")
|
||||
return int(json.loads(resp)["id"])
|
||||
|
||||
def upload_media(self, png_bytes: bytes, filename: str) -> int:
|
||||
"""POST a PNG to WordPress core media; return the attachment id."""
|
||||
boundary = "----slabuploader-media-boundary"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
||||
"Content-Type: image/png\r\n\r\n"
|
||||
).encode("utf-8") + png_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
url = f"{self.base_url}/wp-json/wp/v2/media"
|
||||
code, _headers, resp = self._request(
|
||||
url, "POST", body,
|
||||
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"media upload returned {code}")
|
||||
return int(json.loads(resp)["id"])
|
||||
+147
-5
@@ -1,7 +1,149 @@
|
||||
# Host
|
||||
|
||||
- Machine: ubuDual5060 (192.168.1.202)
|
||||
- Container: slabuploader-m1 (image sha256:98d11c23, restart unless-stopped)
|
||||
- URL: http://192.168.1.202:18080
|
||||
- Port: 18080 (LAN interface, bound 0.0.0.0)
|
||||
- Started: 2026-09-14 (M1 UAT)
|
||||
Port block `10880`–`10889` on ubudual5060 is allocated to this project.
|
||||
`:10880` is Ty's local Node/SvelteKit and must never be bound by project deploys.
|
||||
The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can reach it.
|
||||
Check to see if you are running on ubudual5060 (192.168.1.202) before attempting SSH.
|
||||
|
||||
## Dev-test environment (M2 current, M3 incoming)
|
||||
|
||||
- Host: `ubudual5060` / LAN `192.168.1.202` / Tailscale `100.64.0.9`
|
||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`)
|
||||
- Port: **10881** → 80, bound `0.0.0.0`
|
||||
- URLs: `http://192.168.1.202:10881` (LAN) · `http://100.64.0.9:10881` (Tailscale)
|
||||
- M3 replaces this container on the same port; remove the M2 container once M3 is healthy.
|
||||
|
||||
## Ty local Node/SvelteKit (do not overwrite)
|
||||
|
||||
- Host: `ubudual5060` / `100.64.0.9`
|
||||
- Container: `slabuploader-m1`
|
||||
- Port: **18080** → 80 (inventory previously listed 10880; that port is off limits to project deploys)
|
||||
- URL: `http://100.64.0.9:18080`
|
||||
- **Never** recreate or remap this container for project deploys.
|
||||
|
||||
Future deploys use a **separate** container name and a free port in `10880`–`10889`
|
||||
(other than `10880`), bound `0.0.0.0`.
|
||||
Do not put a host name in the product spec.
|
||||
|
||||
## Deploy records
|
||||
|
||||
### 2026-09-21 02:41 PDT — #90 wood primary category + derived thickness band
|
||||
|
||||
- Source ref: `0329052` on `issue-89-metadata-lock` (pushed to Gitea; pulled into `~/slab-deploy`).
|
||||
- Images (built on `ubudual5060`): `deploy-frontend:latest` `1c169c29f131`,
|
||||
`deploy-fastapi:latest` `32671659639b`.
|
||||
- Compose: `deploy/docker-compose.yml` + host-local (untracked) `deploy/override-m3.yml`.
|
||||
- URL: `http://192.168.1.202:10881` (LAN) · `http://100.64.0.9:10881` (Tailscale).
|
||||
- `matches HEAD`: **yes**.
|
||||
- Sample data preserved: **yes** — 24 slabs before and after `up -d` (no `-v`, no volume removal).
|
||||
- Verified on the live stack: served asset `assets/index-tlFib3RM.js` (sha256 `3b665746ec81f28e…`)
|
||||
contains the new markers and no `#thickness` select; `POST /api/v1/slabs` at `thickness_in 1.0`
|
||||
stored band `350` (`1"- 1 1/2"`); `PUT` at `2.75` re-banded to `352`; a client-sent stale band id
|
||||
was ignored; `POST /review` no longer lists thickness as missing; the Review screen in a real
|
||||
browser (LAN URL) shows the wood-category MultiSelect closed summary with names + count, the
|
||||
`Primary wood category` radio set switching on click, and the band as read-only text.
|
||||
- Not exercised: the Woo publish path. Rows created **before** this deploy keep `thickness_term_id`
|
||||
NULL until their next save (the band is derived on write, so any Review commit fills it in).
|
||||
|
||||
### 2026-09-21 03:12 PDT — follow-up: thickness band omitted entirely from Review
|
||||
|
||||
- Commit: `204bc5e` (fix #90: band removed from UI, not merely read-only; publish test added).
|
||||
- Rebuilt `deploy-frontend:latest` and recreated the container on 10881.
|
||||
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement".
|
||||
- Sample data unchanged (still 25 slabs post-recreate).
|
||||
- All gates (unit, build, requirements, coverage, m3-create e2e) pass.
|
||||
|
||||
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
|
||||
|
||||
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
|
||||
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
|
||||
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
|
||||
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
|
||||
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
|
||||
deterministic title/short_description are now built by the review endpoint on every call; the
|
||||
owner's **Create title / Create short desc** buttons fire the build and write the result
|
||||
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
|
||||
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
|
||||
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
|
||||
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
|
||||
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
|
||||
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
|
||||
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
|
||||
`woo_reachable:true`.
|
||||
- Sample data preserved: **yes** — 34 slabs before/after.
|
||||
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
|
||||
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
|
||||
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
|
||||
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
|
||||
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
|
||||
not exercised this round.
|
||||
|
||||
### 2026-09-21 12:10 PDT — redeploy: `main` after merge `8ee3da9` (reconciled PR #97 + branch)
|
||||
|
||||
- Source: `main` @ `8ee3da9` (merge of `work/merge-89-90` into main, which itself reconciled
|
||||
main's PR #97 client-side `thicknessBand.ts` + `syncSelects`/`box-sizing` against the branch's
|
||||
server-side `thickness_band.py` + Create-string #89). `matches HEAD`: **yes**.
|
||||
- Resolution outcome in the deployed bundle: band is **server-side** — `Review.svelte` no longer
|
||||
imports `thicknessBand` and the read-only band display is gone; PR #97's `syncSelects()` (3× in
|
||||
source) + `box-sizing` CSS preserved; `metadata_locked` still dropped; Create-string + photo
|
||||
strip + wood primary live.
|
||||
- Rebuilt both images from `main`; recreated the stack (`down --remove-orphans` + `up -d`, no `-v`).
|
||||
LAN binds 10881/10882 intact.
|
||||
- Bundle: `assets/index-BScrgd8o.js` (new; differs from pre-merge `YPAqx609`; contains
|
||||
`create-title`, **zero** `thicknessBand` references). API `/api/health` `woo_reachable:true`.
|
||||
- Sample data preserved: **yes** — 35 slabs (one added upstream since last deploy).
|
||||
- Live verification (LAN URL, real DB): `POST /review` on slab `ec4088da…` →
|
||||
`title:'Curl Black Walnut Slab'`, `ready:True`.
|
||||
- Gates at merge time: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
|
||||
- Branches `work/merge-89-90` and `issue-89-metadata-lock` deleted (local + remote); remote has
|
||||
only `main`.
|
||||
|
||||
### 2026-09-21 12:34 PDT — Review mount: restore dropped lineup/woodPrimary imports
|
||||
|
||||
- Source: uncommitted working tree on `main` (parent `67aa3d6`). `matches HEAD`: **no**.
|
||||
Diff: `frontend/src/create/review/Review.svelte` +5 lines restoring imports of
|
||||
`keptInOrder`/`moveItem`/`missingMinimums`/`roleLabel`/`resolvePhotoUrl`/`LineupItem`
|
||||
from `lineup.ts` and `nextPrimary` from `woodPrimary.ts`. The morning merge kept the
|
||||
call sites and dropped the imports → `ReferenceError` on Review mount → "Could not load the slab."
|
||||
- Rebuilt `deploy-frontend` only (`compose build frontend` + `up -d frontend`, no `-v`).
|
||||
Image `56b35f6cdcdf`. FastAPI container left running.
|
||||
- URL: http://192.168.1.202:10881 (LAN) · loopback 127.0.0.1:18082
|
||||
- Bundle: `assets/index-BVCyM130.js` (replaced `index-ck41xD02.js`). Served JS has **0**
|
||||
`nextPrimary`/`keptInOrder` (minified locals) vs 3 unresolved `nextPrimary` in the broken bundle.
|
||||
- Sample data preserved: **yes** — 47 slabs before rebuild; 48 after live Capture→Review probe
|
||||
(created `ef1f4101-b1d2-459d-b9a0-842e7650ea3c`, sku NULL, status calibrated, 48×2 in; not published).
|
||||
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
|
||||
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
|
||||
GET slab/taxonomy/settings 200, review 200.
|
||||
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
|
||||
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
|
||||
|
||||
### 2026-09-25 08:57 PDT — M4 review preview: #99 fixed tape line + calibration rotation
|
||||
|
||||
- Source: branch `m4`, commit **`a756cf5`** (`fix(m4): drop stale tick-nudge hint from tape page (#99)`),
|
||||
pushed and verified on `origin/m4`. `matches HEAD`: **yes**. Preceding M4 commits on the same
|
||||
branch: `1882fa3` (calibration tilt applied to the calibration PNG/overlay only) and
|
||||
`7908a99` (tape calibration collapsed onto one page).
|
||||
- Image `deploy-frontend:m4-a756cf5` (`sha256:26402a77…`) built on the 5060 from a clean release
|
||||
archive, **not** from the `~/slab-deploy` greenfield checkout. Container `slabuploader-m4-review`
|
||||
recreated on network `deploy_default`, host port `10883:80`, **zero mounts**.
|
||||
- URL: http://192.168.1.202:10883/?noload=1 (M4 review only). **Not** `10881` — that is the old
|
||||
`main` stack and has no Length page. `10882` is the API, not a UI.
|
||||
- Bundle: `assets/index-BI2cLnOA.js`. Served bundle has **zero** `Drag the ticks` /
|
||||
`nudge the last tick` strings and **does** contain the fixed-line hint.
|
||||
- Main stack untouched: `:10881` and `:10882/api/health` both 200 throughout.
|
||||
- Sample data preserved: **yes** — the review container is volume-free; the real DB was never
|
||||
rebuilt or re-seeded.
|
||||
- Live verification (deployed URL, real backend): the fixed line renders with both end ticks;
|
||||
a drag starting on a tick behaves identically to a pan elsewhere (line does not move); the
|
||||
crop slider moves the line ends; Length/Thickness → Calculate reaches Photos ready. A drag is
|
||||
still allowed (that is the intended pan).
|
||||
- Gates: `npm test` 102/102; `tsc --noEmit` clean; requirements + coverage gates green;
|
||||
render gate clean at 1440/768/375 (no overflow, no clipped text, no page/console/HTTP errors).
|
||||
- Regression guard proven to fail against pre-fix code, not just pass post-fix.
|
||||
- Ty UAT sign-off: **length e2e signed off 2026-09-25**; minor bugs to be filed separately.
|
||||
- Not done: `svelte-check` still reports its known 42-error baseline (no new error from this work);
|
||||
`Calculate` (42px) and `Fit` (41px) are marginally under the 44px touch reference — pre-existing,
|
||||
deliberately left for a separate decision; Gitea #99's **body** still describes the superseded
|
||||
draggable-tick model (the as-built is recorded in `tasks.md` T2 and requirements rows
|
||||
CAPTURE-170/175/75).
|
||||
|
||||
@@ -6,12 +6,3 @@ Where the container runs is chosen at runtime.
|
||||
No FastAPI and no Woo client in this milestone.
|
||||
|
||||
`docker-compose.yml`, `Caddyfile.fragment`, and `.env.example` describe an older two-service stack. They are not the Milestone 1 contract.
|
||||
|
||||
## Milestone 1 UAT — deployment POR
|
||||
|
||||
- **One container.** Build a single Docker image that serves the SvelteKit capture app. No FastAPI, no Woo client, no Caddy TLS edge in M1. (Caddy/compose/Woo are Milestone 3.)
|
||||
- **Host chosen at runtime.** Record the machine and URL in `INVENTORY.md` when the container starts. Do not put a host name in the product spec.
|
||||
- **LAN-reachable UAT URL.** For UAT the container binds a port on the LAN interface (not only `127.0.0.1`), so other machines on the LAN — e.g. a phone — reach the app at `http://<host-lan-ip>:<port>`.
|
||||
- **No backend.** The app is browser-only in M1 (see `docs/CAPTURE.md`). There is no `/api` route and no inference.
|
||||
|
||||
Reference for the current UAT host: see `INVENTORY.md` (filled in when the container starts).
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
# Release
|
||||
|
||||
Build the Docker image. Run it on the host chosen for that test.
|
||||
Milestone 3 adds the Woo service and a tagged UAT path.
|
||||
The full runbook (topology, access, build/deploy, smoke checks, rollback) is `docs/DEPLOYMENT.md`.
|
||||
This file records only what changes per release.
|
||||
|
||||
Milestone 3:
|
||||
|
||||
- Build happens on the UAT host (`ubudual5060`), never on the dev box. Source arrives by git
|
||||
pull from Gitea into `~/slab-deploy`.
|
||||
- Dev-test path: tagged container on port `10881` bound `0.0.0.0` (see `deploy/INVENTORY.md`),
|
||||
replacing the M2 container. `SLAB-UAT-*` SKUs create Woo drafts only.
|
||||
- `.env` is created on the host from `.env.example` (chmod 600); Woo Application Password and
|
||||
inference keys live in app Settings, not in `.env`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Implementable compose for SlabUploader.
|
||||
# Phase 0 deliverable targets: frontend/Dockerfile, backend/Dockerfile,
|
||||
# backend/app/prompts/, and deploy/.env.example are not yet in the tree on
|
||||
# and deploy/.env.example are not yet in the tree on
|
||||
# docs-only main. This file pins their paths so scaffolding does not invent them.
|
||||
# From repo root (after those scaffolds exist):
|
||||
# cp deploy/.env.example deploy/.env && chmod 600 deploy/.env
|
||||
@@ -16,7 +16,9 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: frontend/Dockerfile
|
||||
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"]
|
||||
ports:
|
||||
- "127.0.0.1:${FRONTEND_PORT:-18080}:80"
|
||||
- "0.0.0.0:10881:80"
|
||||
depends_on: [fastapi]
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -34,8 +36,9 @@ services:
|
||||
volumes:
|
||||
- slab_db:/data
|
||||
- slab_images:/images
|
||||
- ../backend/app/prompts:/app/prompts:ro
|
||||
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"]
|
||||
ports:
|
||||
- "127.0.0.1:${FASTAPI_PORT:-18081}:8000"
|
||||
- "0.0.0.0:10882:8000"
|
||||
mem_limit: 2g
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
|
||||
# multipart to /api/v1/slabs/{id}/photos. nginx's default client_max_body_size
|
||||
# is 1MB, which 413s on real multi-MB photos. Allow large photo bodies.
|
||||
client_max_body_size 25m;
|
||||
|
||||
# Docker embedded DNS for service name resolution (fastapi)
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
# SPA: fall back to index.html for any path
|
||||
# Proxy API calls to the FastAPI backend (M3+)
|
||||
# Use variable to defer DNS resolution (docker service name "fastapi")
|
||||
location /api/ {
|
||||
set $fastapi_upstream fastapi;
|
||||
proxy_pass http://$fastapi_upstream:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# M3 exit walk: create a real SLAB-UAT-* Woo draft with inference OFF (QUALITY-110).
|
||||
# Run from the UAT host (ubudual5060) after a fresh deploy. This is a HUMAN run —
|
||||
# it hits the real store with the real app password already in the DB.
|
||||
# QUALITY-105: this is NOT a mock; it must create a real draft in the store.
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${1:-http://127.0.0.1:10881}"
|
||||
SLAB_ID="$(python -c 'import uuid; print(uuid.uuid4())')"
|
||||
SKU="SLAB-UAT-$(date +%H%M%S)"
|
||||
|
||||
echo "== 0. Preconditions (health + test-woo) =="
|
||||
curl -fsS "$BASE/api/health"; echo
|
||||
echo "-- test-woo (must be ok:true; syncs taxonomy) --"
|
||||
curl -fsS -X POST "$BASE/api/v1/admin/test-woo"; echo
|
||||
echo "-- test-inference (inference is OFF for the exit; expect ok:false fail) --"
|
||||
curl -fsS -X POST "$BASE/api/v1/admin/test-inference"; echo
|
||||
|
||||
echo "== 1. Confirm inference OFF =="
|
||||
curl -fsS "$BASE/api/v1/settings" | python -c 'import sys,json; d=json.load(sys.stdin); print("inference_enabled:", d.get("inference_enabled"), "| content_llm_enabled:", d.get("content_llm_enabled"))'
|
||||
|
||||
echo "== 2. Create the slab (inference-off, manual content) =="
|
||||
cat > /tmp/slab.json <<JSON
|
||||
{
|
||||
"id": "$SLAB_ID",
|
||||
"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": __SPECIES_ID__,
|
||||
"wood_category_ids": [__WOOD_CAT_ID__],
|
||||
"edge_type_term_id": __EDGE_TERM_ID__,
|
||||
"figure_term_ids": [],
|
||||
"grade_term_id": __GRADE_TERM_ID__,
|
||||
"thickness_term_id": __THICK_TERM_ID__,
|
||||
"moisture_term_id": __MOIST_TERM_ID__,
|
||||
"fig_tag_ids": [],
|
||||
"feat_tag_ids": [],
|
||||
"price": 184.0,
|
||||
"price_source": "recommendation",
|
||||
"title": "Black Walnut Live Edge Slab",
|
||||
"short_description": "Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in, 18.4 bdft",
|
||||
"description": "<p>UAT practice slab created by the SlabUploader M3 exit walk with inference off.</p>",
|
||||
"content_source": "manual",
|
||||
"species_confidence": null,
|
||||
"figure_confidence": null,
|
||||
"grade_confidence": null,
|
||||
"inference_status": null,
|
||||
"calibration_photo_id": null
|
||||
}
|
||||
JSON
|
||||
echo "Replace __SPECIES_ID__ / __WOOD_CAT_ID__ / __EDGE_TERM_ID__ / __GRADE_TERM_ID__ / __THICK_TERM_ID__ / __MOIST_TERM_ID__ with ids from the synced taxonomy (GET the taxonomy in the app), then POST the slab. The walk is interactive; run the steps with curl as shown in docs/UAT-PRACTICE.md."
|
||||
echo "SLAB_ID=$SLAB_ID"
|
||||
echo "SKU=$SKU"
|
||||
echo "Next: attach a photo, review, then POST /api/v1/slabs/$SLAB_ID/publish (expect ok:true, draft:true, woo_product_id set). Confirm the draft exists in the Woo store and matches the reviewed set."
|
||||
@@ -0,0 +1,112 @@
|
||||
# Deployment
|
||||
|
||||
Role: ops. This is the canonical runbook for getting a build from a working tree to a live
|
||||
dev/test host. `deploy/INVENTORY.md` is the only source for what runs where (hosts, ports,
|
||||
container names). `docs/UAT-PRACTICE.md` is the human walk once the app is up; this doc is what
|
||||
makes it up.
|
||||
|
||||
## 1. Topology
|
||||
|
||||
| Node | Role | Reach |
|
||||
|---|---|---|
|
||||
| Ty dev box (Windows) | code, git push to Gitea. No Docker CLI. | LAN + Tailscale |
|
||||
| Gitea (`gitea.vps1.afterthedemo.com`) | source of truth for the repo. SSH port closed; HTTPS only. | Tailscale |
|
||||
| `ubudual5060` (UAT host, LAN `192.168.1.202`, Tailscale `100.64.0.9`) | builds and runs Docker images for UAT. | LAN + Tailscale |
|
||||
|
||||
The dev box never builds images. The UAT host pulls source from Gitea and builds locally.
|
||||
Do not install Docker on the dev box to work around this; the pull-build path is the plan.
|
||||
|
||||
## 2. Access (already provisioned; do not re-do)
|
||||
|
||||
- Dev box → UAT host: `ssh ubudual5060` (key `~/.ssh/ubudual5060`, wired in `~/.ssh/config`).
|
||||
Unattended, passphrase-less.
|
||||
- UAT host → Gitea: HTTPS with a read-only token named `5060-pull`, stored in
|
||||
`~/.git-credentials` on the host. The host clones/pulls with the plain HTTPS URL;
|
||||
no key material in the repo.
|
||||
- Deploy checkout on the UAT host: `~/slab-deploy` (branch `main`).
|
||||
The pre-existing `~/SlabUploader` on that host is a stale work clone — do not use it.
|
||||
|
||||
If access is broken, fix it here before touching compose. Do not commit credentials to the repo.
|
||||
|
||||
## 3. Build and deploy (M3)
|
||||
|
||||
Precondition: the M3 scaffold exists on the branch being deployed — `frontend/Dockerfile`,
|
||||
`backend/Dockerfile`, and `deploy/.env` on the host. `main` currently
|
||||
has `frontend/Dockerfile` only; `backend/` lands with M3 implementation.
|
||||
Prompts live in SQLite with all revisions, seeded from the text in `docs/contracts/PROMPTS.md` §6 on
|
||||
first run; no prompt files ship with the image (`METADATA-105`).
|
||||
|
||||
1. Push the branch to Gitea from the dev box.
|
||||
2. On the UAT host:
|
||||
|
||||
```
|
||||
cd ~/slab-deploy
|
||||
git fetch origin && git checkout <branch> && git pull
|
||||
cp deploy/.env.example deploy/.env && chmod 600 deploy/.env # first time only
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env build
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d
|
||||
```
|
||||
|
||||
3. Verify with the smoke checks in §5.
|
||||
|
||||
### `.env` values
|
||||
|
||||
Generate on the UAT host, never in the repo:
|
||||
|
||||
- `SLAB_AES_KEY` — `openssl rand -base64 32`
|
||||
- `WOO_BASE_URL` — the store, `https://` only. FastAPI refuses `http://` at startup.
|
||||
- `APP_HOSTNAME` — from `deploy/INVENTORY.md`.
|
||||
- `FRONTEND_PORT` / `FASTAPI_PORT` — see the port override below.
|
||||
|
||||
Woo Application Password and inference keys are not in `.env`; they are entered in the app's
|
||||
Settings and stored AES-GCM encrypted in the app's SQLite.
|
||||
|
||||
### Port binding for the dev-test environment
|
||||
|
||||
`deploy/docker-compose.yml` binds `127.0.0.1` only (Caddy is the TLS edge in the full stack).
|
||||
The dev-test environment is reached directly on a port from the project block
|
||||
`10880`–`10889` (per `deploy/INVENTORY.md`) and **must be reachable from any machine on
|
||||
`192.168.1.0/24`**, so the deploy must override the port bindings to `0.0.0.0`
|
||||
(compose override file or `ports:` edit scoped to this deploy). Never bind `:10880`
|
||||
(Ty's local Node/SvelteKit) or Ty's `:18080`.
|
||||
|
||||
M3 takes over port `10881` from the M2 container (`slabuploader-m2-overnight`),
|
||||
per the replace-prior-container rule in `deploy/INVENTORY.md`.
|
||||
Remove the M2 container when the M3 one is healthy.
|
||||
|
||||
## 4. Local loop (no UAT host needed)
|
||||
|
||||
- Frontend: `cd frontend && npm install && npm run dev` (Vite).
|
||||
- Backend: `uvicorn` from `backend/` once the M3 scaffold lands; needs the same env vars as §3.
|
||||
- Tests: frontend `npm test` (Playwright config in `frontend/`); backend tests land with M3.
|
||||
|
||||
Use the local loop for iteration; use §3 for anything Ty must see on a phone.
|
||||
|
||||
## 5. Smoke checks (after every deploy)
|
||||
|
||||
Run from the UAT host, then from a phone on the same network (LAN or Tailscale):
|
||||
|
||||
1. `curl -fsS http://127.0.0.1:<frontend-port>/api/health` on the host returns ok.
|
||||
2. Phone browser opens the inventory URL (`deploy/INVENTORY.md`) and the app loads.
|
||||
3. `docker compose -f deploy/docker-compose.yml --env-file deploy/.env ps` shows both services
|
||||
up; `docker logs` for `fastapi` shows no startup refusal (WOO_BASE_URL is `https://`).
|
||||
4. `test-woo` in app Settings reports ok (store reachable, taxonomy synced).
|
||||
|
||||
If a check fails, the deploy is not live. Roll back per §6 before debugging on the live port.
|
||||
|
||||
## 6. Rollback
|
||||
|
||||
1. `docker compose -f deploy/docker-compose.yml --env-file deploy/.env down` on the UAT host.
|
||||
2. Check out the last known-good ref in `~/slab-deploy` and re-run §3 steps 2–3.
|
||||
3. Re-run §5. Volumes `slab_db` and `slab_images` persist across rollback; do not remove them
|
||||
unless the data itself is the problem.
|
||||
|
||||
## Where each rule lives
|
||||
|
||||
| Rule | Source |
|
||||
|---|---|
|
||||
| Hosts, ports, container names | `deploy/INVENTORY.md` |
|
||||
| Compose shape, env contract | `deploy/docker-compose.yml`, `deploy/.env.example` |
|
||||
| UAT walk once the app is up | `docs/UAT-PRACTICE.md` |
|
||||
| M3 scope and exit | `docs/MILESTONES.md`, `docs/REQUIREMENTS.md` |
|
||||
| Woo auth model | `AGENTS.md`, `docs/contracts/CONTENT-WOO.md` |
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
# Milestone 1 — Plan of Record (POR)
|
||||
|
||||
Decisions captured as they are made. This is the working plan for implementing and UAT-ing Milestone 1. It is the source of truth for scope, order, and dispatch; the spec detail lives in `docs/CAPTURE.md` and `docs/PRODUCT.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
Implement **Milestone 1 — Capture** only (see `AGENTS.md`). Browser-only, inference-free, no backend, no Woo create. Milestones 2–4 are out of scope.
|
||||
|
||||
## Method — TDD (non-negotiable)
|
||||
|
||||
Every card is delivered **test-first** per the `test-driven-development` skill:
|
||||
|
||||
```
|
||||
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
||||
```
|
||||
|
||||
- **Red-Green-Refactor**, tracer-bullet: one behavior slice at a time (RED→GREEN→REFACTOR), not all-tests-then-all-code.
|
||||
- **Verify RED** (watch it fail for the right reason) and **Verify GREEN** (it passes + full suite stays green) are mandatory before the card is done.
|
||||
- Code written before a failing test is **deleted and redone** — not kept as "reference."
|
||||
- The deterministic core (math + PNG contract) is unit-tested in a browser-runnable JS suite; UI behaviors are tested with Playwright against the fixtures.
|
||||
- A card is not complete until: every new function has a test, each test was seen failing first, the full suite passes with pristine output.
|
||||
|
||||
## Card breakdown (implementation order)
|
||||
|
||||
Each card follows TDD. "Define" = write the failing tests (RED); "Build" = minimal code to pass (GREEN); "Refactor" = clean up keeping green.
|
||||
|
||||
1. **Scaffold + Dockerfile + test harness** — SvelteKit project with the browser-only M1 runtime (Web Worker, canvas/OffscreenCanvas, IndexedDB via `idb-keyval`), `frontend/Dockerfile`, and the test harness (JS unit runner + **Playwright** web).
|
||||
- *TDD note:* this card establishes the harness itself (a smoke test that runs and fails, then passes) so every later card has a place to be RED/GREEN.
|
||||
2. **Deterministic core** — the pure math + PNG-encode functions.
|
||||
- *RED:* unit tests for `scale`, `sqft`, `bdft`, width sampling, 1/8″ stepping, rounding, and the PNG contract (3:4, 8-bit RGBA, 80% fill, ≥1600px floor, undersized→retake). Run on the fixtures' calibration records.
|
||||
- *GREEN/REFACTOR:* implement to pass; core is authoritative and server-less.
|
||||
3. **Capture + Mask** — Add photo (JPEG; HEIC→"retake as JPEG" notice), green/black key, four knobs, debounced recompute, mask approve.
|
||||
- *RED:* Playwright — load fixture, key sheet, approve; empty-mask blocks approve; HEIC shows the notice.
|
||||
- *GREEN/REFACTOR:* build the Mask state to pass.
|
||||
4. **Length axis** — min-area bounding rectangle + long-side axis, user rotate, approve.
|
||||
- *RED:* Playwright — rect drawn from the approved mask, axis on long side, rotate, approve.
|
||||
- *GREEN/REFACTOR:* build the axis state.
|
||||
5. **Measure + PNG** — length/thickness (1/8″), compute sqft/bdft/widths, write the 3:4 inventory PNG. M1 stops here.
|
||||
- *RED:* Playwright — enter L/thickness → Calculate; assert PNG is 3:4 with alpha, and numbers equal the fixture's expected values.
|
||||
- *GREEN/REFACTOR:* build Measure + PNG encode.
|
||||
6. **M1 UAT** — serve the container per `deploy/README.md` §Milestone 1 UAT; run the full Playwright suite in the container; then the manual phone pass. Record host + URL in `deploy/INVENTORY.md`.
|
||||
|
||||
## Deployment (UAT)
|
||||
|
||||
See `deploy/README.md` §Milestone 1 UAT — deployment POR: one container, host chosen at runtime, LAN-reachable URL, no backend.
|
||||
|
||||
## Dev / QA worker dispatch
|
||||
|
||||
Two local inference endpoints are available as dispatchable workers for test / dev / QA:
|
||||
|
||||
- **`.202:8081`** — `gemma-4-12B-it-q4_k_m` (12B, Q4) — **completion only, no vision.** Use for lighter text: drafting, code review, summaries.
|
||||
- **`.202:8082`** — `Qwen3.8-27B-UD-IQ3_XXS` (27B, IQ3) — **completion + multimodal (vision).** The **vision worker** (any task that must look at a slab photo) and the stronger general-reasoning endpoint.
|
||||
|
||||
Coordinator: Hermes (me) — plans, decomposes, dispatches, verifies, and does work directly when a card is small or judgment-heavy.
|
||||
|
||||
### Calibrated delegation policy (2026-09-14)
|
||||
|
||||
- **Frame every prompt**: system role + "show each step" + explicit constraints. Small/low-quant models drop multi-step arithmetic in one-shot "reply JSON only" prompts but get it exactly right with scratch-space framing. Do not judge capability from under-framed probes.
|
||||
- **The deterministic core stays the coordinator's** (TDD'd, unit-tested). A model that's "usually right" on math is not good enough for the spec — math is cheap to test and must be exact.
|
||||
- **Verify before land**: any load-bearing model output is checked against tests/spec before entering the repo. Workers are assist, not autonomous builders.
|
||||
- **Output quirks**: gemma wraps JSON in ``` fences — strip before parsing. Vision calls run ~18s/image (text ~1s).
|
||||
- **One task per endpoint at a time** (no parallel on the same endpoint — backs up and overheats).
|
||||
- Both run low-quant (Q4/IQ3). Fine for framed assist; deep sustained reasoning is best done by the coordinator directly.
|
||||
|
||||
## Test plan (M1)
|
||||
|
||||
Three layers. The deterministic core is unit-tested; the end-to-end path is Playwright; a real phone is the final UAT gate.
|
||||
|
||||
### 1. Unit (deterministic core) — must pass before any UI consumes it
|
||||
- `bdft = sqft × thickness_in`; `sqft = (mask_px / scale²) / 144`; `scale = longest_axis_px / L`.
|
||||
- 1/8 in stepping; rounding (sqft/bdft 2 decimals).
|
||||
- Width sampling every 6 in along the approved axis.
|
||||
- PNG encode contract: 3:4 aspect, 8-bit RGBA, 80% fill, shorter side ≥ 1600px when source allows; undersized source → warn + retake (no upscale).
|
||||
- Mask empty → approve blocked.
|
||||
|
||||
### 2. Playwright (automated end-to-end, runs in the UAT container's browser)
|
||||
Walks the 5 states against a **sample photo**:
|
||||
- Add photo → Mask (set sheet + sensitivity) → approve → Length axis → approve → Measure (enter L, thickness) → Calculate.
|
||||
- Assert: PNG rendered, dimensions = 3:4, alpha present; sqft/bdft/widths equal the expected values for that fixture (within tolerance); numbers visible on screen.
|
||||
- HEIC input → "retake as JPEG" notice shown (not a crash).
|
||||
- Device draft: reload → Resume → Continue restores last approved state.
|
||||
|
||||
### 3. Manual phone UAT (final gate)
|
||||
On a real phone (iOS Safari / Android Chrome) over the LAN UAT URL: repeat the happy path with one real slab; confirm PNG + numbers on screen. Record host + URL in `deploy/INVENTORY.md`.
|
||||
|
||||
## Test fixtures (sample pics)
|
||||
|
||||
Real slab photos are the fixtures (supplied by Ty from `\\truenas.local\Business\Samples`, SMB user `SMBSVC`). Each is a DJI MIMO top-down JPEG, ~4000×2256, slab on a green/black sheet.
|
||||
|
||||
`tests/fixtures/`:
|
||||
- **sample_01 … sample_0N.JPG** — a representative handful of real slab photos (pulled on demand from the TrueNAS Samples share; do not bulk-copy the whole folder).
|
||||
- Each fixture gets a **calibration record** in `tests/fixtures/<name>.json`: the slab's true length and thickness (in inches), the sheet color, and the approved mask/axis reference — used to compute the expected `scale`, `sqft`, `bdft`, and sampled widths for assertions.
|
||||
|
||||
The real photos drive both **bg removal** (green/black key) and **rectangle/axis calibration**. Expected values come from the calibration records, so assertions stay deterministic even on real imagery.
|
||||
|
||||
**M1 fixture set (agreed, complete for M1):** three black-sheet slabs — one irregular (stress-tests the min-area rectangle/axis), two clean elongated. Green-sheet keying is deferred (not an M1 fixture). A 1 HEIC sample (optional) for the decode-fallback test.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- (none outstanding for M1 as of this plan; new decisions append here)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Milestones
|
||||
|
||||
A milestone is an explicit subset of requirement IDs from `docs/REQUIREMENTS.md`. No prose scope here.
|
||||
|
||||
## M1 — Capture (shipped)
|
||||
|
||||
IDs:
|
||||
|
||||
CAPTURE-5, CAPTURE-10, CAPTURE-20, CAPTURE-35, CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50,
|
||||
CAPTURE-55, CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-75, CAPTURE-80, CAPTURE-85, CAPTURE-90,
|
||||
CAPTURE-95, CAPTURE-100, CAPTURE-105, CAPTURE-135, CAPTURE-140, CAPTURE-141, CAPTURE-145,
|
||||
CAPTURE-150, CAPTURE-155, CAPTURE-160,
|
||||
APP-5, APP-10, APP-25, APP-40,
|
||||
QUALITY-5, QUALITY-10, QUALITY-15, QUALITY-25, QUALITY-30, QUALITY-40, QUALITY-45, QUALITY-55,
|
||||
QUALITY-60, QUALITY-65, QUALITY-70, QUALITY-75, QUALITY-80, QUALITY-85, QUALITY-100,
|
||||
OPS-5, OPS-10, OPS-15, OPS-20, OPS-25, OPS-45, OPS-50, OPS-55
|
||||
|
||||
## M2 — Photos (shipped)
|
||||
|
||||
IDs:
|
||||
|
||||
CAPTURE-25, CAPTURE-30, CAPTURE-110, CAPTURE-115, CAPTURE-120, CAPTURE-125, CAPTURE-130
|
||||
|
||||
## M3 — Create
|
||||
|
||||
IDs:
|
||||
|
||||
METADATA-5, METADATA-10, METADATA-15, METADATA-20, METADATA-25, METADATA-30, METADATA-35,
|
||||
METADATA-40, METADATA-45, METADATA-50, METADATA-55, METADATA-60, METADATA-65, METADATA-66,
|
||||
METADATA-70, METADATA-75, METADATA-80, METADATA-85, METADATA-90, METADATA-95, METADATA-100,
|
||||
METADATA-105, METADATA-106, METADATA-110, METADATA-115,
|
||||
REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-35, REVIEW-40,
|
||||
REVIEW-45, REVIEW-50,
|
||||
PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-31, PUBLISH-35,
|
||||
PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-40, PUBLISH-45, PUBLISH-50, PUBLISH-55, PUBLISH-60,
|
||||
SETTINGS-5, SETTINGS-10, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30, SETTINGS-35,
|
||||
SETTINGS-40, SETTINGS-45, SETTINGS-50, SETTINGS-51, SETTINGS-55, SETTINGS-60, SETTINGS-65,
|
||||
SETTINGS-70, SETTINGS-75, SETTINGS-80, SETTINGS-85, SETTINGS-90,
|
||||
APP-15, APP-20, APP-30, APP-35, APP-45, APP-50, APP-55, APP-60, APP-65,
|
||||
OPS-40,
|
||||
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105, QUALITY-110,
|
||||
QUALITY-120, QUALITY-125
|
||||
|
||||
## M4 — Tape-line, manual taxonomy, Call 2, and UAT exit
|
||||
|
||||
IDs:
|
||||
|
||||
CAPTURE-25, CAPTURE-30, CAPTURE-35, CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50,
|
||||
CAPTURE-55, CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-75, CAPTURE-80, CAPTURE-85,
|
||||
CAPTURE-90, CAPTURE-95, CAPTURE-100, CAPTURE-105, CAPTURE-110, CAPTURE-115,
|
||||
CAPTURE-120, CAPTURE-125, CAPTURE-130, CAPTURE-135, CAPTURE-140, CAPTURE-141,
|
||||
CAPTURE-145, CAPTURE-150, CAPTURE-155, CAPTURE-160, CAPTURE-170, CAPTURE-175,
|
||||
METADATA-5, METADATA-10, METADATA-15, METADATA-20, METADATA-25, METADATA-30,
|
||||
METADATA-50, METADATA-55, METADATA-60, METADATA-65, METADATA-66, METADATA-70,
|
||||
METADATA-75, METADATA-80, METADATA-85, METADATA-90, METADATA-95, METADATA-105,
|
||||
METADATA-106, METADATA-110, METADATA-115, METADATA-120,
|
||||
REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-35,
|
||||
REVIEW-40, REVIEW-45, REVIEW-50, REVIEW-55,
|
||||
PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-31,
|
||||
PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-40, PUBLISH-45,
|
||||
PUBLISH-50, PUBLISH-55, PUBLISH-60,
|
||||
SETTINGS-5, SETTINGS-10, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30,
|
||||
SETTINGS-35, SETTINGS-40, SETTINGS-45, SETTINGS-50, SETTINGS-51, SETTINGS-55,
|
||||
SETTINGS-60, SETTINGS-65, SETTINGS-70, SETTINGS-75, SETTINGS-80, SETTINGS-85,
|
||||
SETTINGS-90,
|
||||
APP-15, APP-20, APP-30, APP-35, APP-45, APP-50, APP-55, APP-60, APP-65,
|
||||
OPS-40,
|
||||
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105,
|
||||
QUALITY-110, QUALITY-120, QUALITY-125, QUALITY-130
|
||||
|
||||
## Later
|
||||
|
||||
IDs:
|
||||
|
||||
CAPTURE-15, CAPTURE-165, METADATA-35, METADATA-40, METADATA-45, METADATA-100,
|
||||
METADATA-107, OPS-30, OPS-35, QUALITY-115
|
||||
+30
-36
@@ -1,4 +1,4 @@
|
||||
# Product requirements
|
||||
# Product overview
|
||||
|
||||
SlabUploader creates one WooCommerce listing per wood slab for Whidbey Wood Store.
|
||||
|
||||
@@ -6,17 +6,23 @@ Each slab is one unit and one listing. Photos and metadata are the inventory rec
|
||||
|
||||
Photographs are taken of the slab on a green or black sheet. That is the capture starting state.
|
||||
|
||||
Owner: Ty. Store: https://www.whidbeywoodstore.com/
|
||||
|
||||
## Complete listing
|
||||
|
||||
A finished listing has:
|
||||
|
||||
- Inventory photo(s), uniform 3:4 transparent PNG
|
||||
- SKU, length, thickness, price
|
||||
- Species, wood category, edge type, figure, grade
|
||||
- Thickness band, moisture
|
||||
- One or more `fig-*` tags
|
||||
- Title, short title, description
|
||||
- Optional `fig-*` tags
|
||||
- Title, short_description, description
|
||||
- Zero or more `feat-*` tags
|
||||
|
||||
Store categories, attributes, and `fig-*` / `feat-*` tags are the filters. Measured values feed title and description templates.
|
||||
Store categories, attributes, and `fig-*` / `feat-*` tags are the filters shoppers use. When content generation runs, the model writes the prose; deterministic templates are the fallback and inject the measured values.
|
||||
|
||||
The locked name for that synced taxonomy package is **Woo Slab Taxonomy**. Details live in `docs/capabilities/CREATE.md`.
|
||||
|
||||
## Decision rights
|
||||
|
||||
@@ -28,47 +34,35 @@ Store categories, attributes, and `fig-*` / `feat-*` tags are the filters. Measu
|
||||
| Widths, sqft, bdft | App, from approved reference photo + length + thickness |
|
||||
| Species, wood category, edge, figure, grade, tags | App may propose; user may change |
|
||||
| Price | App proposal `bdft × species $/bdft`; user may replace |
|
||||
| Title, short title, description | Templates from listing facts; user may edit |
|
||||
| Title, short_description, description | Model or template from listing facts; user may edit |
|
||||
|
||||
User-entered length, thickness, SKU, price, and taxonomy override computed or inferred values.
|
||||
|
||||
## System seams
|
||||
|
||||
Milestone 1 runs in the browser. No backend and no inference URL.
|
||||
Handoff is the device draft: source photo, approved mask, inventory PNG, length, thickness, widths, sqft, bdft.
|
||||
Milestone 3 uses FastAPI as the Woo client. Store credentials stay on the server.
|
||||
Milestones 1 and 2 run in the browser. No backend and no inference URL on the shipped path.
|
||||
|
||||
## Create
|
||||
Milestone 1 handoff is the device draft: source photo, approved mask, inventory PNG, length, thickness, widths, sqft, bdft.
|
||||
|
||||
`SLAB-UAT-*` creates as a WooCommerce draft (store status).
|
||||
Duplicate SKU blocks create.
|
||||
A listing can be created with inference off.
|
||||
A device draft in the app is separate from a WooCommerce draft.
|
||||
Milestone 2 extends the draft with the photo batch, calibration choice, per-photo inventory PNGs, and the dimension-overlay calibration copy.
|
||||
|
||||
## Milestones
|
||||
Milestone 3 introduces FastAPI as the Woo client and inference proxy. Store credentials and inference secrets stay on the server. Spec: `docs/capabilities/CREATE.md`.
|
||||
|
||||
Work is split into four milestones. Each milestone is done when its acceptance can be demonstrated.
|
||||
## Create rules (summary)
|
||||
|
||||
### Milestone 1 — Capture
|
||||
Full behavior is in `docs/capabilities/CREATE.md`.
|
||||
|
||||
Spec: `docs/CAPTURE.md`. Current work.
|
||||
- `SLAB-UAT-*` creates as a WooCommerce draft (store status).
|
||||
- Duplicate SKU blocks create.
|
||||
- A listing can be created with inference off.
|
||||
- A device draft in the app is separate from a WooCommerce draft.
|
||||
|
||||
User stories:
|
||||
- Photograph a slab on a green or black sheet and remove the background.
|
||||
- Set length from the bounding rectangle and get widths, sqft, and bdft.
|
||||
- Leave and later Continue or Start fresh.
|
||||
## Milestone status
|
||||
|
||||
Acceptance: inventory PNG and measurements visible on a phone. No Woo create.
|
||||
| Milestone | Spec | Status |
|
||||
|---|---|---|
|
||||
| 1 Capture | `docs/capabilities/CAPTURE.md` | Shipped |
|
||||
| 2 Photos | `docs/capabilities/PHOTOS.md` | Shipped (UAT `:10881`) |
|
||||
| 3 Create | `docs/capabilities/CREATE.md` | Scope is the M3 ID set in `docs/MILESTONES.md`. Exit: submission-ready for WooCommerce. |
|
||||
|
||||
### Milestone 2 — Review
|
||||
|
||||
Remaining listing fields in the app.
|
||||
Acceptance: every field in Complete listing is filled or proposed and editable.
|
||||
|
||||
### Milestone 3 — Create
|
||||
|
||||
WooCommerce draft for a practice SKU. Then clear the device draft.
|
||||
Acceptance: `SLAB-UAT-*` exists in the store as draft.
|
||||
|
||||
### Milestone 4 — Assist
|
||||
|
||||
Optional vision and generated copy on the manual path from Milestones 1–3.
|
||||
Acceptance: create still works with inference off.
|
||||
Scope is the M3 requirement subset in `docs/MILESTONES.md`; `docs/REQUIREMENTS.md` is normative.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Docs map
|
||||
|
||||
Read this file first. It tells you what SlabUploader is for, what already ships, and what Milestone 3 must build.
|
||||
|
||||
**Who this is for.** Mill owner (Ty), humans reviewing scope, and agents implementing.
|
||||
|
||||
**Source of truth for Milestone 3.** `docs/REQUIREMENTS.md` plus the M3 ID subset in `docs/MILESTONES.md`. Behavior is in `docs/capabilities/CREATE.md` and the `docs/contracts/` set.
|
||||
|
||||
## High-level project goal
|
||||
|
||||
SlabUploader lists one-of-a-kind wood slabs on Whidbey Wood Store (WooCommerce).
|
||||
|
||||
One slab is one unit and one listing. Photos and metadata are the inventory record. The mill owner photographs a slab on a green or black sheet, gets store-ready transparent PNGs and measurements on the phone, fills the store's taxonomy and price, then creates a WooCommerce product.
|
||||
|
||||
Store: https://www.whidbeywoodstore.com/
|
||||
|
||||
## What is already implemented (pre-M3)
|
||||
|
||||
| Milestone | Spec | Status on this branch |
|
||||
|---|---|---|
|
||||
| 1 Capture | `docs/capabilities/CAPTURE.md` | Shipped. Browser-only mask, length axis, measure, inventory PNG, device draft. |
|
||||
| 2 Photos | `docs/capabilities/PHOTOS.md` | Shipped. Batch photos, pick calibration, apply mask/crop to the batch, dimension overlay, Photos-ready lineup. UAT on `:10881`. |
|
||||
|
||||
There is no Woo create yet. There is no Complete-listing taxonomy UI yet. There is no FastAPI Woo client (`backend/` is a stub). Inference is not wired.
|
||||
|
||||
## What Milestone 3 builds
|
||||
|
||||
Spec: `docs/capabilities/CREATE.md`.
|
||||
|
||||
**Exit criteria.** A Photos-ready slab becomes **submission-ready for WooCommerce**, and a practice `SLAB-UAT-*` draft exists in the store that matches the reviewed ready set. Create must still work with inference off.
|
||||
|
||||
In plain terms, M3 connects the Photos-ready device draft to the real store:
|
||||
|
||||
1. Talk to WooCommerce with a WordPress application password (server-side only).
|
||||
2. Sync and use the **Woo Slab Taxonomy** (categories, five locked attributes, `fig-*` / `feat-*` tags) per the requirements.
|
||||
3. Pricing from species `$/bdft` rules, with user override.
|
||||
4. Inference Settings (OpenAI-compatible URL, key, model) and Call 1 / Call 2 rules when enabled.
|
||||
5. Review to the full ready set, then create the Woo product (force draft for `SLAB-UAT-*`).
|
||||
|
||||
Technical detail for Woo sync, metadata, pricing, and inference is in `docs/capabilities/CREATE.md` and `docs/contracts/`.
|
||||
|
||||
## How to read the folder
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `README.md` (this file) | Map: goals, shipped, M3. |
|
||||
| `PRODUCT.md` | Product overview: complete listing, decision rights, seams. |
|
||||
| `capabilities/CAPTURE.md` | Milestone 1 Capture (shipped). |
|
||||
| `capabilities/PHOTOS.md` | Milestone 2 Photos (shipped). |
|
||||
| `capabilities/CREATE.md` | Milestone 3 Create. Read this before task breakdown or coding. |
|
||||
| `contracts/CONTENT-WOO.md` | Listing copy and payload intent; the deterministic title and short-description patterns. |
|
||||
| `DEPLOYMENT.md` | Ops runbook: hosts, build, smoke checks, rollback. |
|
||||
| `UAT-PRACTICE.md` | Human practice walk for a `SLAB-UAT-*` listing. Defers to `capabilities/CREATE.md` on conflict. |
|
||||
| `archive/` | Historical only. Not M3 scope. |
|
||||
|
||||
## Deployment
|
||||
|
||||
Hosts, ports, build and rollback steps, and the dev-test port block live in `docs/DEPLOYMENT.md`;
|
||||
`deploy/INVENTORY.md` is the only source for what runs where. This map does not repeat them.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Requirements
|
||||
|
||||
Normative source of truth. One row per requirement. Enforced by `tools/check_requirements.py`.
|
||||
|
||||
A requirement is defined here and nowhere else. Capability and contract docs cite IDs; they do not restate them.
|
||||
|
||||
Format and rules: `docs/STRUCTURE.md`.
|
||||
Scope: `shipped` | `M3` | `later`.
|
||||
`Test` is a test path, a requirement ID, or `—` (traceability gap). A path given for a `shipped` requirement must exist.
|
||||
|
||||
Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplification` tag; decisions recorded in `.audit/2026-09-18-reconciliation-decisions.md`.
|
||||
|
||||
## CAPTURE — ecommerce-ready capture
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| CAPTURE-5 | Mask, measure, crop, and PNG output are a deterministic function of photo pixels and user inputs, with no inference, model, or network in the math. | shipped | — |
|
||||
| CAPTURE-10 | Supported clients are iOS Safari and Android Chrome, plus desktop Chrome, Edge, and Firefox; unsupported clients get a hard stop, and there are no polyfills. | shipped | — |
|
||||
| CAPTURE-15 | Capture is online only; offline capture is deferred. | later | — |
|
||||
| CAPTURE-20 | Photos are taken on a green or black sheet with the slab fully on the sheet; the first photo is top-down with the lens parallel to the broad face. | shipped | — |
|
||||
| CAPTURE-25 | A batch of one or more photos of one slab is uploaded, and the owner selects which photo is the calibration photo. | shipped | — |
|
||||
| CAPTURE-30 | The calibration photo drives mask, length axis, measure, and crop; other batch photos reuse the approved sheet, sensitivity, and crop rules. | shipped | — |
|
||||
| CAPTURE-35 | Sheet detection samples a 2 to 5 percent strip on all four borders and classifies green or black by HSV; otherwise the owner picks the sheet. | shipped | frontend/src/mask/sheetKeep.test.ts |
|
||||
| CAPTURE-40 | Mask controls are exactly Sheet (auto, green, black) and Sensitivity (0 to 100, step 1); Edge offset and Feather are removed. | shipped | — |
|
||||
| CAPTURE-41 | Sensitivity 0 to 100 maps to green-sheet chroma tightness and black-sheet brightness cutoff in the worker; both widen as sensitivity rises. | shipped | frontend/src/mask/sheetKeep.test.ts |
|
||||
| CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts |
|
||||
| CAPTURE-50 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — |
|
||||
| CAPTURE-55 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — |
|
||||
| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
|
||||
| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
|
||||
| CAPTURE-75 | Scale equals the pixel distance between the two ends of the fixed tape line divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-170 | The calibration overlay draws one black tape-line fixed in the middle of the 3:4 frame, with a tick at each end. The line is a stage fixture: it is not editable, and its ends are set by the crop-fill setting alone (at 90 percent each end sits 5 percent in from the frame edge). The owner pans, zooms, and rotates the slab until its long axis lies along the line. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||
| CAPTURE-175 | The owner-declared length is the tape reading taken where the slab was aligned to the line; scale equals the fixed line's pixel distance between its two ends, in source-photo coordinates, divided by that declared length. Mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-95 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-100 | Crop is 3:4 portrait, slab centered, 90 percent fill at the widest and tallest extremes, on a transparent background. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-105 | After crop the shorter side is at least 800 px when the source allows; otherwise warn and require a new photo, and never upscale. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.batchApply.test.ts |
|
||||
| CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.batchApply.test.ts |
|
||||
| CAPTURE-120 | The dimension overlay is black and oriented to the approved length axis, with the length graphic beside the wood on the right in axis space. | shipped | frontend/src/png/overlay.test.ts |
|
||||
| CAPTURE-125 | Overlay width stations run every 12 inches starting 6 inches from the bottom while station is at most length minus 6; the bottom end follows the calibration photo EXIF orientation. | shipped | frontend/src/png/overlay.test.ts |
|
||||
| CAPTURE-130 | The photos-ready lineup order is clean calibration PNG, dimension-overlay copy, then remaining batch photos in capture order. | shipped | frontend/src/capture/photosReadyLineup.test.ts |
|
||||
| CAPTURE-135 | The client computes all deterministic math; the server stores the client numbers and never recomputes them as source of truth. | shipped | — |
|
||||
| CAPTURE-140 | Load-bearing client APIs are image decode, canvas or OffscreenCanvas, a worker, PNG encode, IndexedDB, and fetch; no OpenCV, ONNX, or U2Net on the shipped path. | shipped | — |
|
||||
| CAPTURE-141 | The preview is display-only: it fits the device width with a height cap so controls stay visible, and mask, rectangle, crop, scale, sqft, and PNG encode use source photo pixels, never the preview's CSS size. | shipped | frontend/src/capture/fitDisplay.test.ts |
|
||||
| CAPTURE-145 | Capture knob preferences persist in localStorage until reset to default. | shipped | — |
|
||||
| CAPTURE-150 | Mask tuning has no apply step and no server round trip. | shipped | — |
|
||||
| CAPTURE-155 | Failures such as a broken mask or undersized source never silently guess; the resolution is retake. | shipped | — |
|
||||
| CAPTURE-160 | Length is stored and displayed to the nearest 1/8 inch, rounded values are what is stored, and sqin appears nowhere in the UI, tests, or copy. | shipped | — |
|
||||
| CAPTURE-165 | U2Net on-demand server matting is deferred. | later | — |
|
||||
|
||||
## METADATA — listing attributes and copy
|
||||
|
||||
The listing is built in two inference calls, both optional. Call 1 (vision) reads the photos and proposes taxonomy; Call 2 (text) writes the long description. The product name and short description are assembled deterministically from the listing facts and never come from a model. With inference off, the owner fills the taxonomy by hand and the server produces the description.
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| METADATA-5 | The listing taxonomy is exactly the synced WooCommerce set; the app never hardcodes, invents, or falls back to its own lists. | M3 | — |
|
||||
| METADATA-10 | Product categories are exactly one species leaf and one or more wood-category leaves; parents group only and are never assigned. | M3 | — |
|
||||
| METADATA-15 | The five locked attributes are Edge Type (1), Figure (at least 1, includes plain), Grade (exactly 1), Thickness (1, round-up band), and Moisture (1, default kiln-dried, never inferred). | M3 | — |
|
||||
| METADATA-20 | Missing attributes or terms are skipped, never created. | M3 | — |
|
||||
| METADATA-25 | Species, character, length, and free-text thickness are not product attributes. | M3 | — |
|
||||
| METADATA-30 | Product tags come from the synced Woo set and are all optional; no hardcoded prefixes, and the bare fig tag is kept. | M3 | — |
|
||||
| METADATA-100 | Call 1 returns feature suggestions as feature_tag_ids chosen only from the synced feat-* tag set (display names without the feat- prefix); WooCommerce is the authority for those tags. No free-text observation bags. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-40 | Call 1 returns species, wood categories, edge, figure (multi), and feature-tag id suggestions constrained to the synced LISTS, each with confidence. Grade is not suggested by Call 1 (owner judgment). Wood category is decided before edge; cookies/burl prefer Wild edge when present. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-45 | Call 1 confidence at or above the threshold (default 0.7) pre-fills review fields; below it the field stays empty for the owner to fill by hand or re-run Call 1; suggestions are always editable. | M3 | — |
|
||||
| METADATA-50 | Typed values always win over inferred values. | M3 | — |
|
||||
| METADATA-85 | The primary wood category selects the Call 2 prompt and the title and short-description patterns. | M3 | — |
|
||||
| METADATA-80 | The server assembles the name and short description deterministically from the primary wood category's patterns and the dimension fact block. The patterns use only synced taxonomy terms and capture facts. | M3 | — |
|
||||
| METADATA-90 | The dimension fact block supplies the exact strings the patterns interpolate and the only numbers Call 2 may print; there is no L by W by T rectangle assumption. | M3 | — |
|
||||
| METADATA-55 | Call 2 runs on demand and produces the long description. | M3 | — |
|
||||
| METADATA-60 | Call 2 receives the processed photos, the metadata the owner picked or changed in review, including primary and secondary wood categories and the human-selected taxonomy attributes and tags, the parent-category path, the dimension fact block, and brand/geo context. | M4 | — |
|
||||
| METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
|
||||
| METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — |
|
||||
| METADATA-70 | With inference off, the server produces the long description. | M3 | — |
|
||||
| METADATA-66 | Every content generation, from a deterministic pattern or from the model, is logged with its inputs, the model request and response when a model was used, and the final stored content. The logged request keeps full text and photo refs only — never base64 image payloads. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-75 | Inference never blocks create and never auto-publishes. | M3 | — |
|
||||
| METADATA-105 | Inference prompts are stored in the database with all revisions; each call uses the active revision, the revision history is retained, and the revision used is recorded with the call. | M3 | — |
|
||||
| METADATA-106 | The prompt revision interface is a REST API: append a revision, activate a revision, and read the active prompt with its revision list. There is no prompt editing UI. | M3 | — |
|
||||
| METADATA-107 | Before the prompt revision interface is exposed beyond the trusted network it requires authentication. | later | — |
|
||||
| METADATA-110 | The Generate text control appears only when content generation is enabled and the inference endpoint is reachable, and is disabled when the text service is down. | M3 | — |
|
||||
| METADATA-115 | A runtime inference failure is classified recoverable or unrecoverable for the owner: recoverable offers Retry, unrecoverable gives a plain message explaining it. | M3 | — |
|
||||
| METADATA-120 | Call 2 is grounded in the owner's human-picked primary and secondary wood categories and taxonomy values from review; it does not receive or depend on Call 1 suggestions. | M4 | — |
|
||||
|
||||
## REVIEW — confirm, tweak, ready
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| REVIEW-5 | Submission-ready requires at least two images (clean first pic plus its dimension-overlay copy), SKU, length, thickness, computed widths and sqft and bdft, exactly one species leaf, at least one wood-category leaf, one Edge Type, at least one Figure, exactly one Grade, one Thickness band, one Moisture, and a price. | M3 | — |
|
||||
| REVIEW-10 | Title, short_description, and description are required on the product; title and short_description are assembled deterministically and the description is written by the model or the server. | M3 | — |
|
||||
| REVIEW-15 | In review the owner declares one primary wood category and any secondary ones before Call 2 runs; primary drives the title and primary plus secondary drive the description; the default primary is the first category selected. | M3 | — |
|
||||
| REVIEW-20 | Every empty required field shows an inline nudge as the owner works; the slab cannot reach ready until the full set is filled, and there is no single submit-time gap list. | M3 | — |
|
||||
| REVIEW-25 | The price suggestion is bdft times the species rate from Settings; when no rate exists the price starts empty and the owner must enter one before create; the owner may override and the override sticks. | M3 | — |
|
||||
| REVIEW-30 | Widths, sqft, and bdft are read-only in production, derived from the mask, and are not overridable. | M3 | — |
|
||||
| REVIEW-35 | A picked taxonomy id that disappeared after a refresh shows as no longer valid and is never silently swapped for another value. | M3 | — |
|
||||
| REVIEW-40 | The owner can edit any field, including generated title and description, until create, except the calculated measurement fields (widths, sqft, bdft), which are not user mutable. | M3 | — |
|
||||
| REVIEW-45 | SKU is 3 to 24 characters, alphanumeric and hyphen only. | M3 | — |
|
||||
| REVIEW-50 | Title and short_description warn past 60 and 160 characters and never exceed the store's hard limits; description has no maximum. | M3 | docs/contracts/WOO-API.md |
|
||||
| REVIEW-55 | Review always presents a thumb-first manual taxonomy flow: the owner selects species, primary and optional secondary wood categories, Edge, Figure, Grade, Moisture, and optional synced tags; no Call 1 suggestion, inference pre-fill, or AI marker is shown. | M4 | — |
|
||||
|
||||
## PUBLISH — store submission
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| PUBLISH-5 | The create sequence validates the ready set, looks up the SKU and stops if it exists, refreshes taxonomy when the last sync is over one hour old, resolves categories and tags and attributes, uploads images, calls POST products, then finalizes. | M3 | — |
|
||||
| PUBLISH-10 | SKUs matching SLAB-UAT-* always create a WooCommerce draft regardless of the create-status setting. | M3 | — |
|
||||
| PUBLISH-15 | A duplicate SKU blocks create and offers edit-SKU or open-existing; this version has no update path. | M3 | — |
|
||||
| PUBLISH-20 | A stale value on submit is a field-level error that shows the current options and never a raw code. | M3 | — |
|
||||
| PUBLISH-25 | On success the server sets woo_product_id and returns it on the terminal poll, purges photos, discards the local working slab row, and clears the device draft; the app never deletes from WooCommerce; WooCommerce is the source of truth. | M3 | — |
|
||||
| PUBLISH-30 | On failure the submission attempt is marked failed, photos are retained, sync_log is written, and a plain message is shown on the affected field or action; retry works from the same screen. | M3 | — |
|
||||
| PUBLISH-31 | If image uploads succeed but the product POST fails, the orphaned WooCommerce media is left in place, the failure is logged, and a manual cleanup note is surfaced; this version does not auto-delete it. | M3 | — |
|
||||
| PUBLISH-35 | The owner never sees raw status codes, stack traces, or internal error codes. | M3 | — |
|
||||
| PUBLISH-36 | Every publish failure is classified for the owner as recoverable (he can act) or unrecoverable (outside his control), and the plain message states which it is and the next step. | M3 | — |
|
||||
| PUBLISH-37 | A recoverable failure points at the field to fix, or offers Retry while the draft and photos are retained. | M3 | — |
|
||||
| PUBLISH-38 | An unrecoverable failure says it is outside the owner's control and offers the available next step. | M3 | — |
|
||||
| PUBLISH-40 | Images upload as processed PNGs with alpha; the clean primary, the primary dimensioned copy, and every remaining batch photo the owner selected are uploaded, with no upper cap on the number of images. | M4 | — |
|
||||
| PUBLISH-45 | The Woo image order is the photos-ready lineup: clean calibration, dimension-overlay copy, then the rest in capture order. | M3 | — |
|
||||
| PUBLISH-50 | Published means the store create succeeded, not that the product is live when status is draft. | M3 | — |
|
||||
| PUBLISH-55 | Only non-null fields are sent; the payload carries name, type simple, status, sku, slug, regular_price, description, short_description, categories, tags, images, attributes, virtual false, downloadable false, catalog_visibility visible, sold_individually true. The slug is the sanitized product name plus the lowercased SKU. | M3 | docs/contracts/WOO-API.md |
|
||||
| PUBLISH-60 | Each published product represents exactly one slab; WooCommerce quantity is one. | M3 | — |
|
||||
|
||||
## SETTINGS — configured once
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| SETTINGS-5 | WOO_BASE_URL lives in compose or .env, is https only, is injected into FastAPI, and is shown read-only in Settings; it is not a SQLite settings row. | M3 | — |
|
||||
| SETTINGS-10 | FastAPI refuses to start when WOO_BASE_URL is missing or not https. | M3 | — |
|
||||
| SETTINGS-15 | Woo auth is a WordPress application password for a dedicated low-privilege user, not consumer keys and not the admin login. | M3 | — |
|
||||
| SETTINGS-20 | Woo username and application password are entered in Settings and stored AES-GCM in SQLite; the browser never keeps them. | M3 | — |
|
||||
| SETTINGS-25 | SLAB_AES_KEY lives in compose or .env as a 32-byte base64 value, never in the DB or repo, and a missing key refuses startup. | M3 | — |
|
||||
| SETTINGS-30 | Inference base URL and API key are stored encrypted in SQLite, while inference_model, inference_enabled, content_llm_enabled, brand_voice, and geo_context are settings. | M3 | — |
|
||||
| SETTINGS-35 | Species rates are configured in Settings as one flat dollars-per-bdft value per synced species leaf, with no tiers. | M3 | — |
|
||||
| SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — |
|
||||
| SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — |
|
||||
| SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — |
|
||||
| SETTINGS-51 | Taxonomy sync keeps only the store's figure and feature product-tag families; all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | — |
|
||||
| SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — |
|
||||
| SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — |
|
||||
| SETTINGS-65 | woo_create_status defaults to draft. | M3 | — |
|
||||
| SETTINGS-70 | The vision pixel cap is the only user-facing vision setting; the JPEG quality lives in server config. | M3 | — |
|
||||
| SETTINGS-75 | Secrets never appear in any GET response; only a configured boolean is returned. | M3 | — |
|
||||
| SETTINGS-80 | test-woo returns ok and detail; it fails closed when credentials are missing or the base URL is not HTTPS, and distinguishes an authentication rejection from an unreachable store. | M3 | — |
|
||||
| SETTINGS-85 | The settings API rejects an attempt to write WOO_BASE_URL; the value is env-only and read-only. | M3 | — |
|
||||
| SETTINGS-90 | inference_enabled and content_llm_enabled default off. | M3 | — |
|
||||
|
||||
## APP — device draft and state
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| APP-5 | Slab status runs draft, calibrated, ready, publishing, published, or failed. | shipped | — |
|
||||
| APP-10 | The device draft is separate from a WooCommerce draft and is restored on open through Resume; Continue restores the last approved state and Start fresh discards. | shipped | — |
|
||||
| APP-15 | The device draft is cleared after a successful create. | M3 | — |
|
||||
| APP-20 | Slab ids are client-generated, timestamps are UTC ISO-8601, and client_rev and server_rev support optimistic concurrency. | M3 | — |
|
||||
| APP-25 | FastAPI is the only caller of WooCommerce and of the inference endpoint; the browser holds no store or inference credentials. | shipped | — |
|
||||
| APP-30 | The API base is /api/v1 and health is at /api/health. | M3 | — |
|
||||
| APP-35 | Errors use a uniform envelope and are JSON; lists are returned in full without pagination. | M3 | — |
|
||||
| APP-40 | The server stores the client-sent derived numbers and does not recompute them as source of truth. | shipped | — |
|
||||
| APP-45 | Health returns status, version, and woo_reachable, and goes non-200 when the database or image volume is not writable. | M3 | — |
|
||||
| APP-50 | Upstream WooCommerce 4xx responses are mapped into the uniform error envelope rather than passed through raw. | M3 | — |
|
||||
| APP-55 | Structured JSON logs carry a request id threaded from the client through FastAPI to WooCommerce, and the log level is set by a LOG_LEVEL env. | M3 | — |
|
||||
| APP-60 | On supported clients, interactive controls are reachable and operable by keyboard with a visible focus ring, and the layout reflows without clipping at 320 px. | M3 | — |
|
||||
| APP-65 | No secret material (Woo credentials, inference key, or AES key) appears in any log output. | M3 | — |
|
||||
|
||||
## OPS — deploy and release
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| OPS-5 | Deploy is Docker Compose plus Caddy on an inventoried host, reachable only over LAN or Tailscale with HTTPS. | shipped | — |
|
||||
| OPS-10 | WOO_BASE_URL and SLAB_AES_KEY are supplied as compose environment and never stored in the repo. | shipped | — |
|
||||
| OPS-15 | deploy/INVENTORY.md is filled before any port bind, covering host, hostname, ports, and DNS reachability from the phone. | shipped | — |
|
||||
| OPS-20 | Services bind to localhost and Caddy is the only public surface. | shipped | — |
|
||||
| OPS-25 | Base images are pinned, not latest. | shipped | — |
|
||||
| OPS-30 | Backup is a WAL-safe SQLite backup plus a tar of the images volume, with retention of 7 daily and 4 weekly. | later | — |
|
||||
| OPS-35 | Restore is stop, restore DB and images, start, and it is tested at least once before backup is considered done. | later | — |
|
||||
| OPS-40 | The UAT environment is ubudual5060 at http://100.64.0.9:10881; port 10880 is off limits. | M3 | — |
|
||||
| OPS-45 | Inference is a remote, configurable endpoint, not part of the app host. | shipped | — |
|
||||
| OPS-50 | The repository on gitea-atd is the source of truth. | shipped | — |
|
||||
| OPS-55 | The app has no login of its own; it relies on the trusted LAN or Tailscale network plus HTTPS. | shipped | — |
|
||||
|
||||
## QUALITY — verification
|
||||
|
||||
| ID | Requirement | Scope | Test |
|
||||
|---|---|---|---|
|
||||
| QUALITY-5 | Tests are written with the code; no feature ships without a passing test and its shown output. | shipped | — |
|
||||
| QUALITY-10 | Deterministic modules are test-driven and covered before any UI consumes them. | shipped | — |
|
||||
| QUALITY-15 | Unit tests for the deterministic core cover vectors TV-1 through TV-10. | shipped | — |
|
||||
| QUALITY-20 | The full suite passes with inference disabled, asserting zero inference calls. | M3 | — |
|
||||
| QUALITY-25 | Pass or fail is only claimed with raw runner evidence; missing evidence means not run. | shipped | — |
|
||||
| QUALITY-30 | Ty reads raw results and owns gate exit; agents never declare a gate green. | shipped | — |
|
||||
| QUALITY-35 | Integration tests use a Woo mock and cover success, error, duplicate SKU, stale field, and taxonomy anchor. | M3 | — |
|
||||
| QUALITY-40 | Property checks assert bdft is monotonic in thickness and square feet. | shipped | — |
|
||||
| QUALITY-45 | The deterministic functions assert the bdft identity and the no-upscale path. | shipped | — |
|
||||
| QUALITY-50 | UI and end-to-end tests run against a Compose stack with mocked Woo. | M3 | — |
|
||||
| QUALITY-55 | Pipeline vector TV-1: an interior green patch not touching the border survives; U2Net is not invoked. | shipped | — |
|
||||
| QUALITY-60 | Pipeline vector TV-2: dark interior wood on a black sheet survives, and flood fill isolates only border-connected regions. | shipped | — |
|
||||
| QUALITY-65 | Pipeline vector TV-3: a 96 inch slab, 1.5 inch thick, 0.64 sqft mask yields 0.96 bdft. | shipped | — |
|
||||
| QUALITY-70 | Pipeline vector TV-4: width samples at 6 inch stations on a 96 inch axis report min, max, and average perpendicular to the confirmed axis. | shipped | — |
|
||||
| QUALITY-75 | Pipeline vector TV-5: one square end and one 45 degree cut, the length axis follows the prevailing length. | shipped | — |
|
||||
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
|
||||
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
|
||||
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
|
||||
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py |
|
||||
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
|
||||
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
|
||||
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
|
||||
| QUALITY-115 | Browser end-to-end capture through publish is verified on iOS Safari, Android Chrome, and desktop Chrome, Edge, and Firefox. | later | — |
|
||||
| QUALITY-120 | No raw status code, stack trace, or internal code reaches the owner in any tested path. | M3 | — |
|
||||
| QUALITY-125 | An encrypted credential stored by Settings decrypts back to the value it was given. | M3 | — |
|
||||
| QUALITY-130 | The M4 exit walk records raw runner evidence for a real `SLAB-UAT-*` WooCommerce draft created through the UI; the draft matches the reviewed ready set and is not published live. | M4 | — |
|
||||
@@ -0,0 +1,121 @@
|
||||
# Structure contract
|
||||
|
||||
This file defines the shape of the `docs/` system and the `.planning/` tree, and how requirements
|
||||
enter it. `tools/check_requirements.py` enforces it.
|
||||
|
||||
If the repo and this file disagree, fix one deliberately. Do not let them drift.
|
||||
|
||||
## Artifact registry
|
||||
|
||||
Every Markdown file under `docs/` is registered here. `docs/archive/**` and `docs/_templates/**` are exempt.
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `docs/README.md` | index |
|
||||
| `docs/PRODUCT.md` | intent |
|
||||
| `docs/STRUCTURE.md` | meta |
|
||||
| `docs/REQUIREMENTS.md` | requirements |
|
||||
| `docs/MILESTONES.md` | scope |
|
||||
| `docs/capabilities/CAPTURE.md` | capability |
|
||||
| `docs/capabilities/PHOTOS.md` | capability |
|
||||
| `docs/capabilities/CREATE.md` | capability |
|
||||
| `docs/contracts/CONTENT-WOO.md` | contract |
|
||||
| `docs/UAT-PRACTICE.md` | practice |
|
||||
| `docs/DEPLOYMENT.md` | ops |
|
||||
| `docs/capabilities/README.md` | index |
|
||||
| `docs/contracts/README.md` | index |
|
||||
| `docs/contracts/WOO-API.md` | contract |
|
||||
| `docs/contracts/API.md` | contract |
|
||||
| `docs/contracts/DATA-MODEL.md` | contract |
|
||||
| `docs/contracts/PROMPTS.md` | contract |
|
||||
| `docs/contracts/UX.md` | contract |
|
||||
| `docs/contracts/TEST-SPEC.md` | contract |
|
||||
|
||||
Every Markdown file under `.planning/` is registered here. `.planning/` holds planning artifacts
|
||||
only and is **not** a second requirements home: `docs/REQUIREMENTS.md` and `docs/MILESTONES.md`
|
||||
remain the point of record, and `.planning/` files cite requirement IDs without defining them.
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `.planning/PROJECT.md` | vision |
|
||||
| `.planning/ROADMAP.md` | roadmap |
|
||||
| `.planning/MILESTONES.md` | history |
|
||||
| `.planning/milestones/v0.5/M3/roadmap.md` | roadmap |
|
||||
| `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown |
|
||||
| `.planning/milestones/v0.5/M4/goals.md` | roadmap |
|
||||
| `.planning/milestones/v0.5/M4/tasks.md` | breakdown |
|
||||
|
||||
Roles:
|
||||
|
||||
- **index** — navigation only.
|
||||
- **intent** — why and what; not testable requirements.
|
||||
- **meta** — this contract.
|
||||
- **requirements** — the only normative text.
|
||||
- **scope** — milestone-to-ID mapping.
|
||||
- **capability** — behavior and acceptance for one capability; cites IDs, does not define them.
|
||||
- **contract** — cross-cutting interface detail; cites IDs, does not define them.
|
||||
- **practice** — human walkthrough.
|
||||
- **ops** — runbook for hosts, builds, and release; cites inventory and contracts, defines no requirements.
|
||||
- **vision** — overall project vision and current state; cites IDs, defines none.
|
||||
- **roadmap** — version or milestone objectives; cites IDs, defines none.
|
||||
- **breakdown** — ordered task list for a version or milestone; cites IDs, defines none.
|
||||
- **history** — historical log of shipped milestones; cites IDs, defines none.
|
||||
|
||||
## ID namespaces
|
||||
|
||||
Namespaces are **owner jobs, not technology layers**. A requirement belongs to the job it serves, and each namespace owns that job's screen, rules, server behavior, and data. There is no separate API, data, or UX layer to fight over.
|
||||
|
||||
All requirement text lives only in `docs/REQUIREMENTS.md`. This table assigns the home and prefix.
|
||||
|
||||
| Prefix | The owner is... | Covers |
|
||||
|---|---|---|
|
||||
| `CAPTURE` | capturing ecommerce-ready pics | photo intake, pick calibration/dimension photo, BG removal, crop, axis, length/width, calculations, inventory PNG, dimension overlay, lineup |
|
||||
| `METADATA` | building the listing | wood attributes and tags, title, short/deep description, via up to 2 inference calls |
|
||||
| `REVIEW` | reviewing and correcting | confidence display, field gating, tweaks, per-slab price suggestion and override, ready gate |
|
||||
| `PUBLISH` | submitting to the store | WooCommerce create, draft/live, duplicate SKU, cleanup |
|
||||
| `SETTINGS` | configuring before the session | Woo connection, inference config, species `$/bdft` rates, brand/geo |
|
||||
| `APP` | (foundation, not a job) | device draft, slab status, resume, server seams, secret handling |
|
||||
| `OPS` | (foundation, not a job) | deploy, hosts, release, UAT |
|
||||
| `QUALITY` | (foundation, not a job) | test obligations, vectors, anti-metrics |
|
||||
|
||||
Price has two homes by design: the species `$/bdft` rates are configured in `SETTINGS`; the per-slab suggestion and override live in `REVIEW`.
|
||||
|
||||
## Requirement format
|
||||
|
||||
Defined only in `docs/REQUIREMENTS.md`, as table rows:
|
||||
|
||||
```
|
||||
| ID | Requirement | Scope | Test |
|
||||
| CAPTURE-5 | Statement of one testable requirement | shipped | frontend/src/measure/measure.test.ts |
|
||||
```
|
||||
|
||||
- `Scope` is `shipped`, a milestone scope (`M3`, `M4`, ...), or `later`.
|
||||
- `Test` is a repo-relative test path, a requirement ID, or `—`. A path given for a `shipped` requirement must exist; a given ID must resolve to a defined requirement.
|
||||
- IDs advance by 5 within each namespace (`5, 10, 15, ...`), resetting for each prefix. The gaps are intentional room for later insertions; never renumber to close a gap.
|
||||
|
||||
## How to change a requirement
|
||||
|
||||
- **Modify:** edit the single row in `REQUIREMENTS.md`; update `Test`; if scope changed, update `MILESTONES.md`. Do not create a doc.
|
||||
- **Add:** pick the prefix and home from the namespace table; append a row; add the test. Do not create a doc.
|
||||
- **New domain:** that is a structure change. Add a namespace row and a home here first, deliberately. Do not create a drive-by artifact.
|
||||
|
||||
Nothing outside `REQUIREMENTS.md` may define a requirement. Capability and contract docs cite IDs only.
|
||||
|
||||
## Enforced by `tools/check_requirements.py`
|
||||
|
||||
1. Every `docs/` Markdown file (except `archive/**` and `_templates/**`) is registered above.
|
||||
2. Every registered file exists on disk.
|
||||
3. Requirement IDs match `^[A-Z]+-\d+$`, are unique, and use a registered prefix.
|
||||
4. Scope is one of `shipped | M3 | later`.
|
||||
5. `Test` references resolve: a path given for a `shipped` requirement exists; an ID given resolves to a defined requirement; `—` is allowed.
|
||||
6. Requirement rows appear only in `REQUIREMENTS.md`.
|
||||
7. Every ID listed in `MILESTONES.md` exists in `REQUIREMENTS.md`.
|
||||
8. Every `.planning/` Markdown file is registered above.
|
||||
9. Every registered `.planning/` file exists on disk.
|
||||
10. Requirement rows appear nowhere outside `REQUIREMENTS.md`, including `.planning/`.
|
||||
|
||||
## Phase 2 migration (complete)
|
||||
|
||||
Capability docs live in `docs/capabilities/` and contracts in `docs/contracts/`. The reconciliation
|
||||
and migration evidence is archived under `.audit/archive/`; the coverage ledger and matrices are in
|
||||
`.audit/coverage/`. Wire `check_requirements.py` into CI when a CI stage exists.
|
||||
@@ -0,0 +1,235 @@
|
||||
# UAT Practice Walk: one `SLAB-UAT-*` listing
|
||||
|
||||
Status: WALK · for Ty to run · SlabUploader
|
||||
|
||||
This is the walkable practice plan. It proves one listing end to end without touching a live product.
|
||||
Follow the sections in order. Each one names what you do, what you should see, and where the rule
|
||||
lives.
|
||||
|
||||
**Inference is OFF for this walk.** The optional ON path is only valid after Gate C is green and
|
||||
the inference endpoint is already configured. Inference stays off here.
|
||||
|
||||
---
|
||||
|
||||
> Milestone 3 exit criteria and field rules live in `docs/capabilities/CREATE.md`. This walk is the human practice path for a `SLAB-UAT-*` listing. Prefer `docs/capabilities/CREATE.md` if anything conflicts.
|
||||
|
||||
|
||||
## 1. Host and deploy preconditions
|
||||
|
||||
Before this walk, the app must already be on the inventoried host. This section only checks that.
|
||||
Host IPs and hostnames come from `deploy/INVENTORY.md`.
|
||||
|
||||
1. **Inventory is filled.** `deploy/INVENTORY.md` has `APP_HOST`, `APP_HOSTNAME`, ports, Caddy
|
||||
location, and the smoke results. It is the only source for what runs where on this host.
|
||||
See `docs/DEPLOYMENT.md` §1 for the worksheet rules.
|
||||
2. **Reachable over LAN/Tailscale.** Your phone reaches `https://${APP_HOSTNAME}` on the same
|
||||
network (LAN or Tailscale). No public exposure. Verify with a browser, not `curl`.
|
||||
3. **HTTPS only.** The URL is `https://`. The browser shows a valid certificate. If the phone
|
||||
warns, the Caddy TLS trust is not set up yet. That is a deploy problem, not a listing problem.
|
||||
4. **Health passes.** In a browser on the phone, open `https://${APP_HOSTNAME}/api/health`.
|
||||
You should see an ok response. This is the same check in `docs/DEPLOYMENT.md` §8 step 1.
|
||||
5. **Store URL is set.** `WOO_BASE_URL` is in the host `.env` and points at the store over
|
||||
`https://`. It is not something you type in the app. See `docs/DEPLOYMENT.md` §3 and §5.
|
||||
|
||||
If any of these fail, fix the deploy before continuing. This walk does not cover Gate B host work.
|
||||
It assumes the app is up and reachable.
|
||||
|
||||
---
|
||||
|
||||
## 2. WordPress Application Password setup
|
||||
|
||||
You do this once per store, in the store's WordPress admin. Do it before you open the app's
|
||||
Settings.
|
||||
|
||||
1. **Store is on HTTPS.** WordPress only offers Application Passwords over HTTPS. The store must
|
||||
already be on TLS. If it is not, stop and fix the store first.
|
||||
2. **Dedicated low-privilege user.** Create or use a WordPress user that can create and edit
|
||||
products and upload media. The store admin login is not reused. A shop manager role or a custom
|
||||
role with product create/edit plus media upload is enough.
|
||||
3. **Generate the password.** Sign in as that user. Go to **Users → Profile → Application
|
||||
Passwords**. Generate a password and label it "SlabUploader". Copy the password. WordPress
|
||||
shows it once.
|
||||
4. **Enter it in the app.** Open the app's Settings on the phone. Enter the WordPress username and
|
||||
the application password. The server stores them encrypted. The browser does not keep them.
|
||||
5. **Run `test-woo`.** In Settings, tap the test. It should report ok. This also resyncs the
|
||||
store's species, categories, tags, and attributes into the app. If it fails, the username or
|
||||
password is wrong, or the store is unreachable.
|
||||
|
||||
Rules: this is a WordPress Application Password, not WooCommerce consumer keys (`ck_`/`cs_`).
|
||||
The app never calls the store from the browser. FastAPI is the only caller. Rotate by revoking
|
||||
the old password in the WP profile and entering a new one in Settings, then re-running
|
||||
`test-woo`. Full lock: `AGENTS.md` §4 and §5, `docs/DEPLOYMENT.md` §5.
|
||||
|
||||
---
|
||||
|
||||
## 3. Practice build rules
|
||||
|
||||
These rules keep practice runs invisible to customers. They are locked. Changes are not made for
|
||||
the walk.
|
||||
|
||||
- **SKU prefix `SLAB-UAT-*`.** Every practice listing uses a SKU that starts with `SLAB-UAT-`.
|
||||
Pick a unique one each time, for example `SLAB-UAT-001`.
|
||||
- **Always a Woo draft.** A `SLAB-UAT-*` SKU creates a WooCommerce draft even if Settings say
|
||||
publish. Customers cannot open it. This is hard rule in `AGENTS.md` §5.
|
||||
- **`woo_create_status` defaults to draft.** The Settings field that controls create status
|
||||
defaults to `draft`. Practice uses the default. If it is `publish`, the
|
||||
`SLAB-UAT-*` rule still forces a draft.
|
||||
- **Customers never see practice listings.** A Woo draft is hidden from the storefront. If you
|
||||
can see the product on the live store, something is wrong.
|
||||
|
||||
If a practice SKU is not `SLAB-UAT-*`, it is not a practice run. Real SKUs are not published in
|
||||
this walk.
|
||||
|
||||
---
|
||||
|
||||
## 4. Prove one listing end to end
|
||||
|
||||
This is the main walk. Run it on a phone browser first, then repeat on a PC browser. Both are in
|
||||
the signed compatibility matrix (`docs/UX.md`, signed 2026-09-06).
|
||||
|
||||
Supported browsers: iOS Safari, Android Chrome on phones; desktop Chrome, Edge, Firefox on PC.
|
||||
Exact version pins are tracked under issue #4. If the browser is not supported, the app shows a
|
||||
hard stop. Switch to a supported browser.
|
||||
|
||||
### 4.1 Capture
|
||||
|
||||
1. Open the app on the phone. Start a new slab.
|
||||
2. Take one or more photos of the slab (the batch); the first should be top-down, lens parallel to the broad
|
||||
face. A tilted shot makes the numbers wrong. Retake if it is tilted.
|
||||
3. **Mask overlay and controls.** The app draws a mask over the photo. You see which pixels are
|
||||
slab and which are not. Tune the controls: **Sheet** (auto/green/black) and **Sensitivity**
|
||||
(0–100). A Sheet change runs immediately; a Sensitivity change waits 2000 ms. Each recomputes in
|
||||
the browser. Look at the edge against the real wood. If it is still off, retake the photo.
|
||||
4. **Confirm the mask.** Tap confirm.
|
||||
5. **Length axis.** The app draws the bounding rectangle and the length axis. Confirm it follows
|
||||
the slab's long direction. Rotate if it is skewed. On a nearly square slab, you say which way
|
||||
is length.
|
||||
6. **Enter numbers by hand.** Length in 1/8" steps. Thickness as a 1/8" pick. SKU: type
|
||||
`SLAB-UAT-001` (or your chosen practice SKU).
|
||||
7. **Check the computed numbers.** The browser shows square feet, board feet, and width samples.
|
||||
Board feet = square feet × thickness in inches. No divide-by-12. If the numbers look wrong,
|
||||
go back and fix the mask or the length.
|
||||
8. **Continue.** This uploads the originals, the processed PNGs, and the numbers to the server.
|
||||
The slab status becomes `calibrated`.
|
||||
|
||||
### 4.2 Review
|
||||
|
||||
1. You are on the review screen. Fill every required field. Inference is OFF, so everything starts
|
||||
empty.
|
||||
2. **Species.** Pick exactly one from the synced store list. The list came from `test-woo`.
|
||||
Only species in the list may be typed.
|
||||
3. **Wood categories.** Pick at least one from the synced list.
|
||||
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
|
||||
exactly one grade.
|
||||
5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness
|
||||
up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish
|
||||
that the Woo product's Thickness attribute is the band the captured thickness rounds up into
|
||||
(`QUALITY-95`).
|
||||
6. **Moisture.** Keep the default (kiln-dried) or change it.
|
||||
7. **fig-* tags.** Optional; pick any from the synced list.
|
||||
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
|
||||
9. **Price.** A recommendation fills from the species $/bdft rule times board feet. You can
|
||||
override it. If there is no rule for the species, the price starts empty. Type it. Publish
|
||||
is still allowed with a manual price.
|
||||
10. **Title and short description.** These are built for you from the primary wood category's
|
||||
pattern and the measurements. You can edit either before publish (`REVIEW-10`, `METADATA-80`).
|
||||
11. **Description.** With Generate text OFF the server produces it (`METADATA-70`). When inference
|
||||
is on you can trigger Generate text to have the model write it (`METADATA-55`).
|
||||
|
||||
Every empty required field shows an inline nudge where it sits. The slab cannot reach `ready`
|
||||
until the full set is filled.
|
||||
|
||||
### 4.3 Publish
|
||||
|
||||
1. Tap **Publish**.
|
||||
2. The server checks the SKU against the store. If `SLAB-UAT-001` is new, it creates the
|
||||
product.
|
||||
3. **What you should see:** a success message on the screen. The local draft and its photos are
|
||||
gone from the app. The slab status in the app is `published`.
|
||||
4. **What that does NOT mean:** the product is not live in the store. It is a WooCommerce draft.
|
||||
The app's "published" means the store create succeeded and the local row was cleaned up.
|
||||
|
||||
### 4.4 Confirm in the store
|
||||
|
||||
1. Open the store's WooCommerce admin.
|
||||
2. Find the product by SKU `SLAB-UAT-001`.
|
||||
3. **It must be a draft.** Status is draft, not published.
|
||||
4. **Check the fields:** SKU, price, category (species), tags (fig-*, feat-*), attributes
|
||||
(Edge Type, Figure, Grade, Thickness, Moisture), and images (the processed transparent PNGs).
|
||||
5. **Open the live storefront.** The product must not appear. A draft is invisible to customers.
|
||||
|
||||
### 4.5 Repeat on a PC browser
|
||||
|
||||
Repeat 4.1 through 4.4 on a desktop browser (Chrome, Edge, or Firefox). Use a different
|
||||
practice SKU, for example `SLAB-UAT-002`. The flow must work the same way. This is the signed
|
||||
PC-browser compatibility check.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pass and fail checks
|
||||
|
||||
You can see all of these without reading code.
|
||||
|
||||
| Check | Where | Pass looks like |
|
||||
|---|---|---|
|
||||
| Health endpoint | Phone browser, `https://${APP_HOSTNAME}/api/health` | ok response |
|
||||
| `test-woo` ok | App Settings | ok; species list is populated |
|
||||
| `woo_create_status` is draft | App Settings | shows `draft` (or `publish`, but `SLAB-UAT-*` forces draft anyway) |
|
||||
| Slab reaches `calibrated` | App, after capture | status is `calibrated` |
|
||||
| Slab reaches `ready` | App, after review | no empty required fields; inline nudges gone |
|
||||
| Publish succeeds | App, after Publish | success message; local draft is gone |
|
||||
| Woo product exists as draft | Store admin, products | SKU `SLAB-UAT-*`, status draft |
|
||||
| SKU prefix correct | Store admin | starts with `SLAB-UAT-` |
|
||||
| Store not live | Live storefront URL | product does not appear |
|
||||
| Board feet identity | App review screen, or a fixture | bdft = sqft × thickness in inches; no divide-by-12 |
|
||||
| Images are processed PNGs | Store admin, product images | transparent PNGs, not the original JPEGs |
|
||||
|
||||
### Duplicate SKU recovery
|
||||
|
||||
If you publish the same SKU twice, the second time the app stops before creating a second product.
|
||||
Under the SKU field you see: "This SKU already exists in the store (any status)." Two paths:
|
||||
edit the SKU to a new value, or open the existing listing. The draft stays in the app. Nothing is
|
||||
lost. This is the expected behavior, not a bug. See `docs/contracts/CONTENT-WOO.md` §2.5.
|
||||
|
||||
### What a fail looks like
|
||||
|
||||
- **No success message after Publish.** The app shows a plain message that the listing could not
|
||||
be created. No status number, no stack trace. The server kept a log. You can retry from the
|
||||
same screen without re-photographing.
|
||||
- **A value is no longer valid.** If a category, attribute, or price was removed from the store
|
||||
between sync and publish, that field shows "This value is no longer valid. Pick from the
|
||||
current list." Re-pick and retry.
|
||||
- **Store unreachable.** A plain message says the store could not be reached. Check the network
|
||||
and retry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cleanup
|
||||
|
||||
After the walk, decide what to do with the Woo draft UAT product.
|
||||
|
||||
- **Leave it or delete it.** Both are fine. It is a draft. Customers cannot see it. If you delete
|
||||
it, the store admin is the place to do it. The app does not delete store products.
|
||||
- **Never customer-visible.** If the product is visible on the live storefront, it is not a
|
||||
draft. That is a bug. Stop and report it.
|
||||
|
||||
**Real live publish is a separate step.** It is not part of this walk. A real product uses a
|
||||
non-UAT SKU (no `SLAB-UAT-` prefix) and is published only after you review it. That is a
|
||||
different decision. That step is not run here. The name of that step is **final POC publish**, and it
|
||||
is gated on your review of one real listing.
|
||||
|
||||
---
|
||||
|
||||
## Where each rule lives
|
||||
|
||||
| Rule | Source |
|
||||
|---|---|
|
||||
| Practice SKU forces draft | `AGENTS.md` §5 |
|
||||
| `woo_create_status` default draft | `AGENTS.md` §5, `docs/contracts/CONTENT-WOO.md` |
|
||||
| App Password setup, not consumer keys | `AGENTS.md` §4, `deploy/` |
|
||||
| Host inventory before deploy | `deploy/INVENTORY.md` |
|
||||
| Capture → review → publish flow | `docs/contracts/UX.md` |
|
||||
| Board feet identity | `docs/REQUIREMENTS.md` (CAPTURE-90) |
|
||||
| Duplicate SKU recovery | `docs/contracts/CONTENT-WOO.md`, `docs/contracts/UX.md` |
|
||||
| PC browser compatibility | `docs/contracts/UX.md` |
|
||||
| M3 scope and exit | `docs/MILESTONES.md`, `docs/REQUIREMENTS.md` |
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Template: decision record. One file per decision.
|
||||
Record why a choice was made when the reason is not obvious from the code or requirements.
|
||||
-->
|
||||
# ADR-<n>: <title>
|
||||
|
||||
- Date: <yyyy-mm-dd>
|
||||
- Status: `<proposed | accepted | superseded>`
|
||||
|
||||
## Context
|
||||
|
||||
<the situation and forces>
|
||||
|
||||
## Decision
|
||||
|
||||
<what was decided>
|
||||
|
||||
## Consequences
|
||||
|
||||
<what this makes easier, harder, or impossible>
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
<!--
|
||||
Template: capability doc. Home: docs/capabilities/<NAME>.md
|
||||
Behavior and acceptance only. Cite requirement IDs; never define them.
|
||||
-->
|
||||
# <Capability>
|
||||
|
||||
Status: `<shipped | in progress | planned>`
|
||||
|
||||
Cites: `<IDs from docs/REQUIREMENTS.md>`
|
||||
|
||||
## Behavior
|
||||
|
||||
<what the capability does, in plain terms>
|
||||
|
||||
## Acceptance
|
||||
|
||||
<what must be observed for this capability to be considered done, and where the evidence lives>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user