Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67775d6c8c | |||
| 6c62b506d4 | |||
| b4a702318d | |||
| c62f0329f6 | |||
| f966fbe690 | |||
| 68d236961a | |||
| 5019b10584 | |||
| 82219fe5ad | |||
| e5514fe3bf | |||
| d5d8c49e04 | |||
| 34dbfa5d7d | |||
| f7040f5947 | |||
| 08c463b8d8 | |||
| 55d66b9644 | |||
| 3c2f8871a8 |
@@ -1,76 +0,0 @@
|
||||
# Coverage gaps — serial worklist
|
||||
|
||||
- Date: 2026-09-18
|
||||
- Branch: `docs/clean-main`
|
||||
- Status: open worklist. Companion to `.audit/coverage/` and `tools/check_coverage.py`.
|
||||
- Rule: work one entry at a time, top to bottom. Each decision is a ruling; record it, then
|
||||
update the matching `gap` rows in the ledger to their resolution and re-run the gate.
|
||||
|
||||
The pilot slice (settings / auth / health) produced 12 `gap` atoms that collapse into the
|
||||
seven distinct holes below. Each is a requirement the source material carries and
|
||||
`docs/REQUIREMENTS.md` does not.
|
||||
|
||||
## GAP-1 — test-woo failure semantics
|
||||
|
||||
- Atoms: `tag:TEST-SPEC API-10`, `tag:OPENAPI test-woo-shape`, `prd decision-17`,
|
||||
`tag:TEST-SPEC GC-3` (partial).
|
||||
- Hole: the PoR says `test-woo` verifies the connection and resyncs (`SETTINGS-40`), but
|
||||
nothing defines its result shape (`ok`, `detail`), that it fails closed on missing
|
||||
credentials or non-HTTPS, or how an auth rejection differs from an unreachable store.
|
||||
- Proposed home: `SETTINGS`.
|
||||
- Status: open.
|
||||
|
||||
## GAP-2 — health endpoint semantics
|
||||
|
||||
- Atoms: `tag:TEST-SPEC H-1`, `tag:TEST-SPEC INS-1`, `tag:OPENAPI health`, `issue:#6`.
|
||||
- Hole: `APP-30` names `/api/health`; nothing defines the body (`status`, `version`,
|
||||
`woo_reachable`) or that health goes non-200 on DB/volume failure.
|
||||
- Proposed home: `APP`.
|
||||
- Status: open.
|
||||
|
||||
## GAP-3 — PUT /settings rejects a WOO_BASE_URL write
|
||||
|
||||
- Atoms: `tag:TEST-SPEC GC-4`, `tag:OPENAPI settings-PUT-reject`.
|
||||
- Hole: `SETTINGS-5` makes the URL env-only and read-only, but nothing requires the settings
|
||||
API to refuse an attempt to write it.
|
||||
- Proposed home: `SETTINGS`.
|
||||
- Status: open.
|
||||
|
||||
## GAP-4 — AES credential round-trip
|
||||
|
||||
- Atom: `tag:TEST-SPEC AES-1` (round-trip half).
|
||||
- Hole: `SETTINGS-75` covers redaction; nothing asserts that an encrypted credential
|
||||
decrypts back to its value.
|
||||
- Proposed home: `QUALITY` (test obligation) over `SETTINGS-20/30`.
|
||||
- Status: open.
|
||||
|
||||
## GAP-5 — upstream HTTP errors mapped to the envelope
|
||||
|
||||
- Atom: `tag:TEST-SPEC FUND-21`.
|
||||
- Hole: publish field errors are mapped; there is no general rule that a 401/400/404 from
|
||||
Woo is turned into the uniform envelope rather than passed through.
|
||||
- Proposed home: `APP` (alongside `APP-35`).
|
||||
- Status: open.
|
||||
|
||||
## GAP-6 — no app-level auth
|
||||
|
||||
- Atom: `prd no-app-auth`.
|
||||
- Hole: no row states that the app has no login of its own and relies on network plus HTTPS.
|
||||
- Proposed home: `OPS`.
|
||||
- Status: open.
|
||||
|
||||
## GAP-7 — httpx timeout values
|
||||
|
||||
- Atom: `cur:CONTENT-WOO httpx-timeouts`.
|
||||
- Hole: connect 10s / read 60s exist only in the contract draft.
|
||||
- Proposed home: contract detail, not a requirement: record in `docs/contracts/` in step 3
|
||||
and mark the ledger atom `dropped` with rationale.
|
||||
- Status: open.
|
||||
|
||||
## GAP-8 — prompt storage
|
||||
|
||||
- Atom: `tag:DATA-MODEL prompt-files` (PROMPTS source still to walk).
|
||||
- Hole: the source docs put prompts in `backend/app/prompts/`, bind-mounted read-only, read fresh
|
||||
per call. Ruling 2026-09-18: prompts live in the DB with all revisions. No PoR row yet.
|
||||
- Proposed home: `METADATA` (prompt management) or `SETTINGS`; resolve during the PROMPTS walk.
|
||||
- Status: resolved 2026-09-18 — `METADATA-105` (prompts stored in the DB with all revisions).
|
||||
@@ -1,183 +0,0 @@
|
||||
# Migration TODOs — source docs
|
||||
|
||||
- Date: 2026-09-18
|
||||
- Branch: `docs/clean-main`
|
||||
- Purpose: edits deferred until Phase 2 step 3, when the capability and contract docs are
|
||||
migrated into `docs/capabilities/` and `docs/contracts/`, made branch-neutral, and
|
||||
converted to cite requirement IDs.
|
||||
- Rule: these docs are not edited now. `REQUIREMENTS.md` is the only normative text and is
|
||||
already correct for everything below.
|
||||
|
||||
## CAPTURE.md
|
||||
|
||||
1. Line 16, "Length of the slab is aligned with the long side of the picture" — ruled dead.
|
||||
Remove.
|
||||
2. Lines 128–129, the `kind=inventory` role label — discarded. The "first inventory photo is
|
||||
the measurement reference" half is `CAPTURE-115`; keep it as a citation, drop the label.
|
||||
3. Line 140, editor/storefront alpha presentation — discarded.
|
||||
4. The sensitivity mapping (line 69) and display/source-pixels rule (lines 58–62) are now
|
||||
`CAPTURE-41` and `CAPTURE-141`; cite them instead of restating.
|
||||
5. Line 82 busy mark ("more than a fraction of a second"; as-built is 300 ms) — ruled a
|
||||
nit, not a requirement. Leave as design prose or drop.
|
||||
|
||||
## CREATE.md
|
||||
|
||||
1. Grade cardinality: change "1 or more" to 1 in all three places (attributes table,
|
||||
ready set, data shape). Ruling: Grade is exactly one.
|
||||
2. `fig-*` cardinality: change "1 or more / required" to optional in the tags table and the
|
||||
ready set. Ruling: tags optional; the Figure attribute is at least one.
|
||||
3. Ready-set images: change "at least one inventory PNG" to two (clean calibration plus its
|
||||
dimension-overlay copy). Ruling: `REVIEW-5` is the PoR.
|
||||
4. Remove "cap at 5 images" from the create sequence.
|
||||
5. Remove "user typed widths/sqft/bdft wins"; the calculated fields are read-only.
|
||||
6. Fix the Call 2 line so the model writes the title/description and the deterministic
|
||||
template is the fallback, not the assembler.
|
||||
|
||||
## CONTENT-WOO.md
|
||||
|
||||
1. Em dash in the title template and example → hyphen.
|
||||
2. "Templates (always on)" and "templates inject those numbers after the prose comes back" →
|
||||
template is the fallback only; the model writes when Call 2 runs.
|
||||
3. `fig-*` "1+ required" and the ready set including `fig-*` tags → tags optional.
|
||||
4. "≤5 inventory photos" → no cap; drop the "only `kind=inventory`" clause.
|
||||
5. The empty `attributes` element shape is the known deferred decision; keep the placeholder.
|
||||
|
||||
## PHOTOS.md
|
||||
|
||||
No changes. Overlay legibility and arrow/text drawing detail were ruled design, not
|
||||
requirements.
|
||||
|
||||
## TEST-SPEC.md (→ `docs/contracts/TEST-SPEC.md`)
|
||||
|
||||
1. Migrate and cite requirement IDs; drop the pre-ID source-doc references.
|
||||
2. Purge superseded cases: UI-1, UIE-9, UIE-10 (four knobs); V-3, API-8 (validation module);
|
||||
C2-2 (`previous_response_id` / thread); C1-1 (image source); C2-4 (prompt files); TV-6, TV-7,
|
||||
E2E-5 (80 percent / 1600 px); UIE-1, E2E-4 (computed override).
|
||||
3. Add cases for the two new rows: `APP-60` (keyboard, focus ring, 320 px reflow) and `APP-65`
|
||||
(no secret material in logs).
|
||||
|
||||
## IMPL-PLAN.md (retired)
|
||||
|
||||
1. Retire `docs/IMPL-PLAN.md`. Phases and Gates A/B/C are superseded by `MILESTONES.md` plus the
|
||||
PoR; acceptance and test obligations are QUALITY rows.
|
||||
2. Superseded: four knobs; "1–5 photos"; 80 percent / 1600 px; calculated-field override; portable
|
||||
context and the stateful probe; prompt files.
|
||||
|
||||
## ARCHITECTURE.md
|
||||
|
||||
1. §1, §2, §4 "four knobs" including Edge offset and Feather — controls are exactly Sheet and
|
||||
Sensitivity (`CAPTURE-40`).
|
||||
2. §2 "1–5 photos" — one or more (`CAPTURE-25`).
|
||||
3. §6.3 ready set requires 1+ `fig-*` tag and 1+ grade — tags are optional and grade is exactly
|
||||
one (`METADATA-30`, `METADATA-15`).
|
||||
4. §8/§9 shared validation module, `validation_hash`, 412, stateful/stateless mode, and
|
||||
per-slab thread — ruled cut 2026-09-18; thread work is out of the MVP and test-inference no
|
||||
longer returns mode.
|
||||
5. Retire `docs/ARCHITECTURE.md`: its testable rules are APP/CAPTURE/SETTINGS rows, and the
|
||||
topology is covered by those plus `deploy/`.
|
||||
6. §3.4 Call 1 image input "all originals downscaled to 1024" → clean processed photos
|
||||
(`METADATA-35`).
|
||||
|
||||
## UX.md (→ `docs/contracts/UX.md`) and UX-DESIGN.md (retired)
|
||||
|
||||
1. Migrate `docs/UX.md` to `docs/contracts/UX.md`: branch-neutral, cite requirement IDs, do not
|
||||
restate rules.
|
||||
2. Retire `docs/UX-DESIGN.md` (non-normative). Fold the "AI" badge, first-run card, separate
|
||||
Title/Description screen, and error-state copy into the UX contract.
|
||||
3. Superseded in UX.md: four knobs → two; "1 to 5 photos" → no cap; "no calibration photos" → the
|
||||
calibration photo exists; 80 percent / 1600 px → 90 percent / 800 px; calculated-field override
|
||||
path → read-only; grade 1+ → exactly one; `fig-*` 1+ → optional; "templates always run" →
|
||||
template fallback; "title/short/description not required to reach ready" → required
|
||||
(`REVIEW-10`); `short_title` → `short_description`.
|
||||
|
||||
## TECH-SPEC-PIPELINE.md (retired)
|
||||
|
||||
1. Retire `docs/TECH-SPEC-PIPELINE.md`. Its deterministic rules are CAPTURE; its ten vectors are
|
||||
QUALITY. Fold any wanted algorithm detail into the CAPTURE capability doc.
|
||||
2. Superseded: 80 percent fill and the 1600 px floor → 90 percent and 800 px; four knobs → two;
|
||||
the auto-dimension overlay is no longer v1.5 (it shipped).
|
||||
|
||||
## PROMPTS.md (→ `docs/contracts/PROMPTS.md`)
|
||||
|
||||
1. Prompts move from server text files to the database with all revisions (`METADATA-105`): drop
|
||||
the read-only mount, host-editing, and reset-to-default script.
|
||||
2. Remove `previous_response_id`, the portable-context machinery, and the shared-validation rules
|
||||
from the Call 2 inputs.
|
||||
3. "1–5 inventory photos" → no cap (`CAPTURE-25`).
|
||||
4. Example title uses an em dash and a multiplication sign → hyphen separator; snap `short_title`
|
||||
to `short_description`.
|
||||
|
||||
## DEPLOYMENT.md (retired)
|
||||
|
||||
1. Retire `docs/DEPLOYMENT.md`. Its testable rules are OPS/SETTINGS; its operational facts live in
|
||||
`deploy/` (`INVENTORY.md`, `docker-compose.yml`, `Caddyfile.fragment`, `RELEASE-PROCESS.md`).
|
||||
2. Fold any still-useful prose into `deploy/` (the deploy README is the natural home).
|
||||
3. Repoint the UAT-PRACTICE references from `docs/DEPLOYMENT.md` §1/§3/§5/§8 to `deploy/`.
|
||||
4. Superseded: `output_px_min 1600` → 800 px floor (`CAPTURE-105`); "prompt files live in git and
|
||||
need no backup" → prompts live in the DB with all revisions (GAP-8).
|
||||
|
||||
## DATA-MODEL.md (→ `docs/contracts/DATA-MODEL.md`)
|
||||
|
||||
1. Grade "1+" → exactly one; `fig_tag_ids` "1+" → optional.
|
||||
2. `output_px_min 1600` and `fill_target 0.80` → 800 px floor and 90 percent fill.
|
||||
3. "Only inventory photos. Calibration photos do not exist." → the calibration photo exists; other
|
||||
batch photos are visual inventory only (`CAPTURE-115`).
|
||||
4. "No hard deletes for slabs (audit)" → the local slab row is deleted on successful create
|
||||
(`PUBLISH-25`).
|
||||
5. Remove `inference_thread_id`; thread work is out of the MVP.
|
||||
6. Prompt files: replace the bind-mounted text files with prompts stored in the DB, all revisions
|
||||
(PoR row pending the PROMPTS walk).
|
||||
7. `short_title` → `short_description` (Woo canonical).
|
||||
|
||||
## OPENAPI.md (→ `docs/contracts/API.md`)
|
||||
|
||||
1. Field name `short_title` → `short_description` (Woo canonical). Applies to the schema, read
|
||||
model, Call 2 result, and examples.
|
||||
2. `fill_target 0.80` and `output_px_min 1600` → 90 percent fill and an 800 px floor
|
||||
(`CAPTURE-100`, `CAPTURE-105`).
|
||||
3. Remove the validation module endpoint, `validation_hash`, and the 412 path (ruled cut).
|
||||
4. Remove `previous_response_id`, the per-slab thread, and `mode` from test-inference; Call 2 is
|
||||
a fresh full-context call.
|
||||
5. Grade cardinality "1+" → exactly one; `fig-*` "1+" → optional.
|
||||
6. The taxonomy-sync example key `synced_at` should be `taxonomy_last_sync_at`.
|
||||
7. Call 1 image input for infer-taxon: original photos → clean processed photos (`METADATA-35`).
|
||||
|
||||
## PRODUCT.md
|
||||
|
||||
1. Line 19, "One or more `fig-*` tags" — tags are optional per `METADATA-30`. Change to
|
||||
optional.
|
||||
2. Line 23, "Measured values feed title and description templates" — template is the fallback;
|
||||
the model writes when Call 2 runs. Cite `METADATA-60`, `METADATA-70`, `METADATA-80`. Same
|
||||
conflict in the decision-rights table, line 37 ("Templates from listing facts").
|
||||
3. Line 20, "short title" — stale wording; the field is `short_description` (`REVIEW-10`).
|
||||
4. Branch-neutral: line 1 "Milestone status on `m3-ds`", and line 66 "behavior ... taken from
|
||||
`main`". Remove branch names.
|
||||
5. Line 25, umbrella term "Woo attributes" → "Woo Slab Taxonomy" (see cross-cutting rename).
|
||||
|
||||
## README.md
|
||||
|
||||
1. Branch-neutral: line 1 title "Docs map (branch `m3-ds`)" and line 44 "copied into
|
||||
`docs/CREATE.md` from `main`". Remove branch names.
|
||||
2. Line 39, umbrella "Woo attributes" → "Woo Slab Taxonomy" (see cross-cutting rename).
|
||||
|
||||
## UAT-PRACTICE.md
|
||||
|
||||
1. Line 100, "four knobs: Sheet, Sensitivity, Edge offset, Feather" — controls are exactly Sheet
|
||||
and Sensitivity; Edge offset and Feather are removed (`CAPTURE-40`).
|
||||
2. Line 97, "1 to 5 photos" — no cap; `CAPTURE-25` is one or more.
|
||||
3. Line 122, "at least one figure, at least one grade" — grade is exactly one (`METADATA-15`).
|
||||
4. Line 126, "`fig-*` tags. Pick at least one." — tags are optional (`METADATA-30`).
|
||||
5. Branch-neutral: line 14 "On branch `m3-ds`". Stale refs to `docs/DEPLOYMENT.md`, `docs/UX.md`,
|
||||
`docs/TECH-SPEC-PIPELINE.md`, `docs/IMPL-PLAN.md` in the "where each rule lives" table.
|
||||
|
||||
## Cross-cutting: "Woo Slab Taxonomy" rename
|
||||
|
||||
Ruling 2026-09-18: the umbrella package term is **Woo Slab Taxonomy**; canonical Woo
|
||||
attributes (the five) keep the name.
|
||||
|
||||
- Rename umbrella occurrences: `AGENTS.md:48`, `PRODUCT.md:25`, `CREATE.md:7`, `CREATE.md:34`,
|
||||
`CREATE.md:50` (heading), `CREATE.md:261`, `CREATE.md:397`, `README.md:39`.
|
||||
- Keep "Woo attributes" where it means the canonical five: `CREATE.md:61`,
|
||||
`CONTENT-WOO.md:121`.
|
||||
- Give the term one glossary home in the step-3 contract docs; make PRODUCT/AGENTS reference
|
||||
it instead of redefining.
|
||||
@@ -1,211 +0,0 @@
|
||||
# Requirements reconciliation — decision record
|
||||
|
||||
- Date: 2026-09-18
|
||||
- Branch: `docs/clean-main`
|
||||
- Participants: Ty + opencode
|
||||
- Status: working record. Gets distilled into `docs/REQUIREMENTS.md`, then retired.
|
||||
|
||||
This captures the decisions made while consolidating the scattered requirements, so
|
||||
context is not lost between sessions. Loose ends are collected in section 16.
|
||||
|
||||
## 0. Resume here (for a new session)
|
||||
|
||||
**SUPERSEDED 2026-09-18. Read `.audit/2026-09-18-session-handoff.md` instead.**
|
||||
|
||||
This section is kept only so the history below still reads correctly. The current state,
|
||||
locked rulings, gate output, and next steps all live in the handoff. Everything below this
|
||||
point is the decision log from the original reconciliation, not the current resume point.
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
Requirements are scattered across layers of docs (old `main` specs, current branch docs,
|
||||
`archive/`), with duplicates and contradictions. Goal: one clean `main` holding all shipped
|
||||
code plus one reconciled requirement set, with M3 scoped as a subset of it. Coding is paused
|
||||
until that exists.
|
||||
|
||||
## 2. Git state established
|
||||
|
||||
1. `pre-simplification` tag created at `13bfa792bb3ed968b333675c85bcec0c451e2635` (old main tip; retains the deleted specs: ARCHITECTURE, IMPL-PLAN, OPENAPI, DATA-MODEL, TEST-SPEC, UX, UX-DESIGN, TECH-SPEC-PIPELINE, DEPLOYMENT, PROMPTS, Build-release-standard).
|
||||
2. `main` fast-forwarded to `d754cac` (the M1>M2>M3 code line). `main` now holds all shipped code plus current docs.
|
||||
3. Local `m3-ds` created, tracking `origin/m3-ds`.
|
||||
4. Phase 1 scaffold committed as `b42e3cb` on branch `docs/clean-main`.
|
||||
5. Nothing pushed. `origin/main` still at the old tip until we decide to push.
|
||||
|
||||
## 3. Process rules adopted
|
||||
|
||||
1. Coding is paused until the requirement set is reconciled.
|
||||
2. Requirements live only in `docs/REQUIREMENTS.md`, one row per requirement, with stable IDs.
|
||||
3. Milestones are expressed as explicit subsets of requirement IDs (`docs/MILESTONES.md`).
|
||||
4. Conflict resolution rule: current/later docs plus the code that actually runs beat the tag and `archive/`. "Typically later docs prevail."
|
||||
5. The structure is enforced by `tools/check_requirements.py` (registered docs, unique IDs, traceability).
|
||||
6. Verification discipline: no "done" without raw evidence; CI is the only mechanical gate.
|
||||
|
||||
## 4. Namespaces (owner jobs, not tech layers)
|
||||
|
||||
1. `CAPTURE` — ecommerce-ready pics: intake, pick calibration photo, BG removal, crop, axis, length/width, calculations, inventory PNG, dimension overlay, lineup.
|
||||
2. `METADATA` — building the listing: attributes and tags, title, short/deep description, the two inference calls.
|
||||
3. `REVIEW` — confirm and correct: confidence display, field gating, tweaks, per-slab price suggestion and override, ready gate.
|
||||
4. `PUBLISH` — WooCommerce create, draft/live, duplicate SKU, cleanup.
|
||||
5. `SETTINGS` — configured before the session: Woo connection, inference config, species `$/bdft` rates, brand/geo.
|
||||
6. `APP` — foundation: device draft, slab status, resume, server seams, secret handling.
|
||||
7. `OPS` — foundation: deploy, hosts, release, UAT.
|
||||
8. `QUALITY` — foundation: test obligations, vectors, anti-metrics.
|
||||
|
||||
## 5. Capture decisions
|
||||
|
||||
1. Mask controls are exactly two: `Sheet` (auto/green/black) and `Sensitivity` (0–100). Edge offset and Feather are dropped. The Sheet toggle itself is a candidate for removal (auto-detect stays).
|
||||
2. Sensitivity changes are debounced 2000 ms; a Sheet change runs an immediate pass.
|
||||
3. The owner uploads all photos of the slab, then selects one as the calibration photo. That photo drives every downstream step, is duplicated with dimensions added, and becomes the first showcase image.
|
||||
4. Crop is 90% fill, shorter side at least 800 px when the source allows (shipped values beat the old 80%/1600).
|
||||
5. Length and thickness are the only user-set numbers. Widths, sqft, and bdft are computed from the approved mask and are immutable in production (no typed override).
|
||||
6. Widths are sampled every 6 inches along the approved axis, dropping the first and last 6 inches.
|
||||
7. Thickness band uses the round-up model: measured `1"` lands in the `1–1.5"` store band.
|
||||
8. Woo image order is locked: clean calibration PNG, then dimension-overlay copy, then remaining batch photos in the order added. Minimum two images per listing; no upper cap.
|
||||
|
||||
## 6. Ready set (submission-ready)
|
||||
|
||||
Required:
|
||||
|
||||
1. At least two images — clean first pic plus its dimension-overlay copy.
|
||||
2. `sku` matching `[A-Z0-9-]{3,24}`.
|
||||
3. `length_in` and `thickness_in`.
|
||||
4. Widths / sqft / bdft (computed, immutable).
|
||||
5. `categories` — exactly one species leaf.
|
||||
6. `categories` — at least one wood-category leaf.
|
||||
7. `attributes[Edge Type]` — one term.
|
||||
8. `attributes[Figure]` — at least one term.
|
||||
9. `attributes[Grade]` — exactly one term.
|
||||
10. `attributes[Thickness]` — one term (round-up band).
|
||||
11. `attributes[Moisture]` — one term, default kiln-dried.
|
||||
12. `regular_price`.
|
||||
13. `name`, `short_description`, `description`.
|
||||
|
||||
Optional: all Woo product tags (figure family and features family).
|
||||
|
||||
Woo schema field mapping lives in section 6 of this record and will become the `METADATA` requirements.
|
||||
|
||||
Note: title / short description / description are required on the product. Because deterministic templates are the inference-off fallback, they are guaranteed non-empty at create.
|
||||
|
||||
## 7. Taxonomy and categories
|
||||
|
||||
1. Categories sync from Woo; the product is assigned to leaf categories. Parents are never assigned but matter in context: the site shows them (breadcrumbs/filters) and Call 2 receives the parent path as context.
|
||||
2. Species is exactly one synced leaf. Wood category is one or more synced leaves.
|
||||
3. In review, the owner designates a primary wood category and any secondary ones. Default primary is the first category selected. How the owner corrects it, and how they stay aware of it, is a UX problem deferred to design.
|
||||
4. Primary drives the title. Primary plus secondary drives the description prose.
|
||||
5. The primary leaf selects a specialized Call 2 prompt. This is the "common pattern."
|
||||
6. Wood-category leaves: Knife blanks, Specialty cuts, Dimensional wood, Live edge wood, Bowl blanks, logs and caps, Pen blanks, Pepper mill blanks. All are settled prose/prompt work items except `Specialty cuts`, which is parked.
|
||||
7. All Woo product tags are optional. Tags come from the synced Woo set; no hardcoded names or prefixes. Wrinkle: one figure tag is bare `fig` (Figured), so a `fig-*` glob would drop it — the filter must be data-driven.
|
||||
8. Current tags. Features: `feat-mincr` (Crack/Check minor), `feat-resin` (Resin Ready). Figure: `fig` (Figured), `fig-brdseye`, `fig-chatoy`, `fig-crotch`, `fig-curl`, `fig-fback`, `fig-flame`, `fig-hfig`, `fig-marbled`, `fig-quilted`, `fig-splt`.
|
||||
|
||||
## 8. Locked attributes
|
||||
|
||||
1. `attributes[Edge Type]` — 1 term.
|
||||
2. `attributes[Figure]` — at least 1 term; includes `plain` for basic slabs; never left empty.
|
||||
3. `attributes[Grade]` — exactly 1 term.
|
||||
4. `attributes[Thickness]` — 1 term, round-up band.
|
||||
5. `attributes[Moisture]` — 1 term, default kiln-dried, never inferred.
|
||||
6. Missing attributes or terms are skipped, never created. v1 does not invent taxonomy.
|
||||
|
||||
## 9. Text generation (Call 2)
|
||||
|
||||
1. Call 2 produces `name`, `short_description`, and `description`.
|
||||
2. The template is a consistency/style guide in the prompt; the model has creative license. It is not assembled deterministically in code.
|
||||
3. With inference off, the deterministic template is the fallback that fills those three fields.
|
||||
4. Call 2 receives all metadata confirmed or changed in review — not the raw Call 1 suggestions.
|
||||
5. Dimensions are supplied as a literal fact block (species, figure term, length, thickness, average width, width min–max, board feet). The model uses those exact strings and invents no numbers.
|
||||
6. There is no `L × W × T` rectangle assumption; slabs are irregular and width varies.
|
||||
7. The dimension-overlay image is not sent to the model.
|
||||
8. A post-check for number drift is parked as `later`; if it is ever added it is plain deterministic comparison, not an AI call.
|
||||
9. The title separator is a hyphen: ` - `.
|
||||
|
||||
## 10. Inference transport
|
||||
|
||||
1. Calls are asynchronous: the request returns `202`, the client polls and shows phase feedback.
|
||||
2. Phases: uploading, analyzing, still working. Polling is the heartbeat that distinguishes dead connectivity from a working model.
|
||||
3. Tunable defaults: ack 15 s, upload-stall 20 s, overall inference deadline 120 s after ack, poll every 2 s backing off to 10 s.
|
||||
4. On timeout or failure, fail gracefully with a plain message and a Retry action. Retry is offered only if `test-inference` previously passed. A retry replaces any in-flight job (idempotent).
|
||||
5. Inference input is the clean processed image (masked, cropped), composited on soft white, no alpha, downscaled to at most 1024 px long edge, JPEG quality 80.
|
||||
6. Only the pixel cap is exposed in Settings. JPEG quality 80 lives in a server config file.
|
||||
7. Call 1 and Call 2 both receive the images. Call 2 additionally receives the literal fact block.
|
||||
8. Provider threads are dropped; always full resend to the provider.
|
||||
9. Inference never blocks create and never auto-publishes.
|
||||
|
||||
## 11. Prompt management
|
||||
|
||||
1. Prompts are stored in SQLite as immutable revisions, seeded from bundled repo defaults on first run.
|
||||
2. Saving appends a revision; an active pointer selects which revision is live.
|
||||
3. Reset to default re-activates the bundled default; reset to a revision re-activates that revision.
|
||||
4. Each inference call records the revision id/hash it used.
|
||||
5. A runtime debug flag gates both the Advanced settings UI and the prompt-write endpoints. Single build, not two.
|
||||
6. This is post-M3 (`later`).
|
||||
|
||||
## 12. Pricing
|
||||
|
||||
1. A flat `$/bdft` rate per species is configured in Settings, matched to a synced species leaf. No tiers in v1.
|
||||
2. The per-slab suggestion is `bdft × rate`; the owner may override and the override sticks. No rule means an empty price the owner types.
|
||||
3. The price field and its suggestion live in the review screen.
|
||||
|
||||
## 13. Publish
|
||||
|
||||
1. Create sequence is the documented one: validate the ready set, look up the SKU (stop if it exists), refresh taxonomy if older than one hour, resolve categories/tags/attributes, upload inventory PNGs, `POST /products`, then finalize.
|
||||
2. On success the local slab row is deleted. WooCommerce is the only source of truth; `sync_log` is retained; photos are purged; the device draft is cleared.
|
||||
3. `SLAB-UAT-*` always creates a Woo draft.
|
||||
4. A duplicate SKU blocks create; there is no update path.
|
||||
5. A stale value on submit is a field-level error telling the owner to pick from the current list.
|
||||
|
||||
## 14. Settings
|
||||
|
||||
1. Compose env: `WOO_BASE_URL` (https only), `SLAB_AES_KEY`.
|
||||
2. Encrypted in SQLite: `woo_wp_username`, `woo_app_password`, `inference_base_url`, `inference_api_key`.
|
||||
3. Plain settings: `inference_model`, `inference_enabled`, `content_llm_enabled`, `woo_create_status` (default draft), `brand_voice`, `geo_context`.
|
||||
4. Derived: `taxonomy_last_sync_at`, `taxonomy_anchor`.
|
||||
5. Auth is a WordPress application password for a dedicated low-privilege user; not consumer keys, not the login password.
|
||||
|
||||
## 15. Scope mapping
|
||||
|
||||
Shipped: M1 Capture, M2 Photos, capture knob prefs.
|
||||
|
||||
M3: store-connection and AI-connection settings; taxonomy sync; the five attributes; Call 1; Call 2; title/description generation; review screen with field gating; pricing; publish; local draft cleanup.
|
||||
|
||||
Later: prompt-management UI, U2Net, offline capture, OCR/ruler, batch create, number-drift check, Specialty cuts prose, primary-category correction UX.
|
||||
|
||||
## 16. Open / parked items
|
||||
|
||||
1. `attributes` element shape on `POST /products` is open — needs a live Whidbey product sample or the official Woo REST fields. Do not invent.
|
||||
2. Server persistence approach is open — full OpenAPI slab CRUD vs a thinner FastAPI path.
|
||||
3. `Specialty cuts` leaf prose is parked.
|
||||
4. Primary-category correction and awareness is a UX problem, deferred.
|
||||
5. Number-drift check is `later`.
|
||||
6. Prompt management is post-M3.
|
||||
7. The QUALITY/OPS extraction re-run is still pending (the first pass was truncated).
|
||||
8. "Later docs prevail" is the rule, but the tag/archive still hold material not yet reconciled (e.g. U2Net, browser floor, TES vectors, deploy details).
|
||||
|
||||
## 17. Work items
|
||||
|
||||
1. ~~Re-run the QUALITY/OPS extraction.~~ Done 2026-09-18 (TEST-SPEC ~110 rows; plan/deploy/release ~120 rows).
|
||||
2. Write `docs/REQUIREMENTS.md` namespace by namespace, starting with `CAPTURE`.
|
||||
3. Draft the per-leaf title/prose work once the `METADATA` section is being written.
|
||||
4. Migrate capability/contract docs into their homes, make docs branch-neutral, rebuild the structure check, wire CI.
|
||||
|
||||
## 18. Rulings deferred to a final cleanup pass
|
||||
|
||||
These surfaced during the QUALITY/OPS extraction. They do not block writing `REQUIREMENTS.md`; park them and reconcile in the cleanup pass.
|
||||
|
||||
1. Test directory layout: old TEST-SPEC mandates Vitest and `frontend/src/__tests__/`; shipped code uses the Node test runner (`node --import tsx --test`) and `frontend/tests/`. Contradiction.
|
||||
2. Test coverage target (~95% for deterministic modules) — adopt or drop.
|
||||
3. "The Test Bar" role and who authors reports versus Ty's sign-off.
|
||||
4. `docs/checks/check-structure.sh` gate timing and criteria — now replaced by `tools/check_requirements.py`.
|
||||
5. Deploy ports: proposal `18080/18443` versus UAT `:10881`, and `APP_HOSTNAME` binding.
|
||||
6. Backup retention "7 daily + 4 weekly" has no verification test.
|
||||
7. `SLAB_AES_KEY_OLD` rekey env placement (host vs compose).
|
||||
8. Smoke step 5: local `published` versus Woo `draft` mismatch — intended or not.
|
||||
9. Release process: `Build-release-standard` points at GitHub while the repo is Gitea.
|
||||
10. Taxonomy sync endpoint path divergence across docs.
|
||||
11. Ownership of `deploy/INVENTORY.md` across Gate B vs Phase 6.
|
||||
12. Scope labels: do shipped/M3/later map to phases or to release milestones.
|
||||
13. `C1`/`C2` (Call 1 / Call 2) namespace ownership ambiguity.
|
||||
14. Whether `UAT-PRACTICE.md` change control is a gate.
|
||||
15. Definition of "real fixtures" acceptance for TV-3, TV-6, TV-7, TV-10.
|
||||
16. `INF-1` ownership: SETTINGS toggle versus QUALITY suite obligation.
|
||||
17. Server-side API surface (`revision_conflict`, validation module, slab CRUD) belongs to the open server-persistence decision.
|
||||
18. `output_px_min` "never below 1600" from old DEPLOYMENT/IMPL-PLAN versus shipped 800 floor.
|
||||
@@ -1,102 +0,0 @@
|
||||
# Session handoff — requirements reconciliation
|
||||
|
||||
- Date: 2026-09-18
|
||||
- Branch: `docs/clean-main`
|
||||
- HEAD: `84d7681`. The last pushed tip was `1603467`.
|
||||
- Working tree: clean. This session's work is committed as `84d7681`; not pushed yet.
|
||||
- This file supersedes the stale "Resume here" section 0 of
|
||||
`2026-09-18-reconciliation-decisions.md`.
|
||||
|
||||
## Read first
|
||||
|
||||
1. `docs/STRUCTURE.md` — structure contract, ID namespaces, the step-5 ID stride rule.
|
||||
2. `docs/REQUIREMENTS.md` — the PoR. 136 requirements.
|
||||
3. `.audit/2026-09-18-migration-todos.md` — deferred source-doc edits for step 3.
|
||||
4. `.audit/2026-09-18-coverage-gaps.md` — the serial gap worklist (GAP-1..GAP-7).
|
||||
5. `.audit/coverage/README.md` — the coverage ledger schema.
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
python tools/check_requirements.py -> PASS, 12 docs, 8 namespaces, 136 requirements
|
||||
python tools/check_coverage.py -> PASS, 40 atoms, 12 gaps, 20/136 IDs claimed
|
||||
python tools/check_coverage.py --strict -> FAIL by design until coverage is complete
|
||||
```
|
||||
|
||||
## What this session did
|
||||
|
||||
1. Renumbered every requirement in steps of 5 per namespace (see STRUCTURE.md). Nothing
|
||||
cites IDs yet, so this was free. First IDs: `CAPTURE-5`, `METADATA-5`, etc.
|
||||
2. Applied owner corrections: `PUBLISH-8` (no image cap), `METADATA-16` (template is the
|
||||
fallback only), `REVIEW-8` (calculated fields are not mutable), `PUBLISH-30`
|
||||
(attempt-level failure wording), `PUBLISH-35` and `PUBLISH-36/37/38` (error triage).
|
||||
3. Walked `CAPTURE.md`, `PHOTOS.md`, `CREATE.md`, `CONTENT-WOO.md` section by section.
|
||||
Added five rows: `CAPTURE-41`, `CAPTURE-141`, `METADATA-66`, `PUBLISH-31`,
|
||||
`SETTINGS-51`.
|
||||
4. Built the coverage method: `tools/check_coverage.py`, `.audit/coverage/` (schema plus a
|
||||
settings/auth/health pilot ledger), and the gap worklist.
|
||||
|
||||
## Rulings locked this session (do not relitigate)
|
||||
|
||||
1. `METADATA-1`, `-2`, `-3` stay as written.
|
||||
2. Audit decisions beat older specs. Title separator is the hyphen ` - `.
|
||||
3. Grade is exactly 1. `fig-*` tags are optional. The Figure attribute is at least 1.
|
||||
Ready set is two images; `REVIEW-5` is the PoR.
|
||||
4. Tag scope is important: sync keeps only the figure and feature tag families so non-wood
|
||||
Woo metadata does not pollute the workflow (`SETTINGS-51`).
|
||||
5. Content-generation logging stays (`METADATA-66`), it is the trace/debug/tuning record.
|
||||
6. Partial-publish orphaned media: log it, surface a manual cleanup note, no auto-delete
|
||||
(`PUBLISH-31`).
|
||||
7. Order of the coverage walk: current docs, then tag docs, then the archived PRD, then the
|
||||
Gitea main design issues (#1–#20). TEST-SPEC is done LAST, reconciled against a finished
|
||||
PoR.
|
||||
|
||||
## Open work
|
||||
|
||||
1. Continue the coverage walk doc by doc, section by section. Remaining current docs:
|
||||
`PRODUCT`, `README`, `UAT-PRACTICE`. Then the tag docs, the archived PRD, the main
|
||||
issues, and finally TEST-SPEC.
|
||||
2. The pilot ledger `coverage/settings-auth-health.md` mixes sources. When a walked source
|
||||
collides with it, split the ledger into per-source files first (this write was started
|
||||
and reverted once; get explicit approval).
|
||||
3. Resolve the coverage gaps in one pass, then step 3 migration
|
||||
(`docs/capabilities/`, `docs/contracts/`, branch-neutral, cite IDs), then retire the
|
||||
working records.
|
||||
|
||||
## Known gaps
|
||||
|
||||
1. `.audit/2026-09-18-coverage-gaps.md` has GAP-1..GAP-7, all open. GAP-1 (`test-woo`
|
||||
failure semantics) and GAP-2 (health/`woo_reachable`) are the ones with the most
|
||||
downstream content.
|
||||
2. The coverage ledger currently reports 12 `gap` atoms from the pilot slice.
|
||||
3. Still-open decisions: the `attributes` element shape on `POST /products`, and server
|
||||
persistence (full CRUD vs a thinner path).
|
||||
4. Discussed but unresolved: Woo credentials are stored server-side, so two phones share one
|
||||
credential row and a wrong password locks both out. A "last-known-good plus auth-gated
|
||||
promotion" rule was proposed; no requirement row yet.
|
||||
|
||||
## Process rules
|
||||
|
||||
1. This is planning work. Walk in plan mode (read-only); flip to build only for edits the
|
||||
owner has explicitly approved.
|
||||
2. No file gets created, edited, or deleted without the owner saying go for that specific
|
||||
change.
|
||||
3. Where a doc and the shipped code disagree, the as-built code is what the PoR documents.
|
||||
4. Raw evidence for any "done": gate output, diff, or command output.
|
||||
|
||||
## Environment
|
||||
|
||||
1. Subagents `general` and `explore` are routed to the local SWIFT model
|
||||
(`deepblu5090-8084/Swift-Qwen3.8-27B-Q5_K_M.gguf`) in the global config
|
||||
`~/.config/opencode/opencode.jsonc`. Config loads at startup; restart after edits.
|
||||
2. SWIFT is suitable for mechanical extraction only (read and list). Dispositions and
|
||||
rulings stay with the owner. It never writes.
|
||||
3. `.audit/docs-structure-pass.tsv` is finished history from 2026-09-09. Leave it.
|
||||
|
||||
## Commit
|
||||
|
||||
- `84d7681` — coverage gate, migration TODOs, session handoff. Working tree clean.
|
||||
- Touches: `docs/REQUIREMENTS.md` (+5 rows), `docs/README.md`, `AGENTS.md`,
|
||||
`.audit/2026-09-18-reconciliation-decisions.md`, and the new `.audit` files plus
|
||||
`tools/check_coverage.py`.
|
||||
- Not pushed to origin as of this handoff.
|
||||
@@ -1,7 +0,0 @@
|
||||
ts phase decision why evidence result
|
||||
2026-09-09T19:43:56Z harness ran check-structure.sh on untouched main tip capture red baseline before structure edits docs/checks/check-structure.sh FAILED as expected (red baseline)
|
||||
2026-09-09T19:43:56Z frame done predicate is check-structure.sh exit 0 with lock phrases intact falsifiable gate for structure pass .audit/docs-structure-pass.tsv open
|
||||
2026-09-09T19:45:25Z unit AGENTS ownership complete; Gates table removed; UX-DESIGN demoted; IMPL-PLAN owns gate meanings subtract calendar restatement and close ownership gap AGENTS.md,docs/UX-DESIGN.md,docs/IMPL-PLAN.md check-structure PASSED
|
||||
2026-09-09T19:45:25Z unit TEST-SPEC and deploy docs label missing tree as Phase 0 targets stop agents inventing harness paths that are not on main docs/TEST-SPEC.md,docs/DEPLOYMENT.md,deploy/docker-compose.yml,README.md check-structure PASSED
|
||||
2026-09-09T19:45:25Z verify ran docs/checks/check-structure.sh after all edits prove done predicate on real artifact docs/checks/check-structure.sh exit 0 VERIFIED
|
||||
2026-09-09T19:47:17Z attention-fix restored Gate B meaning to original AGENTS wording; hardened check-structure for both deploy files and fuller math lock phrase Attention review flagged Gate B parenthetical as lock drift and weak deploy grep docs/IMPL-PLAN.md,docs/checks/check-structure.sh check-structure PASSED
|
||||
|
@@ -1,57 +0,0 @@
|
||||
# Coverage ledger
|
||||
|
||||
Provenance for every requirement in `docs/REQUIREMENTS.md`. One row per source atom.
|
||||
Kept under `.audit/` on purpose, so it is not part of the `docs/` structure contract.
|
||||
|
||||
`tools/check_coverage.py` reads every `*.md` in this directory except this file.
|
||||
|
||||
## Row format
|
||||
|
||||
```
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
```
|
||||
|
||||
- **Source** — where the atom came from, prefixed by kind: `tag:TEST-SPEC`, `tag:OPENAPI`,
|
||||
`prd`, `cur:CREATE`, `issue:#5`, `audit:14`.
|
||||
- **Atom** — the stable key inside that source, e.g. `H-1`, `settings-PUT-reject`.
|
||||
- **Requirement** — a short restatement, enough to recognise it.
|
||||
- **Disposition** — one of the values below.
|
||||
- **PoR ID** — the `docs/REQUIREMENTS.md` ID(s), comma-separated. `—` when none.
|
||||
- **Rationale** — required for everything that is not plain `ported`.
|
||||
|
||||
## Dispositions
|
||||
|
||||
| Value | Meaning | PoR ID | Rationale |
|
||||
|---|---|---|---|
|
||||
| `ported` | fully represented by the cited row(s) | required | optional |
|
||||
| `duplicate` | same rule as another atom | optional | required |
|
||||
| `new` | PoR row with no source atom; a reconciliation decision | required | required |
|
||||
| `superseded` | replaced by a later source or decision | optional | required |
|
||||
| `deferred` | intentionally out of current scope | optional | required |
|
||||
| `dropped` | deliberately discarded | `—` | required |
|
||||
| `gap` | not represented, not deliberately dropped: an open hole | `—` | required |
|
||||
|
||||
`gap` is the finding. It is the thing this ledger exists to surface.
|
||||
|
||||
## Checks
|
||||
|
||||
Forward (always):
|
||||
|
||||
1. Every row has six well-formed columns and a known disposition.
|
||||
2. A `(Source, Atom)` pair is unique across the ledger.
|
||||
3. `ported` and `new` cite at least one defined requirement ID; every cited ID resolves.
|
||||
4. `dropped` and `gap` carry no ID and a non-empty rationale.
|
||||
5. Every non-`ported` row carries a non-empty rationale.
|
||||
|
||||
Reverse (`--strict` only, once coverage is complete):
|
||||
|
||||
6. Every ID in `docs/REQUIREMENTS.md` is claimed by at least one atom, or is `new`.
|
||||
7. No `gap` rows remain.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
python tools/check_coverage.py
|
||||
python tools/check_coverage.py --strict
|
||||
python tools/check_coverage.py --ledger-dir <path> # for negative tests
|
||||
```
|
||||
@@ -1,8 +0,0 @@
|
||||
# Coverage ledger — cur:CONTENT-WOO
|
||||
|
||||
Source `cur:CONTENT-WOO` (`docs/CONTENT-WOO.md`, current branch). Seeded by the
|
||||
settings/auth/health pilot 2026-09-18.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| cur:CONTENT-WOO | httpx-timeouts | httpx connect 10s, read 60s | dropped | — | connection detail; recorded in docs/contracts in step 3 |
|
||||
@@ -1,9 +0,0 @@
|
||||
# Coverage ledger — cur:CREATE
|
||||
|
||||
Source `cur:CREATE` (`docs/CREATE.md`, current branch). Seeded by the settings/auth/health
|
||||
pilot 2026-09-18.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| cur:CREATE | settings-url | WOO_BASE_URL from env, read-only; change means edit .env and recreate | ported | SETTINGS-5 | — |
|
||||
| cur:CREATE | test-inference-shape | test-inference returns ok, mode, vision_capable, detail | ported | SETTINGS-45 | mode removed by ruling; SETTINGS-45 now returns ok, vision_capable, detail |
|
||||
@@ -1,19 +0,0 @@
|
||||
# Coverage ledger — README.md (docs map)
|
||||
|
||||
Source `cur:README` (`docs/README.md`, role: index). Walked 2026-09-18. Navigation-heavy;
|
||||
only the testable statements carry PoR IDs.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| cur:README | high-level-goal | one slab is one unit and one listing; capture flow | dropped | — | intent restatement; listing invariant covered by PUBLISH-60 |
|
||||
| cur:README | implemented-m1-m2 | M1/M2 shipped, UAT on `:10881` | ported | OPS-40 | — |
|
||||
| cur:README | no-woo-create-yet | no Woo create, backend stub, inference unwired | dropped | — | historical branch state, not a requirement |
|
||||
| cur:README | m3-exit-criteria | ready set plus a real `SLAB-UAT-*` draft with inference off | ported | QUALITY-110, REVIEW-5, METADATA-70 | — |
|
||||
| cur:README | app-password-server-side | WordPress application password, server-side only | ported | SETTINGS-15, SETTINGS-20, APP-25 | — |
|
||||
| cur:README | sync-taxonomy | sync and use the synced taxonomy | ported | METADATA-5 | — |
|
||||
| cur:README | pricing-species-rate | price from species `$/bdft` with user override | ported | REVIEW-25, SETTINGS-35 | — |
|
||||
| cur:README | inference-settings-calls | inference Settings and Call 1 / Call 2 rules | ported | SETTINGS-30, SETTINGS-45, METADATA-35, METADATA-55 | — |
|
||||
| cur:README | review-ready-create | review to the ready set, then create, force draft | ported | REVIEW-5, PUBLISH-10 | — |
|
||||
| cur:README | copied-from-main | technical detail copied from `main` | dropped | — | branch-neutral migration, not a requirement |
|
||||
| cur:README | folder-map | how to read the folder table | dropped | — | navigation |
|
||||
| cur:README | deploy-reminders | UAT `:10881` not `:10880`; env vs encrypted settings | ported | OPS-40, OPS-10, SETTINGS-5, SETTINGS-20, SETTINGS-30 | — |
|
||||
@@ -1,11 +0,0 @@
|
||||
# Coverage ledger — issues
|
||||
|
||||
Source `issue:#n` (Gitea `Ty_Tech/SlabUploader` design issues). Seeded by the
|
||||
settings/auth/health pilot 2026-09-18. The issue Source column distinguishes each issue.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| issue:#5 | app-password | app password, dedicated user, AES-GCM, browser never holds it, sole caller | ported | SETTINGS-15, SETTINGS-20, APP-25 | — |
|
||||
| issue:#11 | url-compose | WOO_BASE_URL declared in compose, not user-editable | ported | SETTINGS-5 | — |
|
||||
| issue:#14 | taxonomy-refresh | taxonomy refresh triggers and anchor | ported | SETTINGS-50, SETTINGS-55, SETTINGS-60 | — |
|
||||
| issue:#6 | instrument-health | health checks and instrumentation | ported | APP-45 | — |
|
||||
@@ -1,185 +0,0 @@
|
||||
# Coverage matrix — ARCHITECTURE.md
|
||||
|
||||
Source: `docs/ARCHITECTURE.md` at tag `pre-simplification`.
|
||||
Method: subagent extracted every surface entry; PoR home assigned here.
|
||||
Dispositions: `ported`, `superseded`, `dropped`, `ui`, `missing`.
|
||||
|
||||
## SETTINGS KEYS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| inference_enabled | SETTINGS-30, SETTINGS-90 | ported | default off added |
|
||||
| Species $/bdft | SETTINGS-35 | ported | — |
|
||||
| prompts (as settings) | METADATA-105 | superseded | prompts live in the DB, not settings |
|
||||
| Inference endpoint base URL | SETTINGS-30 | ported | — |
|
||||
| Inference API key | SETTINGS-30 | ported | — |
|
||||
| Woo credentials | SETTINGS-15, SETTINGS-20 | ported | — |
|
||||
| publish status | SETTINGS-65 | ported | — |
|
||||
| Store URL env, read-only | SETTINGS-5 | ported | — |
|
||||
| SLAB_AES_KEY | SETTINGS-25 | ported | — |
|
||||
| WOO_BASE_URL | SETTINGS-5, SETTINGS-10 | ported | — |
|
||||
| sheet mode | CAPTURE-40, CAPTURE-145 | ported | — |
|
||||
| sensitivity | CAPTURE-41, CAPTURE-145 | ported | — |
|
||||
| edge offset | CAPTURE-40 | superseded | removed control |
|
||||
| feather | CAPTURE-40 | superseded | removed control |
|
||||
| reset-to-default | CAPTURE-145 | ported | — |
|
||||
| Species list with $/bdft | SETTINGS-35 | ported | — |
|
||||
| Taxonomy snapshot | SETTINGS-50 | ported | — |
|
||||
|
||||
## CONTROLS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| New Wood 1–5 photos | CAPTURE-25 | superseded | no cap |
|
||||
| Four mask knobs | CAPTURE-40 | superseded | two knobs |
|
||||
| Mask edge OK | CAPTURE-50 | ported | — |
|
||||
| Retune knobs / Retake | CAPTURE-50, CAPTURE-155 | ported | — |
|
||||
| Length axis rotate/confirm | CAPTURE-60, CAPTURE-65 | ported | — |
|
||||
| User length/thickness/SKU | CAPTURE-70 | ported | — |
|
||||
| Call 1 pre-fill / manual taxonomy | METADATA-45, METADATA-50 | ported | — |
|
||||
| Generate text button | METADATA-55, METADATA-110 | ported | availability added |
|
||||
| Publish | PUBLISH-5 | ported | — |
|
||||
| Settings "Refresh taxonomy" control | SETTINGS-60 | ui | optional; not required for POC |
|
||||
| Inline nudges | REVIEW-20 | ported | — |
|
||||
|
||||
## LISTING FIELDS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Photos 1–5 | CAPTURE-25 | superseded | no cap |
|
||||
| SKU | REVIEW-45 | ported | — |
|
||||
| length / thickness | CAPTURE-70 | ported | — |
|
||||
| sqft / bdft / 6in widths | CAPTURE-80, CAPTURE-85, CAPTURE-90 | ported | — |
|
||||
| 3:4 PNG | CAPTURE-95, CAPTURE-100 | ported | — |
|
||||
| title / short_title / description | REVIEW-10 | ported | short_title naming migrated |
|
||||
| species | METADATA-10 | ported | — |
|
||||
| wood categories | METADATA-10 | ported | — |
|
||||
| edge / figure | METADATA-15 | ported | — |
|
||||
| grade | METADATA-15 | ported | cardinality superseded |
|
||||
| feat-* | METADATA-30 | ported | — |
|
||||
| fig-* | METADATA-30 | ported | cardinality superseded |
|
||||
| price | REVIEW-25 | ported | — |
|
||||
| moisture | METADATA-15 | ported | — |
|
||||
| originals | PUBLISH-25, PUBLISH-40 | ported | purge after publish |
|
||||
| woo_product_id | PUBLISH-25 | ported | — |
|
||||
|
||||
## STATUSES
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| draft | APP-5 | ported | — |
|
||||
| calibrated | APP-5 | ported | — |
|
||||
| ready | APP-5, REVIEW-5 | ported | fig/grade cardinality superseded |
|
||||
| publishing | APP-5 | ported | — |
|
||||
| published | APP-5, PUBLISH-25 | ported | — |
|
||||
| failed | APP-5, PUBLISH-30 | ported | — |
|
||||
| transitions draft→...→published/failed | APP-5 | ported | — |
|
||||
|
||||
## ENDPOINTS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| /wp-json/wc/v3/ | SETTINGS-15 | ported | — |
|
||||
| /api/v1/settings | SETTINGS-20, SETTINGS-30 | ported | — |
|
||||
| /api/v1/admin/taxonomy | SETTINGS-50 | ported | — |
|
||||
| POST /api/v1/settings/test-woo | SETTINGS-40, SETTINGS-80 | ported | — |
|
||||
| GET /api/v1/validation/module | — | dropped | validation module ruled cut |
|
||||
| POST /api/v1/settings/test-inference | SETTINGS-45 | ported | — |
|
||||
|
||||
## ERROR CODES
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| 409 duplicate SKU only | PUBLISH-15 | ported | — |
|
||||
| 422 stale value | PUBLISH-20 | ported | — |
|
||||
| 412 validation hash | — | dropped | validation module ruled cut |
|
||||
| "Never show 412" | PUBLISH-35 | dropped | moot after cut |
|
||||
|
||||
## USER-FACING ERROR / EMPTY STATES
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Call 1 runtime failure → retry or message | METADATA-115 | ported | recoverable retries, unrecoverable explains |
|
||||
| Below 0.7 fields stay empty | METADATA-45 | ported | — |
|
||||
| Duplicate SKU field error | PUBLISH-15 | ported | — |
|
||||
| Stale value field error | PUBLISH-20 | ported | — |
|
||||
| Review gate empty subset | REVIEW-5, REVIEW-20 | ported | — |
|
||||
| Inline nudges, not submit-only | REVIEW-20 | ported | — |
|
||||
| UI "Updating rules" | — | dropped | validation module cut |
|
||||
| failed error detail stored, retry | PUBLISH-30 | ported | — |
|
||||
|
||||
## INFERENCE
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Call 1 auto-run only if inference_enabled | METADATA-35, SETTINGS-90 | ported | — |
|
||||
| Call 1 skipped when disabled | METADATA-70 | ported | — |
|
||||
| Call 1 input: all originals downscaled to 1024 | METADATA-35 | superseded | PoR sends the clean processed photos |
|
||||
| Call 1 input: synced taxonomy only | METADATA-35, METADATA-40 | ported | — |
|
||||
| Call 1 input: SKU/length/thickness | METADATA-35 | ported | — |
|
||||
| Call 1 timeout 45s | METADATA-115 | ported | runtime failure triage |
|
||||
| Threshold ≥0.7 pre-populate | METADATA-45 | ported | — |
|
||||
| Below 0.7 empty | METADATA-45 | ported | — |
|
||||
| Manual entry authoritative | METADATA-50 | ported | — |
|
||||
| Call 2 no auto-fire | METADATA-55 | ported | — |
|
||||
| Call 2 portable context / full resend | — | superseded | thread work out of MVP |
|
||||
| previous_response_id | — | superseded | thread work out of MVP |
|
||||
| Call 2 confirmed vs inferred | METADATA-60 | ported | — |
|
||||
| per-slab thread, delete on submit/abandon | — | superseded | thread work out of MVP |
|
||||
| test-inference mode stateful/stateless/fail | SETTINGS-45 | ported | — |
|
||||
| fail blocks Call 2 only | SETTINGS-45 | ported | — |
|
||||
| Call 1/2 assist-only, do not gate publish | METADATA-75 | ported | — |
|
||||
| low-confidence does not auto-fill | METADATA-45 | ported | — |
|
||||
|
||||
## PUBLISH PAYLOAD
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Draft upload contents | CAPTURE-135 | ported | — |
|
||||
| Originals needed for retry | PUBLISH-40 | ported | — |
|
||||
| Post-publish deletion | PUBLISH-25 | ported | — |
|
||||
| Duplicate SKU handling | PUBLISH-15 | ported | — |
|
||||
| Stale value handling | PUBLISH-20 | ported | — |
|
||||
| Product status per setting | SETTINGS-65, PUBLISH-10 | ported | — |
|
||||
| Title/desc typed, generated, or templated | REVIEW-10, METADATA-70 | ported | templates are fallback |
|
||||
| Publish poll | PUBLISH-5 | ported | — |
|
||||
| woo_product_id stored | PUBLISH-25 | ported | — |
|
||||
| Images purged | PUBLISH-25 | ported | — |
|
||||
| Publish resync > 1 hour | SETTINGS-60 | ported | — |
|
||||
| Submit carries validation_hash / 412 retry | — | dropped | validation module cut |
|
||||
| Woo-shaped 422 store rejection | APP-50, PUBLISH-20 | ported | — |
|
||||
| Taxonomy: categories/attributes/fig-* synced | SETTINGS-50, SETTINGS-51 | ported | scope narrowed to fig/feat |
|
||||
|
||||
## CONSTANTS / NUMBERS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| 1–5 photos | CAPTURE-25 | superseded | no cap |
|
||||
| downscale 1024 | METADATA-35 | ported | source conflict noted |
|
||||
| Call 1 timeout 45s | METADATA-115 | ported | runtime failure triage |
|
||||
| confidence 0.7 | METADATA-45 | ported | — |
|
||||
| 3:4 | CAPTURE-100 | ported | — |
|
||||
| 80% fill | CAPTURE-100 | superseded | 90 percent |
|
||||
| 6in widths | CAPTURE-80 | ported | — |
|
||||
| SKU charset 3..24 | REVIEW-45 | ported | case widened |
|
||||
| taxonomy staleness 1 hour | SETTINGS-60 | ported | — |
|
||||
| ready cardinalities (PNG/species/wood/edge/figure/fig/grade/moisture/feat) | REVIEW-5 | ported | fig/grade superseded |
|
||||
| five attributes | METADATA-15 | ported | — |
|
||||
|
||||
## BROWSER / API SUPPORT
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Browser does not call Woo or vision | APP-25 | ported | — |
|
||||
| no CORS to store | APP-25 | ported | — |
|
||||
| localStorage persistence | CAPTURE-145 | ported | — |
|
||||
| client BG removal APIs | CAPTURE-140, CAPTURE-35 | ported | — |
|
||||
| camera | CAPTURE-25, CAPTURE-20 | ported | — |
|
||||
| no U2Net control (POC) | CAPTURE-165 | ported | — |
|
||||
| U2Net deferred, not deleted | CAPTURE-165 | ported | — |
|
||||
| title/desc Woo maxLength | REVIEW-50 | ported | Woo v3 documents no max; caps in `docs/contracts/WOO-API.md` §11 |
|
||||
|
||||
## Findings
|
||||
|
||||
None open. Runtime inference failure is `METADATA-115`; the Call 1 image source is
|
||||
`METADATA-35`; title/description length caps are inherent Woo limits.
|
||||
@@ -1,158 +0,0 @@
|
||||
# Coverage matrix — CREATE.md (current branch)
|
||||
|
||||
Source: `docs/CREATE.md` (current working tree).
|
||||
Method: every surface entry dispositioned to PoR IDs or a recorded drop.
|
||||
|
||||
## Findings
|
||||
|
||||
None new. Two open decisions in the doc are already settled elsewhere: the `attributes` element
|
||||
shape is resolved in `docs/contracts/WOO-API.md` §4, and the crop fill/floor (90% / 800) is the
|
||||
PoR. The client stack note ("Vite + Svelte SPA") is implementation, not a requirement.
|
||||
|
||||
## Superseded (queued in migration TODOs)
|
||||
|
||||
- ready set "at least one inventory PNG" → REVIEW-5 (two images)
|
||||
- grade 1+ → METADATA-15 (exactly one)
|
||||
- `fig-*` 1+ required → METADATA-30 (optional)
|
||||
- `short_title` → REVIEW-10 (`short_description`)
|
||||
- "user typed number wins if overridden" for widths/sqft/bdft → REVIEW-30, REVIEW-40 (read-only)
|
||||
- "title/short/desc not required to reach ready" → REVIEW-10
|
||||
- "templates always available" and "server assembles from templates" → METADATA-70, METADATA-80
|
||||
(model writes when Call 2 runs; template is fallback)
|
||||
- "Cap at 5 images" / "Cap ≤5" → PUBLISH-40 (no cap)
|
||||
- Call 1 input "inventory photos downscaled to 1024" → METADATA-35 (clean processed photos)
|
||||
|
||||
## SETTINGS KEYS
|
||||
|
||||
- `WOO_BASE_URL` env, read-only → SETTINGS-5; HTTPS → SETTINGS-10
|
||||
- `SLAB_AES_KEY` env → SETTINGS-25
|
||||
- `woo_wp_username` / `woo_app_password` encrypted → SETTINGS-20
|
||||
- `woo_create_status` default draft → SETTINGS-65
|
||||
- `inference_base_url`, `inference_api_key`, `inference_model`, `inference_enabled` → SETTINGS-30
|
||||
- `content_llm_enabled` → SETTINGS-30, SETTINGS-90
|
||||
- `taxonomy_last_sync_at`, `taxonomy_anchor` → SETTINGS-55
|
||||
- `brand_voice`, `geo_context` → SETTINGS-30
|
||||
|
||||
## CONTROLS
|
||||
|
||||
- store URL read-only → SETTINGS-5
|
||||
- credential entry → SETTINGS-20
|
||||
- Generate text disabled when endpoint down → METADATA-110, METADATA-75
|
||||
- Call 2 blocked when test-inference `fail` → SETTINGS-45
|
||||
- inline nudges → REVIEW-20
|
||||
- species picker synced only → METADATA-5, METADATA-10
|
||||
- attribute pickers synced only → METADATA-15
|
||||
- stale field / vanished id → PUBLISH-20, REVIEW-35
|
||||
- duplicate SKU actions → PUBLISH-15
|
||||
- create/publish trigger → PUBLISH-5
|
||||
- draft upload trigger → CAPTURE-135
|
||||
|
||||
## LISTING FIELDS
|
||||
|
||||
- `sku` → REVIEW-45; `status` enum → APP-5
|
||||
- `length_in`, `thickness_in` 1/8 → CAPTURE-70
|
||||
- `sqft`, `bdft`, widths → CAPTURE-80, CAPTURE-85, CAPTURE-90
|
||||
- `species_id` 1 leaf → METADATA-10; `wood_category_ids` 1+ → METADATA-10
|
||||
- `edge_type_term_id` 1 → METADATA-15; `figure_term_ids` 1+ → METADATA-15
|
||||
- `thickness_term_id` round-up → METADATA-15; `moisture_term_id` default kiln-dried → METADATA-15
|
||||
- `feat_tag_ids` 0+ → METADATA-30
|
||||
- `price`, `price_per_bdft`, `price_source` → REVIEW-25
|
||||
- templates (title, short, description, `{character}`, `{geo_sentence}`) → contract detail in
|
||||
`docs/contracts/CONTENT-WOO.md`; fallback only
|
||||
- species/character/length/free-text thickness not attributes → METADATA-25
|
||||
|
||||
## STATUSES
|
||||
|
||||
- draft/calibrated/ready/publishing/published/failed → APP-5
|
||||
- `woo_create_status` draft/publish → SETTINGS-65
|
||||
- test-inference mode stateful/stateless/fail → SETTINGS-45
|
||||
- device draft is not a Woo draft → APP-10
|
||||
|
||||
## ENDPOINTS
|
||||
|
||||
- `/api/health` → APP-30, APP-45
|
||||
- slabs CRUD, photos, infer-taxon, infer-content, publish, taxonomy, pricing, settings →
|
||||
`docs/contracts/API.md`
|
||||
- Woo endpoints (`/wc/v3/products`, categories, tags, attributes, media) →
|
||||
`docs/contracts/WOO-API.md`
|
||||
|
||||
## ERROR CODES
|
||||
|
||||
- 502 `woo_error` → SETTINGS-50, PUBLISH-30
|
||||
- 422 `unpublishable` → REVIEW-5
|
||||
- 409 `duplicate_sku` → PUBLISH-15
|
||||
- 202 publish → contract
|
||||
|
||||
## USER-FACING ERROR / EMPTY STATES
|
||||
|
||||
- sync failure keeps previous cache → SETTINGS-50
|
||||
- create blocked until ready → REVIEW-5, REVIEW-20
|
||||
- duplicate SKU block → PUBLISH-15
|
||||
- stale field, current options → PUBLISH-20
|
||||
- vanished id not silently swapped → REVIEW-35
|
||||
- failure: log, mark failed, keep photos, field-level error, no raw code → PUBLISH-30, PUBLISH-35
|
||||
- low-confidence not shown as defaults → METADATA-45
|
||||
- Generate text down → METADATA-110
|
||||
- no pricing rule → price empty, owner types → REVIEW-25
|
||||
|
||||
## INFERENCE
|
||||
|
||||
- Call 1 auto only when enabled → METADATA-35
|
||||
- Call 1 input (photos, metadata, taxonomy) → METADATA-35
|
||||
- Call 1 output + confidence → METADATA-40
|
||||
- threshold 0.7 pre-fill / below empty → METADATA-45
|
||||
- moisture never inferred → METADATA-15
|
||||
- typed wins → METADATA-50
|
||||
- never blocks create / auto-publish → METADATA-75
|
||||
- Call 2 user-triggered; requires test-inference ok → METADATA-55, SETTINGS-45
|
||||
- model prose only, no dimensions → METADATA-65
|
||||
- templates fallback → METADATA-70
|
||||
- test-inference shape, fail blocks Call 2 only → SETTINGS-45
|
||||
- templates math from deterministic pipeline → METADATA-90
|
||||
- inference-off create → QUALITY-20, METADATA-70
|
||||
- FastAPI proxies inference → APP-25
|
||||
|
||||
## PUBLISH PAYLOAD / FLOW
|
||||
|
||||
- payload fields (`name`, `type`, `status`, `sku`, `regular_price`, `description`,
|
||||
`short_description`, `categories`, `tags`, `images`, `attributes`, `virtual`, `downloadable`,
|
||||
`catalog_visibility`) → `docs/contracts/WOO-API.md`, PUBLISH-55
|
||||
- non-null only → PUBLISH-55
|
||||
- `SLAB-UAT-*` force draft → PUBLISH-10
|
||||
- images upload keep alpha, lineup order → PUBLISH-40, PUBLISH-45
|
||||
- published meaning (not live) → PUBLISH-50
|
||||
- success behavior (product id, purge, delete row, clear device draft) → PUBLISH-25, APP-15
|
||||
- failure behavior → PUBLISH-30
|
||||
- create sequence steps 1–4 → PUBLISH-5
|
||||
- `images alt` text → contract detail
|
||||
- attributes element shape → resolved, `docs/contracts/WOO-API.md` §4
|
||||
|
||||
## CONSTANTS / NUMBERS
|
||||
|
||||
- `per_page=100` → SETTINGS-50, WOO-API §7
|
||||
- connect 10s / read 60s → contract detail
|
||||
- taxonomy staleness 1 hour → SETTINGS-60
|
||||
- SKU `[A-Z0-9-]{3,24}` → REVIEW-45
|
||||
- downscale 1024 → METADATA-35
|
||||
- confidence 0.7 → METADATA-45
|
||||
- bdft and price formulas → CAPTURE-90, REVIEW-25
|
||||
- 1/8 step → CAPTURE-70
|
||||
- five attributes → METADATA-15
|
||||
- UAT port `:10881`, do not touch `:10880` → OPS-40
|
||||
- fill 90% / 800px floor → CAPTURE-100, CAPTURE-105
|
||||
|
||||
## BROWSER / API SUPPORT
|
||||
|
||||
- FastAPI only Woo caller; browser no creds → APP-25
|
||||
- app-password Basic auth, not consumer keys → SETTINGS-15
|
||||
- HTTPS only → SETTINGS-10
|
||||
- proxy inference, no key in browser → APP-25, SETTINGS-30
|
||||
- device draft IndexedDB → APP-10, CAPTURE-140
|
||||
- Woo source of truth, cache ids → METADATA-5
|
||||
- v1 no term creation → METADATA-20
|
||||
- taxonomy sync order, transactional, anchor, re-pull, triggers → SETTINGS-50, SETTINGS-55,
|
||||
SETTINGS-60
|
||||
- pricing rules flat per species, seed, override sticks, manual allowed → SETTINGS-35, REVIEW-25
|
||||
- practice rules (`SLAB-UAT-*`, hidden, inference off) → PUBLISH-10, QUALITY-20
|
||||
- dishonest proxies → QUALITY-105
|
||||
- tag/leaf filters and feat mapping → SETTINGS-51, METADATA-10, METADATA-30, METADATA-100
|
||||
@@ -1,83 +0,0 @@
|
||||
# Coverage matrix — CONTENT-WOO, CAPTURE, PHOTOS (current branch)
|
||||
|
||||
Sources: `docs/CONTENT-WOO.md`, `docs/CAPTURE.md`, `docs/PHOTOS.md` (current working tree).
|
||||
|
||||
## Findings
|
||||
|
||||
None new. Two notes: the copy-template storage location ("templates live in Settings") is
|
||||
contract detail for `docs/contracts/CONTENT-WOO.md`, and the case-insensitive name-matching rule
|
||||
is legacy — the review flow selects from pickers, so it is superseded by `METADATA-5`.
|
||||
|
||||
## CONTENT-WOO.md
|
||||
|
||||
Superseded: `short_title` → REVIEW-10; `fig-*` 1+ required → METADATA-30; "≤5 inventory photos" →
|
||||
PUBLISH-40; "Only kind=inventory photos / calibration photos do not exist" → calibration exists
|
||||
(CAPTURE-25, CAPTURE-30); "check against the shared rules (current copy)" → validation module cut
|
||||
(REVIEW-5); case-insensitive name matching → METADATA-5.
|
||||
|
||||
Ported:
|
||||
- `content_llm_enabled`, `content_source` → SETTINGS-30, METADATA-66
|
||||
- `geo_sentence` from Settings, default none → SETTINGS-30
|
||||
- `woo_create_status` default draft → SETTINGS-65
|
||||
- `WOO_BASE_URL` env HTTPS; `SLAB_AES_KEY` → SETTINGS-5, SETTINGS-10, SETTINGS-25
|
||||
- templates title/short/description and `{character}` → contract detail
|
||||
`docs/contracts/CONTENT-WOO.md`
|
||||
- categories species 1 + wood 1+, tags fig/feat, five attributes → METADATA-10, METADATA-15,
|
||||
METADATA-30
|
||||
- ready set → REVIEW-5 (fig/grade cardinality superseded)
|
||||
- payload fields → `docs/contracts/WOO-API.md`, PUBLISH-55
|
||||
- `type simple`, `virtual false`, `downloadable false`, `catalog_visibility visible` → PUBLISH-55
|
||||
- image bytes keep alpha, `POST /media` → PUBLISH-40, WOO-API §5
|
||||
- `SLAB-UAT-*` force draft → PUBLISH-10
|
||||
- status `draft`/`publish`, published meaning → SETTINGS-65, PUBLISH-50
|
||||
- create sequence steps 1–7 → PUBLISH-5
|
||||
- dedupe every time → PUBLISH-15
|
||||
- post-create delete originals + processed → PUBLISH-25
|
||||
- failure: field message, mark failed, keep photos → PUBLISH-30, PUBLISH-35
|
||||
- partial-failure orphaned media manual cleanup → PUBLISH-31
|
||||
- taxonomy sync order, `per_page=100`, 1-hour reconcile, anchor → SETTINGS-50, SETTINGS-55,
|
||||
SETTINGS-60
|
||||
- vision 0.7 fills; vision off typed; typed wins → METADATA-45, METADATA-50, METADATA-70
|
||||
- model writes prose only, synced choices only, no invented dimensions → METADATA-40, METADATA-65
|
||||
- httpx timeouts 10s/60s → contract detail
|
||||
- wire `{field, code, message, actions[]}`, codes never shown → PUBLISH-35, API contract
|
||||
|
||||
## CAPTURE.md
|
||||
|
||||
Ported:
|
||||
- Sheet control, Sensitivity 0–100 → CAPTURE-40, CAPTURE-41
|
||||
- immediate value update, 2.0 s recompute, last good overlay → CAPTURE-45
|
||||
- approve blocked with no slab pixels → CAPTURE-50
|
||||
- length axis rotate/approve → CAPTURE-60
|
||||
- length/thickness 1/8, Calculate → CAPTURE-70
|
||||
- scale `longest_axis_px / L` → CAPTURE-75
|
||||
- widths every 6 in, drop first/last 6, `L ≤ 12` all zero → CAPTURE-80
|
||||
- `sqft = mask_pixels / scale² / 144`, 2 dp → CAPTURE-85
|
||||
- `bdft = sqft × thickness_in`, 2 dp → CAPTURE-90
|
||||
- 3:4, 90% fill, shorter side ≥ 800, no upscale → CAPTURE-100, CAPTURE-105
|
||||
- PNG 8-bit RGBA, alpha kept → CAPTURE-95
|
||||
- browsers and hard stop → CAPTURE-10
|
||||
- client APIs and no OpenCV/ONNX/U2Net → CAPTURE-140
|
||||
- preview display-only, source pixels → CAPTURE-141
|
||||
- device draft, Resume/Continue/Start fresh → APP-10
|
||||
- 1/8 steps → CAPTURE-70; sensitivity maps green/black → CAPTURE-41
|
||||
|
||||
Dropped: busy-mark "fraction of a second" (nit, migration TODO); "other backgrounds out of M1"
|
||||
(scope); M1 single reference photo superseded by batch (CAPTURE-25).
|
||||
|
||||
## PHOTOS.md
|
||||
|
||||
Ported:
|
||||
- batch one or more, continue at least one → CAPTURE-25
|
||||
- pick calibration photo → CAPTURE-25
|
||||
- M1 states on the calibration photo only → CAPTURE-30
|
||||
- Photos ready lineup order → CAPTURE-130
|
||||
- per-photo empty mask / size floor excluded, rest proceed → CAPTURE-110
|
||||
- single-photo batch works → CAPTURE-25
|
||||
- crop rules reuse (3:4, 90%, PNG, floor) → CAPTURE-100, CAPTURE-105
|
||||
- overlay black; stations every 12 in from 6 in, while ≤ L−6; EXIF bottom → CAPTURE-120,
|
||||
CAPTURE-125
|
||||
- 1–N photos → CAPTURE-25
|
||||
- no inference / Woo create on this path → APP-25
|
||||
|
||||
No findings.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Coverage matrix — Gitea design issues #1–#20
|
||||
|
||||
Source: `gitea.vps1.afterthedemo.com/Ty_Tech/SlabUploader` issues #1–#20 (design issues).
|
||||
Method: every issue dispositioned to PoR IDs or a recorded drop.
|
||||
|
||||
## Findings
|
||||
|
||||
None open. Issue #6's structured logging, request id, and `LOG_LEVEL` are now `APP-55`.
|
||||
|
||||
## Issue map
|
||||
|
||||
| Issue | Atom | Disposition | PoR |
|
||||
|---|---|---|---|
|
||||
| #1 | portable multi-turn context (closed, superseded by #2) | superseded | thread work out; Call 2 fresh full context METADATA-60 |
|
||||
| #2 | portable multi-turn context | ported | METADATA-60, SETTINGS-45 |
|
||||
| #2 | per-slab thread, delete on submit/abandon | superseded | thread work out |
|
||||
| #2 | confirmed vs inferred labels | ported | METADATA-60 |
|
||||
| #3 | duplicate SKU means "any status" | ported | PUBLISH-15 |
|
||||
| #4 | browser compatibility floor + hard stop + load-bearing APIs | ported | CAPTURE-10, CAPTURE-140 |
|
||||
| #5 | Woo auth via application passwords, server-side, AES-GCM | ported | SETTINGS-15, SETTINGS-20, APP-25 |
|
||||
| #6 | health checks DB + image-volume writability | ported | APP-45 |
|
||||
| #6 | sync_log + inference_log written and queryable | ported | PUBLISH-30, METADATA-66 |
|
||||
| #6 | structured logging, request id phone→FastAPI→Woo, LOG_LEVEL | ported | APP-55 |
|
||||
| #7 | Prometheus metrics, alerting, log retention (V1) | dropped | out of M3 scope |
|
||||
| #8 | Call 1 pre-populates review fields when enabled | ported | METADATA-45 |
|
||||
| #9 | 0.7 threshold prefill; suite passes with inference off | ported | METADATA-45, QUALITY-20 |
|
||||
| #10 | per-field inline nudges; UX presentation pass | ported | REVIEW-20; docs/contracts/UX.md |
|
||||
| #11 | WOO_BASE_URL compose-only, read-only, HTTPS, not user-editable | ported | SETTINGS-5, SETTINGS-10, SETTINGS-85 |
|
||||
| #12 | only synced species selectable | ported | METADATA-5 |
|
||||
| #13 | store-admin creates a species leaf (V1) | dropped | out of M3 scope |
|
||||
| #14 | taxonomy anchor, client re-pull, triggers | ported | SETTINGS-50, SETTINGS-55, SETTINGS-60 |
|
||||
| #15 | BG knobs inline, live re-run, mask on source photo | ported | CAPTURE-45, CAPTURE-150 |
|
||||
| #15 | four knobs | superseded | CAPTURE-40 |
|
||||
| #15 | client-only POC; U2Net deferred | ported | CAPTURE-165 |
|
||||
| #16 | V1 instrumentation | dropped | out of M3 scope |
|
||||
| #17 | shared validation module, versioned, submit-checked | dropped | validation module cut |
|
||||
| #18 | 409/422 mapped to field-level recovery, no top banners | ported | PUBLISH-15, PUBLISH-20, PUBLISH-35 |
|
||||
| #19 | POC acceptance test cases | duplicate | TEST-SPEC owns the test plan |
|
||||
| #20 | TEST-SPEC review feedback | dropped | process; TEST-SPEC reconciled last |
|
||||
@@ -1,152 +0,0 @@
|
||||
# Coverage matrix — OPENAPI.md
|
||||
|
||||
Source: `docs/OPENAPI.md` at tag `pre-simplification`.
|
||||
Method: subagent extracted every surface entry; PoR home assigned here.
|
||||
Dispositions: `ported`, `superseded`, `dropped` (contract detail), `ui`, `missing`.
|
||||
|
||||
## Findings
|
||||
|
||||
None. Three items are contract detail (photo 40 MB request cap, `GET /meta`, `inference_error`
|
||||
field), and the schema conflicts are already resolved in the PoR.
|
||||
|
||||
## SETTINGS KEYS
|
||||
|
||||
- `woo_base_url` read-only → SETTINGS-5
|
||||
- `woo_base_url` not writable, reject write → SETTINGS-85
|
||||
- `woo_wp_username` / `woo_app_password` write-only, AES-GCM → SETTINGS-20
|
||||
- reject `woo_consumer_key` / `woo_consumer_secret` → SETTINGS-15
|
||||
- `woo_credentials_configured` boolean → SETTINGS-75
|
||||
- secrets masked, never echoed → SETTINGS-75
|
||||
- `inference_base_url`, `inference_model`, `inference_enabled`, `content_llm_enabled`,
|
||||
`brand_voice`, `geo_context`, `woo_create_status` → SETTINGS-30, SETTINGS-65
|
||||
- `aspect_ratio` 3:4 → CAPTURE-100
|
||||
- `output_px` / `output_px_min` 1600, `fill_target` 0.80 → superseded CAPTURE-105
|
||||
- client prefs not in SettingsView → CAPTURE-145
|
||||
- PUT partial body, re-encrypt, no echo → SETTINGS-20, SETTINGS-30
|
||||
- `inference_enabled` default `true` in the example → superseded SETTINGS-90 (default off)
|
||||
|
||||
## CONTROLS
|
||||
|
||||
- pickers limited to synced taxonomy → METADATA-5
|
||||
- duplicate SKU actions `edit_sku` / `open_existing` → PUBLISH-15
|
||||
- stale field actions/options → PUBLISH-20
|
||||
|
||||
## LISTING FIELDS (SlabCreate, read model, results)
|
||||
|
||||
- `id`, `client_rev` → APP-20
|
||||
- `sku` 3..24 uppercase → REVIEW-45 (case widened; Woo hard cap in WOO-API §9)
|
||||
- `length_in`, `thickness_in` 1/8 → CAPTURE-70
|
||||
- `sqft`, `bdft`, widths → CAPTURE-80, CAPTURE-85, CAPTURE-90
|
||||
- `species_id`, `wood_category_ids` → METADATA-10
|
||||
- `edge_type_term_id`, `figure_term_ids`, `thickness_term_id`, `moisture_term_id` → METADATA-15
|
||||
- `grade_term_ids` (plural, 1+) → superseded METADATA-15 (exactly one, singular)
|
||||
- `fig_tag_ids` (1+) → superseded METADATA-30 (optional)
|
||||
- `feat_tag_ids` (0+) → METADATA-30
|
||||
- `price`, `price_source` → REVIEW-25
|
||||
- `price_per_bdft` on slab → contract detail (pricing rules SETTINGS-35)
|
||||
- `title`, `description` → REVIEW-10
|
||||
- `short_title` → superseded REVIEW-10 (`short_description` canonical)
|
||||
- `content_source` → METADATA-66
|
||||
- read model `status`, `inference_status`, `woo_product_id`, `published_at`, revisions → APP-5,
|
||||
METADATA-35, PUBLISH-25, APP-20
|
||||
- `inference_error` → contract detail
|
||||
- PhotoRead `kind` label → dropped (label discarded); urls → contract; 410 → PUBLISH-25
|
||||
- Call1 result ids/confidences → METADATA-40; free-text → METADATA-100
|
||||
- Call2 result → METADATA-55/60
|
||||
- PriceRule shape → SETTINGS-35
|
||||
|
||||
## STATUSES
|
||||
|
||||
- `calibrated` on create → APP-5, CAPTURE-135
|
||||
- `ready` preconditions → REVIEW-5 (fig/grade cardinality superseded)
|
||||
- `publishing` / `failed` / `published` → APP-5, PUBLISH-25, PUBLISH-30
|
||||
- `inference_status` enum → METADATA-35
|
||||
- duplicate check across any Woo status → PUBLISH-15
|
||||
|
||||
## ENDPOINTS
|
||||
|
||||
- `GET /api/health` body → APP-45
|
||||
- `GET /api/v1/meta` → contract detail
|
||||
- `POST/GET/PUT/DELETE /api/v1/slabs` → docs/contracts/API.md
|
||||
- `POST .../u2net/{pid}` reserved → CAPTURE-165
|
||||
- `GET .../photos/...` → PUBLISH-25
|
||||
- `POST .../infer-taxon` → METADATA-35
|
||||
- `POST .../infer-content` → METADATA-55
|
||||
- `POST .../publish` → PUBLISH-5
|
||||
- taxonomy GET/sync → SETTINGS-50, SETTINGS-55, SETTINGS-60
|
||||
- pricing CRUD → SETTINGS-35
|
||||
- settings GET/PUT, test-woo, test-inference → SETTINGS-20, SETTINGS-30, SETTINGS-40, SETTINGS-45,
|
||||
SETTINGS-75, SETTINGS-85
|
||||
- `GET /api/v1/validation/module` → dropped (module cut)
|
||||
|
||||
## ERROR CODES
|
||||
|
||||
- 400, 404, 500 → APP-35
|
||||
- 409 `revision_conflict` → APP-20
|
||||
- 409 `duplicate_sku` → PUBLISH-15
|
||||
- 422 `unpublishable` → REVIEW-5, REVIEW-20
|
||||
- 422 `stale_field` → PUBLISH-20
|
||||
- 422 test-inference gate → SETTINGS-45
|
||||
- 422 `woo_base_url` write → SETTINGS-85
|
||||
- 502 `woo_error` → PUBLISH-30, SETTINGS-50
|
||||
- 504 timeout → METADATA-115
|
||||
- 410 photos → PUBLISH-25
|
||||
- 412 validation module → dropped (module cut)
|
||||
|
||||
## USER-FACING ERROR / EMPTY STATES
|
||||
|
||||
- never show the code, no raw code → PUBLISH-35
|
||||
- unpublishable UI mapping → REVIEW-20
|
||||
- test-woo fails closed → SETTINGS-80
|
||||
- test-inference `fail` blocks Call 2 only → SETTINGS-45, METADATA-75
|
||||
- low-confidence no prefill → METADATA-45
|
||||
- Woo failure surfaces as failed + log → PUBLISH-30
|
||||
- taxonomy sync failure keeps previous cache → SETTINGS-50
|
||||
|
||||
## INFERENCE
|
||||
|
||||
- Call 1 auto only if enabled → METADATA-35
|
||||
- Call 1 inputs "original photos downscaled to 1024" → superseded METADATA-35 (clean processed
|
||||
photos)
|
||||
- Call 1 timeout 45s → METADATA-115
|
||||
- confidence persist ≥0.7 → METADATA-45
|
||||
- free-text → feat-* mapping → METADATA-100
|
||||
- Call 2 default resend full context → METADATA-60 (no thread)
|
||||
- `previous_response_id`, per-slab thread → superseded (thread work out)
|
||||
- Call 2 requires test-inference ok → SETTINGS-45
|
||||
- validation module shared with Call 2 → dropped (module cut)
|
||||
|
||||
## PUBLISH PAYLOAD / FLOW
|
||||
|
||||
- preconditions → REVIEW-5
|
||||
- dedupe → PUBLISH-15
|
||||
- stale → PUBLISH-20
|
||||
- success (product id, purge, delete row) → PUBLISH-25
|
||||
- failure (failed, sync_log, poll 502) → PUBLISH-30
|
||||
- create-time Woo status → SETTINGS-65, PUBLISH-10
|
||||
|
||||
## CONSTANTS / NUMBERS
|
||||
|
||||
- base path `/api/v1`, version bump → APP-30
|
||||
- timestamps UTC ISO-8601 → APP-20
|
||||
- photo request cap 40 MB → contract detail
|
||||
- SKU pattern `[A-Z0-9-]{3,24}` → REVIEW-45
|
||||
- 1/8 step → CAPTURE-70
|
||||
- price 2 decimals → REVIEW-25
|
||||
- poll backoff 1/2/4/10s → contract detail
|
||||
- 1024 downscale → METADATA-35
|
||||
- 0.7 threshold → METADATA-45
|
||||
- 45s timeout → METADATA-115
|
||||
- 3:4 → CAPTURE-100
|
||||
- 1600 / 0.80 → superseded CAPTURE-105
|
||||
- publish cardinalities → REVIEW-5
|
||||
|
||||
## BROWSER / API SUPPORT
|
||||
|
||||
- client pins version → APP-30
|
||||
- no client auth → OPS-55
|
||||
- Woo HTTPS Basic Auth, application password, not consumer keys → SETTINGS-10, SETTINGS-15
|
||||
- u2net post-POC not exposed → CAPTURE-165
|
||||
- out of contract (bulk, staff, webhook, analytics) → dropped
|
||||
- test-woo always resyncs → SETTINGS-40
|
||||
- anchor re-pull / bump rules → SETTINGS-55, SETTINGS-60
|
||||
@@ -1,115 +0,0 @@
|
||||
# Coverage matrix — PRD-2026-08.txt
|
||||
|
||||
Source: `docs/archive/PRD-2026-08.txt` (frozen history). Method: every labeled atom dispositioned.
|
||||
|
||||
## Findings
|
||||
|
||||
None new. The two potential holes from this doc are already covered: inference timeout/error →
|
||||
`METADATA-115`, and no app-level auth → `OPS-55`. The archive's own banner already flags FR17,
|
||||
FR19, FR40, FR42, and the NFR server timing as non-authoritative.
|
||||
|
||||
## Archive banner
|
||||
|
||||
- Flags FR17, FR19, FR40, FR42, NFR server timing, and "Known stale" → each of those lines is
|
||||
superseded by a current row (below).
|
||||
|
||||
## Decisions log (2026-08-31)
|
||||
|
||||
- Doc contradiction freeze → dropped (process).
|
||||
- bdft identity, secrets AES-GCM, online-only, `woo_create_status` default draft, `SLAB-UAT-*`
|
||||
force draft, hybrid happy path, inference off first → ported CAPTURE-90, SETTINGS-20/25,
|
||||
CAPTURE-15, SETTINGS-65, PUBLISH-10, CAPTURE-5, QUALITY-20.
|
||||
- Review package not in repo → dropped.
|
||||
|
||||
## Decisions log (2026-08-27)
|
||||
|
||||
| # | Atom | Disposition | PoR |
|
||||
|---|---|---|---|
|
||||
| 1 | no calibration photos | superseded | CAPTURE-25, CAPTURE-30 |
|
||||
| 1 | manual SKU/length/thickness | ported | CAPTURE-70 |
|
||||
| 2 | first photo is the measurement photo, top-down | ported | CAPTURE-30, CAPTURE-20 |
|
||||
| 3 | all photos 80% crop | superseded | CAPTURE-100 |
|
||||
| 4 | transparent PNG min 1600, 3:4 | superseded | CAPTURE-95, CAPTURE-105 |
|
||||
| 5 | green/black auto-detect from borders | ported | CAPTURE-35 |
|
||||
| 6 | species 1 leaf, wood 1+ leaf | ported | METADATA-10 |
|
||||
| 7 | edge 1, figure 1+, thickness round-up | ported | METADATA-15 |
|
||||
| 7 | grade 1+ | superseded | METADATA-15 |
|
||||
| 8 | tags fig/feat 1+ | superseded | METADATA-30 |
|
||||
| 9 | two-call inference | ported | METADATA-35, METADATA-55 |
|
||||
| 10 | Call 1 auto only if enabled; review before Call 2 | ported | METADATA-35, METADATA-55 |
|
||||
| 11 | timeout/non-200 error + retry | ported | METADATA-115 |
|
||||
| 12 | create status default draft; SLAB-UAT force draft | ported | SETTINGS-65, PUBLISH-10 |
|
||||
| 13 | prompts as server text files | superseded | METADATA-105 |
|
||||
| 14 | brand voice + GEO into Call 2 | ported | METADATA-60, SETTINGS-30 |
|
||||
| 15 | Woo creds server-side AES-GCM | ported | SETTINGS-15, SETTINGS-20 |
|
||||
| 16 | inference endpoint configurable | ported | SETTINGS-30 |
|
||||
| 17 | test connection text + vision | ported | SETTINGS-45, SETTINGS-80 |
|
||||
| 18 | offline not required | ported | CAPTURE-15 |
|
||||
|
||||
Open items: none → dropped.
|
||||
|
||||
## Functional requirements
|
||||
|
||||
- FR1 (1–5 photos) superseded → CAPTURE-25; first top-down ported → CAPTURE-20
|
||||
- FR2 manual SKU/length/thickness → CAPTURE-70
|
||||
- FR3 border BG detect → CAPTURE-35
|
||||
- FR4 scale/sqft/bdft, old divide-by-12 wrong → CAPTURE-75, CAPTURE-85, CAPTURE-90
|
||||
- FR5 80% crop / 1600 / 3:4 → superseded CAPTURE-100, CAPTURE-105
|
||||
- FR6 retake/abort → CAPTURE-155
|
||||
- FR7 deterministic compute → CAPTURE-85, CAPTURE-90
|
||||
- FR8 thickness manual authoritative → CAPTURE-70, METADATA-50
|
||||
- FR9 deterministic unit-tested → QUALITY-5, QUALITY-15
|
||||
- FR10 sync categories/attributes/tags → SETTINGS-50
|
||||
- FR11 species 1 leaf → METADATA-10
|
||||
- FR12 wood 1+ leaf → METADATA-10
|
||||
- FR13 edge 1 → METADATA-15
|
||||
- FR14 figure 1+ → METADATA-15
|
||||
- FR15 grade 1+ → superseded METADATA-15
|
||||
- FR16 thickness round-up → METADATA-15
|
||||
- FR17 fig-* 1+ → superseded METADATA-30
|
||||
- FR18 auto-assign + manual override → METADATA-40, METADATA-45, METADATA-50
|
||||
- FR19 category-weighted pricing → superseded SETTINGS-35
|
||||
- FR20 bdft pricing → REVIEW-25
|
||||
- FR21 price editable, stored authoritative → REVIEW-25
|
||||
- FR22 Call 1 vision with confidences → METADATA-35, METADATA-40
|
||||
- FR22a threshold 0.7 → METADATA-45
|
||||
- FR22b species bias full list → METADATA-40
|
||||
- FR22c free-text → feat-* → METADATA-100
|
||||
- FR23 Call 2 user-triggered; prose only; server assembles templates → METADATA-55, METADATA-65;
|
||||
server-assembles superseded → METADATA-70, METADATA-80
|
||||
- FR24 prompts text files + reset script → superseded METADATA-105
|
||||
- FR25 progress indicator → dropped (presentation)
|
||||
- FR26 timeout/non-200 error + retry → METADATA-115
|
||||
- FR27 results editable, no auto publish → METADATA-50, METADATA-75
|
||||
- FR28 endpoint configurable + test text/vision → SETTINGS-30, SETTINGS-45, SETTINGS-80
|
||||
- FR29 chroma-key, U2Net fallback → CAPTURE-35, CAPTURE-165
|
||||
- FR30 80% / 3:4 / 1600 → superseded CAPTURE-100, CAPTURE-105
|
||||
- FR31 transparent PNG → CAPTURE-95
|
||||
- FR32 original + processed, purge after publish → PUBLISH-25, PUBLISH-40
|
||||
- FR33 consumer keys + app password → superseded SETTINGS-15
|
||||
- FR34 publish blocked on existing SKU → PUBLISH-15
|
||||
- FR35 create product fields → PUBLISH-55
|
||||
- FR36 default draft; SLAB-UAT draft → SETTINGS-65, PUBLISH-10
|
||||
- FR37 log sync, surface errors, store product id → PUBLISH-25, PUBLISH-30
|
||||
- FR38 draft slab state → APP-10
|
||||
- FR39 single review screen, all editable → REVIEW-40
|
||||
- FR40 server-side processing → superseded CAPTURE-135, APP-40
|
||||
- FR41 settings UI → SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35
|
||||
- FR42 compose on `.201` Caddy → superseded OPS-5, OPS-15, OPS-40
|
||||
- FR43 secrets encrypted, HTTPS → SETTINGS-20, SETTINGS-25, OPS-5
|
||||
|
||||
## Non-functional requirements
|
||||
|
||||
- performance budgets (1–2s, 3–5s, 45s) → dropped (server timing non-authoritative)
|
||||
- reliability: unit-tested → QUALITY-5, QUALITY-15
|
||||
- security: encrypted/HTTPS → SETTINGS-20, SETTINGS-25, OPS-5
|
||||
- maintainability separation → dropped
|
||||
- usability one-hand → dropped
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- E2E capture→publish on iPhone/Android → QUALITY-115
|
||||
- area accuracy +/- 0.25 in² → dropped (sqin banned)
|
||||
- normalization 80% / PNG / 1600 → superseded CAPTURE-100, CAPTURE-105
|
||||
- Woo product images/taxonomy/content → PUBLISH-40, PUBLISH-45, PUBLISH-55
|
||||
- deterministic unit tests + inference logged → QUALITY-5, METADATA-66
|
||||
@@ -1,84 +0,0 @@
|
||||
# Coverage matrix — DATA-MODEL, PROMPTS, TECH-SPEC-PIPELINE, DEPLOYMENT, IMPL-PLAN
|
||||
|
||||
Sources: the five tag docs at `pre-simplification`.
|
||||
|
||||
## Findings
|
||||
|
||||
None new. The remaining entries are already ported, superseded by prior rulings, or process
|
||||
detail. No new requirement is missing.
|
||||
|
||||
## Superseded (common to several)
|
||||
|
||||
- four knobs including edge offset and feather → CAPTURE-40
|
||||
- "1–5 photos" / "1–N photos" cap → CAPTURE-25
|
||||
- 80% fill / 1600 px floor → CAPTURE-100, CAPTURE-105
|
||||
- grade "1+" / `grade_term_ids` plural → METADATA-15 (exactly one, singular)
|
||||
- `fig-*` 1+ → METADATA-30 (optional)
|
||||
- `inference_thread_id`, portable context, `previous_response_id`, per-slab thread → thread work
|
||||
out
|
||||
- prompt files on disk, reset script, "prompts are server files" → METADATA-105 (DB with revisions)
|
||||
- `output_px` / `output_px_min` 1600, `fill_target` 0.80 → CAPTURE-100, CAPTURE-105
|
||||
- validation module rules in Call 2 → module cut
|
||||
- em dash title example → hyphen separator
|
||||
|
||||
## DATA-MODEL.md
|
||||
|
||||
- settings keys (`woo_wp_username`, `woo_app_password`, inference keys/flags, brand/GEO,
|
||||
`woo_create_status` draft, taxonomy stamps) → SETTINGS-20, SETTINGS-30, SETTINGS-55, SETTINGS-65
|
||||
- `WOO_BASE_URL` not a settings row → SETTINGS-5; `SLAB_AES_KEY` env, `value_enc` nonce||ciphertext
|
||||
→ SETTINGS-25
|
||||
- taxonomy columns and cardinalities → METADATA-10, METADATA-15, METADATA-30
|
||||
- `sku`, `price`, `price_source`, `content_source` → REVIEW-45, REVIEW-25, METADATA-66
|
||||
- status machine, transitions, `sync_log.status` → APP-5, PUBLISH-30
|
||||
- published deletes row + photos, keeps sync_log → PUBLISH-25
|
||||
- IDs/timestamps/rounding conventions → APP-20, CAPTURE-160
|
||||
- `sync_log.payload_hash`, `woo_product_id`, `error`, `http_status` → contract detail
|
||||
- `inference_log` append-only, `call` 1|2 → METADATA-66
|
||||
- `client_rev`/`server_rev` default 1 → APP-20
|
||||
|
||||
## PROMPTS.md
|
||||
|
||||
- `inference_enabled`, `content_llm_enabled` gates → SETTINGS-90, METADATA-35, METADATA-70
|
||||
- Call 1 purpose, inputs, synced-only constraint, schema, threshold, prefill, manual-wins,
|
||||
ready-not-defined, inference-off → METADATA-5, METADATA-35, METADATA-40, METADATA-45,
|
||||
METADATA-50, METADATA-75
|
||||
- free-text → feat-* mapping → METADATA-100
|
||||
- Call 2 purpose, prose-only, no dimensions, `content_llm_enabled` false → METADATA-55,
|
||||
METADATA-65, METADATA-70
|
||||
- brand/GEO + templates → SETTINGS-30, contract detail
|
||||
- fail closed → SETTINGS-45, METADATA-115
|
||||
- re-run Call 1 endpoint → API contract
|
||||
|
||||
## TECH-SPEC-PIPELINE.md
|
||||
|
||||
- sheet/sensitivity knobs, persistence, immediate re-run, confirm, rotate, nearly square →
|
||||
CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-60, CAPTURE-65, CAPTURE-145, CAPTURE-150
|
||||
- mask single contour, retake → CAPTURE-55
|
||||
- scale/sqft/bdft/widths/price formulas → CAPTURE-75, CAPTURE-80, CAPTURE-85, CAPTURE-90,
|
||||
REVIEW-25
|
||||
- no-rule empty price, publish allowed → REVIEW-25
|
||||
- unsupported client hard stop, client APIs → CAPTURE-10, CAPTURE-140
|
||||
- factory constants (hue/sat/val, black threshold, border strip, inch step, decimals) → contract
|
||||
detail (client constants), CAPTURE-35, CAPTURE-41
|
||||
- no inference in scope → CAPTURE-5
|
||||
|
||||
## DEPLOYMENT.md
|
||||
|
||||
- `SLAB_AES_KEY`, `WOO_BASE_URL` env, creds encrypted → SETTINGS-25, SETTINGS-5, SETTINGS-20
|
||||
- `woo_create_status` draft, UAT draft → SETTINGS-65, PUBLISH-10
|
||||
- health, test-woo on rotate, inference remote → APP-45, SETTINGS-40, OPS-45
|
||||
- host inventory, ports, LAN/Tailscale, Caddy HTTPS, env mode → OPS-15, OPS-5, OPS-20
|
||||
- backup 7 daily / 4 weekly → OPS-30
|
||||
- probes/ports proposal, rekey command, backup schedule → dropped (operational detail)
|
||||
- `output_px_min` floor line → superseded CAPTURE-105
|
||||
|
||||
## IMPL-PLAN.md
|
||||
|
||||
- ground rules, inference-toggle suite, status machine → QUALITY-5, QUALITY-10, QUALITY-20, APP-5
|
||||
- phase deliverables restate CAPTURE/REVIEW/METADATA/PUBLISH/SETTINGS rows
|
||||
- `woo_create_status`, dedupe, stale recovery, sync_log/purge, partial failure → PUBLISH-10,
|
||||
PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-31
|
||||
- Woo client basic auth, no consumer keys → SETTINGS-15
|
||||
- E2E browsers, no OCR/ruler/offline → QUALITY-115, CAPTURE-15
|
||||
- coverage ≥95%, specific UAT slab set, LoRA, "no processing status" → dropped (process/quality
|
||||
targets, not product requirements)
|
||||
@@ -1,109 +0,0 @@
|
||||
# Coverage matrix — TEST-SPEC.md
|
||||
|
||||
Source: `docs/TEST-SPEC.md` at tag `pre-simplification` (107 cases). Reconciled last, against the
|
||||
finished PoR.
|
||||
|
||||
## Findings
|
||||
|
||||
Two behaviors were tested but not required; both are now PoR rows:
|
||||
|
||||
- FUND-3, FUND-5, FUND-6, FUND-7 (keyboard tab order, visible focus ring, 320 px reflow) → `APP-60`
|
||||
- FUND-24 (no secret material in logs) → `APP-65`
|
||||
|
||||
## Superseded cases to purge on migration
|
||||
|
||||
- UI-1, UIE-9, UIE-10 — four knobs (edge offset, feather) → two controls
|
||||
- V-3, API-8 — shared validation module, version hash, 412
|
||||
- C2-2 — `previous_response_id` / per-slab thread
|
||||
- C1-1 — "inventory photos at 1024" → clean processed photos
|
||||
- C2-4 — prompt file edits without restart → prompts live in the DB
|
||||
- TV-6, TV-7, E2E-5 — 80 percent fill / 1600 px → 90 percent / 800 px
|
||||
- UIE-1, E2E-4 — manual override of computed fields → read-only
|
||||
|
||||
## Case map
|
||||
|
||||
FUND
|
||||
- FUND-1..4, 8..11 — test conventions → dropped (test practice, not requirements)
|
||||
- FUND-3, 5, 6, 7 — accessibility → APP-60
|
||||
- FUND-12, 13, 16 — SQLite constraints/migrations → dropped (engineering practice)
|
||||
- FUND-14 — WAL stale reads → dropped (engineering practice)
|
||||
- FUND-15 — illegal status transitions rejected → APP-5
|
||||
- FUND-17 — fresh nonce, tamper fails → SETTINGS-25
|
||||
- FUND-18 — ciphertext absent from GET → SETTINGS-75
|
||||
- FUND-19 — missing key refuses startup → SETTINGS-25
|
||||
- FUND-20 — Basic auth app passwords, HTTPS → SETTINGS-15
|
||||
- FUND-21 — 401/400/404 mapped → APP-50
|
||||
- FUND-22 — pre-create SKU lookup, race → PUBLISH-15
|
||||
- FUND-23 — timeouts/network errors graceful, no stack → PUBLISH-30, PUBLISH-35
|
||||
- FUND-24 — secrets redacted in logs → APP-65
|
||||
- FUND-25 — HTTPS, health via proxy, port collision → OPS-5, OPS-15, OPS-20
|
||||
|
||||
TV
|
||||
- TV-1..TV-10 → QUALITY-55..QUALITY-100 (TV-6/TV-7 numeric values superseded)
|
||||
|
||||
Validation
|
||||
- V-1 — SKU charset → REVIEW-45
|
||||
- V-2 — title/description limits → REVIEW-50
|
||||
- V-3 — module hash / 412 → dropped
|
||||
|
||||
AES / health / draft
|
||||
- AES-1 → QUALITY-125, SETTINGS-75
|
||||
- AES-2 → SETTINGS-25
|
||||
- H-1 → APP-45
|
||||
- D-1 → API contract, APP-5
|
||||
|
||||
Gate C / Woo
|
||||
- GC-1 → PUBLISH-10; GC-2 → PUBLISH-15; GC-3 → SETTINGS-40; GC-4 → SETTINGS-85; GC-5 → SETTINGS-10
|
||||
- W-1 → PUBLISH-25; W-2 → PUBLISH-15; W-3 → SETTINGS-55; W-4 → SETTINGS-40; W-5 → PUBLISH-30
|
||||
|
||||
Inference
|
||||
- INF-1 → METADATA-75, QUALITY-20
|
||||
- INF-2, INF-3 → SETTINGS-45 (mode is kept)
|
||||
- INF-4 → SETTINGS-45
|
||||
|
||||
UI (capture)
|
||||
- UI-1 → superseded CAPTURE-40
|
||||
- UI-2 → CAPTURE-50, CAPTURE-55, CAPTURE-165
|
||||
- UI-3 → CAPTURE-60, CAPTURE-65
|
||||
- UI-4 → CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90
|
||||
- UI-5 → REVIEW-5, REVIEW-20
|
||||
|
||||
Ops
|
||||
- OPS-1 → APP-45; OPS-2 → OPS-15; OPS-3 → SETTINGS-40, SETTINGS-65, APP-45; OPS-4 → OPS-30;
|
||||
OPS-5 → OPS-35
|
||||
|
||||
API
|
||||
- API-1 → APP-20; API-2 → APP-40; API-3 → SETTINGS-75; API-4 → METADATA-35, METADATA-115;
|
||||
API-5 → METADATA-45; API-6 → REVIEW-5; API-7 → SETTINGS-50, SETTINGS-55; API-8 → dropped;
|
||||
API-9 → API contract; API-10 → SETTINGS-80
|
||||
- TAX-1 → SETTINGS-60; TAX-2 → METADATA-5; TAX-3 → SETTINGS-60
|
||||
|
||||
UI end-to-end
|
||||
- UIE-1 → superseded REVIEW-30, REVIEW-40
|
||||
- UIE-2 → METADATA-45, REVIEW-20
|
||||
- UIE-3 → PUBLISH-15; UIE-4 → PUBLISH-20; UIE-5 → PUBLISH-25; UIE-6 → PUBLISH-30
|
||||
- UIE-7 → METADATA-70, METADATA-110
|
||||
- UIE-8 → SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65
|
||||
- UIE-9, UIE-10 → superseded CAPTURE-40, CAPTURE-45, CAPTURE-145
|
||||
|
||||
Calls
|
||||
- C1-1 → superseded METADATA-35 (clean processed photos)
|
||||
- C1-2, C1-3 → METADATA-45, REVIEW-5
|
||||
- C2-1 → METADATA-55, METADATA-60
|
||||
- C2-2 → superseded (thread work out)
|
||||
- C2-3 → METADATA-65
|
||||
- C2-4 → METADATA-66, METADATA-105
|
||||
- OFF-1 → QUALITY-20; OFF-2 → SETTINGS-45
|
||||
|
||||
Instrumentation
|
||||
- INS-1 → APP-45; INS-2 → APP-55; INS-3 → METADATA-66; INS-4 → PUBLISH-30
|
||||
|
||||
E2E
|
||||
- E2E-1..3 → QUALITY-115
|
||||
- E2E-4 → superseded REVIEW-30, REVIEW-40
|
||||
- E2E-5 → superseded CAPTURE-100, CAPTURE-105
|
||||
|
||||
## Test harness (contract detail)
|
||||
|
||||
Harness layout, allowed result values, evidence rules, and the Gitea-issue bug log are engineering
|
||||
practice. They belong in `docs/contracts/TEST-SPEC.md`, not the PoR.
|
||||
@@ -1,217 +0,0 @@
|
||||
# Coverage matrix — UX-DESIGN.md (pilot)
|
||||
|
||||
Purpose: prove every entry on a reference surface in the source doc has a home in the PoR.
|
||||
Source: `docs/UX-DESIGN.md` at tag `pre-simplification` (non-normative wireframes).
|
||||
Method: subagent extracted every entry mechanically; this file assigns the PoR home and disposition.
|
||||
Dispositions: `ported`, `superseded`, `dropped`, `ui` (presentation-only, not a requirement),
|
||||
`missing` (a finding).
|
||||
|
||||
Pilot proof: the three entries fixed in SETTINGS-45 / SETTINGS-90 / METADATA-110 are marked
|
||||
`ported` below. Before that fix they had no home, which is exactly what this matrix surfaces.
|
||||
|
||||
## SETTINGS KEYS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Store URL field | SETTINGS-5 | ported | env, read-only |
|
||||
| WP Username field | SETTINGS-20 | ported | — |
|
||||
| Application Password field | SETTINGS-20 | ported | — |
|
||||
| Inference Endpoint field | SETTINGS-30 | ported | — |
|
||||
| Inference API Key field | SETTINGS-30 | ported | — |
|
||||
| Enable Vision toggle, default off | SETTINGS-90 | ported | fixed |
|
||||
| Enable Content toggle, default off | SETTINGS-90 | ported | fixed |
|
||||
| Pricing Rules | SETTINGS-35 | ported | — |
|
||||
| Publish Status dropdown, default Draft | SETTINGS-65 | ported | — |
|
||||
| Brand Voice field | SETTINGS-30 | ported | — |
|
||||
| GEO Context field | SETTINGS-30 | ported | — |
|
||||
| Sheet dropdown | CAPTURE-40 | ported | — |
|
||||
| Edge Offset control | CAPTURE-40 | superseded | removed |
|
||||
| Feather control | CAPTURE-40 | superseded | removed |
|
||||
| Knob persistence localStorage | CAPTURE-145 | ported | — |
|
||||
|
||||
## CONTROLS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Test Connection (WP) | SETTINGS-40, SETTINGS-80 | ported | resync and failure |
|
||||
| Test Connection (Inference) two-turn probe, stateful/stateless/fail | SETTINGS-45 | ported | fixed |
|
||||
| Enable Vision / Enable Content buttons | SETTINGS-90 | ported | — |
|
||||
| Add Rule / Edit (pricing) | SETTINGS-35 | ported | — |
|
||||
| Reset Capture Knobs | CAPTURE-145 | ported | — |
|
||||
| Refresh Store Lists | SETTINGS-60 | ported | — |
|
||||
| Got it (first-run card) | — | ui | presentation |
|
||||
| R retake shortcut | CAPTURE-50 | ported | — |
|
||||
| Sheet dropdown | CAPTURE-40 | ported | — |
|
||||
| Sensitivity slider | CAPTURE-41, CAPTURE-45 | ported | — |
|
||||
| Edge Offset slider | CAPTURE-40 | superseded | removed |
|
||||
| Feather slider | CAPTURE-40 | superseded | removed |
|
||||
| Retake (capture) | CAPTURE-50, CAPTURE-155 | ported | — |
|
||||
| Confirm (capture) | CAPTURE-50 | ported | — |
|
||||
| Rotate (axis) | CAPTURE-60, CAPTURE-65 | ported | — |
|
||||
| Confirm (axis) | CAPTURE-60 | ported | — |
|
||||
| SKU input | REVIEW-45 | ported | — |
|
||||
| Length input | CAPTURE-70 | ported | — |
|
||||
| Thickness input | CAPTURE-70 | ported | — |
|
||||
| Done (type values) | APP-5 | ported | status calibrated |
|
||||
| X exit review | APP-10 | ported | — |
|
||||
| Species dropdown | METADATA-10 | ported | — |
|
||||
| Wood Cat dropdown | METADATA-10 | ported | — |
|
||||
| Edge Type dropdown | METADATA-15 | ported | — |
|
||||
| Figure dropdown | METADATA-15 | ported | — |
|
||||
| Grade dropdown | METADATA-15 | ported | cardinality superseded |
|
||||
| Fig Tags dropdown | METADATA-30 | ported | cardinality superseded |
|
||||
| Feat Tags dropdown | METADATA-30 | ported | — |
|
||||
| Thickness Band dropdown | METADATA-15 | ported | — |
|
||||
| Moisture dropdown | METADATA-15 | ported | — |
|
||||
| Price input | REVIEW-25 | ported | — |
|
||||
| Review Title / Desc button | REVIEW-10 | ported | — |
|
||||
| Publish button disabled until ready | REVIEW-20 | ported | — |
|
||||
| Title input | REVIEW-10 | ported | — |
|
||||
| Short Title input | REVIEW-10 | ported | naming migrated |
|
||||
| Description input | REVIEW-10 | ported | — |
|
||||
| Generate Text button, availability | METADATA-110 | ported | fixed |
|
||||
| Done (title/desc) | REVIEW-40 | ported | — |
|
||||
| Open in Store | — | ui | candidate: store link is presentation |
|
||||
| Done (publish success) | PUBLISH-25 | ported | — |
|
||||
| Retry (publish failed) | PUBLISH-30, PUBLISH-37 | ported | — |
|
||||
| Back to Review | PUBLISH-30 | ported | retry from same screen |
|
||||
| Retake (mask failure) | CAPTURE-50, CAPTURE-55 | ported | — |
|
||||
| Rotate (axis check) | CAPTURE-60 | ported | — |
|
||||
| Retake (source too small) | CAPTURE-105 | ported | — |
|
||||
| Edit SKU | PUBLISH-15 | ported | — |
|
||||
| Open Existing | PUBLISH-15 | ported | — |
|
||||
| Refresh Store Lists (stale) | PUBLISH-20 | ported | — |
|
||||
| Retry / Open Settings (unreachable) | PUBLISH-30, PUBLISH-36, PUBLISH-38 | ported | — |
|
||||
| Update Browser | CAPTURE-10 | ported | — |
|
||||
|
||||
## LISTING FIELDS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| SKU | REVIEW-45, PUBLISH-15 | ported | — |
|
||||
| Length | CAPTURE-70 | ported | — |
|
||||
| Thickness | CAPTURE-70 | ported | — |
|
||||
| sqft (computed) | CAPTURE-85 | ported | — |
|
||||
| bdft (computed) | CAPTURE-90 | ported | — |
|
||||
| widths (computed) | CAPTURE-80 | ported | — |
|
||||
| Photos, "at least one inventory PNG" | REVIEW-5 | superseded | ready set is two images |
|
||||
| Species exactly one | METADATA-10 | ported | — |
|
||||
| Wood category 1+ | METADATA-10 | ported | — |
|
||||
| Edge type one | METADATA-15 | ported | — |
|
||||
| Figure 1+ | METADATA-15 | ported | — |
|
||||
| Fig tags 1+ | METADATA-30 | superseded | tags optional |
|
||||
| Feat tags 0+ | METADATA-30 | ported | — |
|
||||
| Grade 1+ | METADATA-15 | superseded | grade exactly one |
|
||||
| Thickness Band | METADATA-15 | ported | — |
|
||||
| Moisture | METADATA-15 | ported | — |
|
||||
| Price | REVIEW-25 | ported | — |
|
||||
| Title | REVIEW-10 | ported | — |
|
||||
| Short Title | REVIEW-10 | ported | naming migrated |
|
||||
| Description | REVIEW-10 | ported | — |
|
||||
| Store product ID | PUBLISH-25 | ported | display is presentation |
|
||||
|
||||
## STATUSES
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Publish Status Draft | SETTINGS-65 | ported | — |
|
||||
| calibrated | APP-5 | ported | — |
|
||||
| publishing | APP-5 | ported | — |
|
||||
| published | APP-5, PUBLISH-50 | ported | — |
|
||||
| failed | APP-5 | ported | — |
|
||||
| Woo product draft | PUBLISH-10 | ported | — |
|
||||
| SKU "any status" | PUBLISH-15 | ported | — |
|
||||
| probe stateful/stateless/fail | SETTINGS-45 | ported | fixed |
|
||||
|
||||
## ENDPOINTS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| WP REST v3 auth | SETTINGS-15, SETTINGS-80 | ported | — |
|
||||
| Inference endpoint | SETTINGS-30 | ported | — |
|
||||
| Inference endpoint reachability | METADATA-110, METADATA-75 | ported | fixed |
|
||||
|
||||
## ERROR CODES
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| green check / red error | SETTINGS-80 | ported | — |
|
||||
| stateful/stateless/fail | SETTINGS-45 | ported | fixed |
|
||||
|
||||
## USER-FACING ERROR / EMPTY STATES
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Mask failure "Cannot isolate the slab edge..." | CAPTURE-55 | ported | — |
|
||||
| Axis misalignment "Rotate the axis..." | CAPTURE-60 | ported | — |
|
||||
| Source too small "Photo will be too small..." | CAPTURE-105 | ported | — |
|
||||
| SKU duplicate "This SKU already exists..." | PUBLISH-15 | ported | — |
|
||||
| Stale taxonomy "This value is no longer valid..." | PUBLISH-20, REVIEW-35 | ported | — |
|
||||
| Store unreachable "Cannot reach the store..." | PUBLISH-30, PUBLISH-36 | ported | — |
|
||||
| Publish failed "The store rejected the listing..." | PUBLISH-30, PUBLISH-35 | ported | — |
|
||||
| Publishing in progress | PUBLISH-5 | ported | — |
|
||||
| Publish success | PUBLISH-25 | ported | — |
|
||||
| Browser gate "update Safari or Chrome" | CAPTURE-10 | superseded | matrix includes PC |
|
||||
| Species/Figure/Wood/Edge nudges | REVIEW-20, METADATA-10, METADATA-15 | ported | — |
|
||||
| Generic inline nudge | REVIEW-20 | ported | — |
|
||||
| First-run guidance text | — | ui | presentation |
|
||||
| Vision-off empty taxonomy | METADATA-45, METADATA-70 | ported | — |
|
||||
|
||||
## INFERENCE
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Call 1 flag default off | SETTINGS-90, METADATA-35 | ported | fixed |
|
||||
| Call 2 flag default off | SETTINGS-90, METADATA-55 | ported | fixed |
|
||||
| Generate Text triggers Call 2 | METADATA-55 | ported | — |
|
||||
| Pre-populate threshold 0.7 | METADATA-45 | ported | — |
|
||||
| Below-threshold leaves empty | METADATA-45 | ported | — |
|
||||
| Below-threshold manual fill to ready | REVIEW-5, REVIEW-20 | ported | — |
|
||||
| Two-turn probe stateful/stateless/fail | SETTINGS-45 | ported | fixed |
|
||||
| AI badge on Call 1 fields | — | ui | presentation |
|
||||
| Generate Text availability | METADATA-110 | ported | fixed |
|
||||
| Call 2 prose only, templates numbers | METADATA-65, METADATA-70 | ported | — |
|
||||
| Vision off state | METADATA-70 | ported | — |
|
||||
|
||||
## PUBLISH PAYLOAD / FLOW
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Publish action, poll | PUBLISH-5 | ported | — |
|
||||
| Draft creation | PUBLISH-10 | ported | — |
|
||||
| SLAB-UAT-* always draft | PUBLISH-10 | ported | — |
|
||||
| Publish Status default draft | SETTINGS-65 | ported | — |
|
||||
| Duplicate SKU behavior | PUBLISH-15 | ported | — |
|
||||
| Stale taxonomy behavior | PUBLISH-20 | ported | — |
|
||||
| Success result, open in store | PUBLISH-25 | ported | link is presentation |
|
||||
| Failure behavior, keep draft, retry | PUBLISH-30 | ported | — |
|
||||
| Product ID stored, images purged | PUBLISH-25 | ported | — |
|
||||
| Publish button gating | REVIEW-20 | ported | — |
|
||||
| Submit-time validation after all fields | REVIEW-20 | superseded | per-field nudges, no submit list |
|
||||
| Taxonomy freshness on test | SETTINGS-40 | ported | — |
|
||||
|
||||
## CONSTANTS / NUMBERS
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| SKU pattern `[A-Z0-9-]{3,24}` | REVIEW-45 | ported | case widened |
|
||||
| Length / thickness 1/8 step | CAPTURE-70 | ported | — |
|
||||
| Confidence threshold 0.7 | METADATA-45 | ported | — |
|
||||
| Width sample every 6 inches | CAPTURE-80 | ported | — |
|
||||
| Wireframe width 375px | — | ui | sketch detail |
|
||||
| Sample values (sqft 0.64, bdft 0.96, widths, price, id) | — | ui | examples |
|
||||
| Mandatory counts list | REVIEW-5 | ported | grade/fig superseded |
|
||||
| Optional feat-* zero fine | METADATA-30 | ported | — |
|
||||
| Photo thumbnail count 5 | CAPTURE-25 | superseded | no cap |
|
||||
| SLAB-UAT-* pattern | PUBLISH-10 | ported | — |
|
||||
|
||||
## BROWSER / API SUPPORT
|
||||
|
||||
| Entry | PoR | Disposition | Note |
|
||||
|---|---|---|---|
|
||||
| Gate on missing APIs | CAPTURE-10 | ported | — |
|
||||
| Supported browsers | CAPTURE-10 | ported | matrix worded phone-only, superseded |
|
||||
| Hard-stop screen | CAPTURE-10 | ported | — |
|
||||
| Client-side mask re-run | CAPTURE-140, CAPTURE-150 | ported | — |
|
||||
| Camera capture | CAPTURE-20, CAPTURE-25 | ported | — |
|
||||
| localStorage | CAPTURE-145 | ported | — |
|
||||
@@ -1,12 +0,0 @@
|
||||
# Coverage ledger — prd
|
||||
|
||||
Source `prd` (`docs/archive/PRD-2026-08.txt`, archived). Seeded by the settings/auth/health
|
||||
pilot 2026-09-18. The archived PRD is walked in full later in the order.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| prd | decision-15 | Woo credentials are server-side only, never in the browser | ported | SETTINGS-15, SETTINGS-20 | — |
|
||||
| prd | decision-16 | inference endpoint is configurable: base URL, key, model | ported | SETTINGS-30 | — |
|
||||
| prd | decision-17 | test connection verifies text and vision support | ported | SETTINGS-80, SETTINGS-45 | — |
|
||||
| prd | no-app-auth | no app-level auth; HTTPS plus trusted network | ported | OPS-55 | — |
|
||||
| prd | FR41 | Settings UI for inference, Woo creds, pricing, brand/GEO, prompts | ported | SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35 | prompts are post-M3, audit section 11 |
|
||||
@@ -1,32 +0,0 @@
|
||||
# Coverage ledger — PRODUCT.md
|
||||
|
||||
Source `cur:PRODUCT` (`docs/PRODUCT.md`, role: intent). Walked 2026-09-18 section by section
|
||||
against the PoR. Term ruling: the umbrella "Woo attributes" package is renamed **Woo Slab
|
||||
Taxonomy**; canonical Woo attributes (the five) keep their name.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| cur:PRODUCT | one-slab-one-listing | one slab is one unit and one listing | ported | PUBLISH-60 | — |
|
||||
| cur:PRODUCT | photos-metadata-inventory | photos and metadata are the inventory record | dropped | — | intent framing; realized by CAPTURE/PHOTOS/CREATE, not a discrete rule |
|
||||
| cur:PRODUCT | green-black-sheet | photographs taken on a green or black sheet | ported | CAPTURE-20 | — |
|
||||
| cur:PRODUCT | inventory-png-3-4-transparent | inventory photo(s), uniform 3:4 transparent PNG | ported | CAPTURE-95, CAPTURE-100 | — |
|
||||
| cur:PRODUCT | listing-fields-user-owned | SKU, length, thickness, price | ported | CAPTURE-70, REVIEW-25 | SKU has no standalone row; folded into PUBLISH-5/PUBLISH-15 |
|
||||
| cur:PRODUCT | taxon-wood-attributes | species, wood category, edge, figure, grade | ported | METADATA-10, METADATA-15 | — |
|
||||
| cur:PRODUCT | thickness-band-moisture | thickness band, moisture | ported | METADATA-15 | — |
|
||||
| cur:PRODUCT | fig-tags-cardinality | one or more `fig-*` tags | superseded | METADATA-30 | source says one or more; ruled optional, Figure attribute is at least one |
|
||||
| cur:PRODUCT | title-shorttitle-description | title, short title, description | ported | REVIEW-10 | "short title" is stale wording for short_description |
|
||||
| cur:PRODUCT | feat-tags-optional | zero or more `feat-*` tags | ported | METADATA-30 | — |
|
||||
| cur:PRODUCT | woo-slab-taxonomy-term | "Woo attributes" locked package name | ported | METADATA-5 | renamed Woo Slab Taxonomy; umbrella rule lives in METADATA-5 |
|
||||
| cur:PRODUCT | measured-values-templates | measured values feed title and description templates | superseded | METADATA-60, METADATA-70, METADATA-80 | template is the fallback; the model writes when Call 2 runs |
|
||||
| cur:PRODUCT | decision-rights-table | decision rights for mask/crop, measures, taxonomy, price, prose | ported | CAPTURE-50, CAPTURE-80, CAPTURE-85, CAPTURE-90, CAPTURE-100, METADATA-40, METADATA-45, REVIEW-25, REVIEW-40 | row-for-row mapping; L37 carries the template conflict |
|
||||
| cur:PRODUCT | override-precedence | user entries override computed or inferred values | ported | METADATA-50 | — |
|
||||
| cur:PRODUCT | milestones-1-2-browser-only | M1/M2 browser-only, no backend or inference URL | ported | APP-25, OPS-45 | — |
|
||||
| cur:PRODUCT | m1-handoff-device-draft | M1 handoff is the device draft contents | ported | APP-10 | — |
|
||||
| cur:PRODUCT | m2-extends-draft | M2 batch, calibration, per-photo PNGs, overlay copy | ported | CAPTURE-25, CAPTURE-110, CAPTURE-115, CAPTURE-130 | — |
|
||||
| cur:PRODUCT | m3-fastapi-client | M3 FastAPI Woo client and inference proxy, secrets server-side | ported | APP-25, SETTINGS-15, SETTINGS-20, SETTINGS-30 | — |
|
||||
| cur:PRODUCT | slab-uat-forces-draft | `SLAB-UAT-*` creates a Woo draft | ported | PUBLISH-10 | — |
|
||||
| cur:PRODUCT | duplicate-sku-blocks | duplicate SKU blocks create | ported | PUBLISH-15 | — |
|
||||
| cur:PRODUCT | create-inference-off | a listing can be created with inference off | ported | METADATA-70, METADATA-75, QUALITY-20 | — |
|
||||
| cur:PRODUCT | device-draft-not-woo-draft | device draft is separate from a WooCommerce draft | ported | APP-10 | — |
|
||||
| cur:PRODUCT | milestone-status-table | milestone status table, UAT on `:10881` | ported | OPS-40 | only host/port is testable; rest navigation; branch-neutral edit queued |
|
||||
| cur:PRODUCT | out-of-m3-scope | anything beyond CREATE is out of M3 | dropped | — | scope prose, not a requirement; MILESTONES.md owns scope |
|
||||
@@ -1,62 +0,0 @@
|
||||
# Coverage ledger — tag:ARCHITECTURE
|
||||
|
||||
Source `tag:ARCHITECTURE` (`docs/ARCHITECTURE.md` at tag `pre-simplification`). Seeded by the
|
||||
settings/auth/health pilot 2026-09-18.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:ARCHITECTURE | s5-server-secrets | Woo creds, inference key, AES key, WOO_BASE_URL are server-side only | ported | SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30 | — |
|
||||
| tag:ARCHITECTURE | s5-client-prefs | sheet and sensitivity preferences persist in localStorage | ported | CAPTURE-145 | — |
|
||||
| tag:ARCHITECTURE | s7-taxonomy | last_sync_at, anchor, and sync triggers | ported | SETTINGS-50, SETTINGS-55, SETTINGS-60 | — |
|
||||
| tag:ARCHITECTURE | s3-woo-only-fastapi | Woo is called only by FastAPI | ported | APP-25 | — |
|
||||
| tag:ARCHITECTURE | topology-hybrid | phone happy-path; server stores/proxies; reverse proxy HTTPS | ported | CAPTURE-5, APP-25, OPS-5 | — |
|
||||
| tag:ARCHITECTURE | u2net-deferred | server U2Net deferred from POC, not deleted | ported | CAPTURE-165 | — |
|
||||
| tag:ARCHITECTURE | keys-server-side | Woo and LLM keys stay on the server; browser never calls Woo or vision | ported | APP-25, SETTINGS-15, SETTINGS-20, SETTINGS-30 | — |
|
||||
| tag:ARCHITECTURE | new-wood-1-5-photos | new wood: 1-5 photos | superseded | CAPTURE-25 | PoR is one or more; no cap in code |
|
||||
| tag:ARCHITECTURE | bg-four-knobs | sheet / sensitivity / edge / feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
|
||||
| tag:ARCHITECTURE | length-axis-confirm | length axis overlay, rotate/confirm | ported | CAPTURE-60, CAPTURE-65 | — |
|
||||
| tag:ARCHITECTURE | user-length-thickness-sku | user length, thickness, SKU | ported | CAPTURE-70 | — |
|
||||
| tag:ARCHITECTURE | client-measure-png | sqft, bdft, 6in widths, 3:4 PNGs | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90, CAPTURE-95, CAPTURE-100 | — |
|
||||
| tag:ARCHITECTURE | call1-prefill | review: Call 1 pre-fill or manual taxonomy | ported | METADATA-35, METADATA-40, METADATA-45 | — |
|
||||
| tag:ARCHITECTURE | generate-text-tap | tap Generate text | ported | METADATA-55 | — |
|
||||
| tag:ARCHITECTURE | call2-proxy-text | Call 2 proxy title, description, short description | ported | METADATA-55 | — |
|
||||
| tag:ARCHITECTURE | happy-path-phone | happy path stays on phone through PNG and numbers; FastAPI idle until confirmed mask | ported | CAPTURE-5, CAPTURE-135 | — |
|
||||
| tag:ARCHITECTURE | mask-knobs-client | mask knobs on capture; each change re-runs client BG removal; U2Net deferred | ported | CAPTURE-45, CAPTURE-150, CAPTURE-165 | — |
|
||||
| tag:ARCHITECTURE | draft-upload-delete | draft upload sends originals, PNGs, numbers; delete after publish | ported | CAPTURE-135, PUBLISH-25, PUBLISH-40 | — |
|
||||
| tag:ARCHITECTURE | call1-auto-body | Call 1 server-side, auto when enabled, 1024 + synced taxonomy, 45s, 0.7 | ported | METADATA-35, METADATA-40, METADATA-45 | — |
|
||||
| tag:ARCHITECTURE | call2-no-auto | Call 2 does not auto-fire; user taps Generate text | ported | METADATA-55 | — |
|
||||
| tag:ARCHITECTURE | call2-thread-context | portable context, previous_response_id, per-slab thread | superseded | — | thread ids pushed out of MVP; Call 2 is a fresh call |
|
||||
| tag:ARCHITECTURE | woo-only-409-422 | Woo only via FastAPI; duplicate SKU 409 on field; stale value 422 on field; draft stays | ported | APP-25, PUBLISH-15, PUBLISH-20, PUBLISH-30 | — |
|
||||
| tag:ARCHITECTURE | settings-server | settings on the server; store URL env, read-only | ported | SETTINGS-5, SETTINGS-30, SETTINGS-65 | — |
|
||||
| tag:ARCHITECTURE | what-runs-where-table | what runs where | duplicate | CAPTURE-5, APP-25 | restates the topology and swimlane rows |
|
||||
| tag:ARCHITECTURE | s5s1-basic-auth | WordPress Basic auth, app password, AES-GCM, not consumer keys | ported | SETTINGS-15, SETTINGS-20 | — |
|
||||
| tag:ARCHITECTURE | s5s2-inference-key | inference base URL and key encrypted; server proxies | ported | SETTINGS-30, APP-25 | — |
|
||||
| tag:ARCHITECTURE | s5s3-aes-key | AES master key env, never in the DB | ported | SETTINGS-25 | — |
|
||||
| tag:ARCHITECTURE | s5s4-woo-url | WOO_BASE_URL env, HTTPS, not user-editable, read-only | ported | SETTINGS-5, SETTINGS-10 | — |
|
||||
| tag:ARCHITECTURE | s5b2-species-rate | species list with $/bdft read from server settings sync | ported | SETTINGS-35 | — |
|
||||
| tag:ARCHITECTURE | s5b3-taxonomy-snapshot | taxonomy snapshot read from server, no auth | ported | SETTINGS-50 | — |
|
||||
| tag:ARCHITECTURE | s5f1-deploy-setup | deploy sets WOO_BASE_URL; operator creates low-priv WP user | ported | SETTINGS-5, SETTINGS-15 | — |
|
||||
| tag:ARCHITECTURE | s5f2-enter-creds | user enters credentials in Settings; URL read-only | ported | SETTINGS-20 | — |
|
||||
| tag:ARCHITECTURE | s5f3-encrypt | browser POSTs credentials; server encrypts AES-GCM, stores ciphertext | ported | SETTINGS-20 | — |
|
||||
| tag:ARCHITECTURE | s5f4-sync-cache | server syncs taxonomy, caches in SQLite | ported | SETTINGS-50 | — |
|
||||
| tag:ARCHITECTURE | s5f5-client-read | client reads taxonomy, no auth | ported | SETTINGS-50 | — |
|
||||
| tag:ARCHITECTURE | s5f6-server-woo | all Woo calls go server-to-Woo | ported | APP-25 | — |
|
||||
| tag:ARCHITECTURE | status-machine | draft, calibrated, ready, publishing, published, failed | ported | APP-5 | — |
|
||||
| tag:ARCHITECTURE | ready-set | ready set fields | ported | REVIEW-5 | — |
|
||||
| tag:ARCHITECTURE | ready-set-fig-grade | ready set requires 1+ fig-* tag and 1+ grade | superseded | METADATA-15, METADATA-30 | grade exactly one; tags optional |
|
||||
| tag:ARCHITECTURE | anchor-bump | anchor bumps only on stored-subset change including deletions | ported | SETTINGS-55 | — |
|
||||
| tag:ARCHITECTURE | anchor-repull | client re-pulls full taxonomy iff the anchor advanced | ported | SETTINGS-60 | — |
|
||||
| tag:ARCHITECTURE | species-exclusivity | species pickers and Call 1 lists are exactly the cached set | ported | METADATA-5 | — |
|
||||
| tag:ARCHITECTURE | trigger-test-woo | test-woo always resyncs taxonomy | ported | SETTINGS-40 | — |
|
||||
| tag:ARCHITECTURE | trigger-publish-1h | publish resync if the last sync is older than 1 hour | ported | PUBLISH-5, SETTINGS-60 | — |
|
||||
| tag:ARCHITECTURE | trigger-optional-launch | optional app-launch or Settings refresh | dropped | — | optional, not required for POC |
|
||||
| tag:ARCHITECTURE | trigger-no-nav-sync | do not sync on every screen navigation | dropped | — | design guidance, not a requirement |
|
||||
| tag:ARCHITECTURE | validation-module | shared validation module, version hash, 412 | dropped | — | ruled cut 2026-09-18 |
|
||||
| tag:ARCHITECTURE | sku-charset | SKU charset [A-Z0-9-]{3,24} | ported | REVIEW-45 | case widened to alphanumeric by ruling |
|
||||
| tag:ARCHITECTURE | validation-empty-optional | field-exit local check; empty optional is valid | dropped | — | review gate covers required fields; no separate row |
|
||||
| tag:ARCHITECTURE | call1-call2-context | Call 1 to Call 2 portable context default | superseded | — | thread work pushed out of MVP |
|
||||
| tag:ARCHITECTURE | test-inference-probe | test-inference two-turn probe records stateful, stateless, fail | ported | SETTINGS-45 | mode removed; probe keeps ok, vision_capable, detail |
|
||||
| tag:ARCHITECTURE | per-slab-thread | per-slab thread id, delete on publish or abandon | superseded | — | cut 2026-09-18 |
|
||||
| tag:ARCHITECTURE | inference-runtime-failure | Call 1 timeout 45s returns error plus retry on the phone | ported | METADATA-115 | — |
|
||||
| tag:ARCHITECTURE | call1-image-source | all originals downscaled to 1024 | superseded | METADATA-35 | PoR sends the clean processed photos |
|
||||
| tag:ARCHITECTURE | title-desc-maxlength | Woo maxLength caps title and descriptions | dropped | — | inherent: bound by Woo limits |
|
||||
@@ -1,50 +0,0 @@
|
||||
# Coverage ledger — tag:DATA-MODEL
|
||||
|
||||
Source `tag:DATA-MODEL` (`docs/DATA-MODEL.md` at tag `pre-simplification`, role: schema spec).
|
||||
Walked 2026-09-18. Column mechanics belong in `docs/contracts/DATA-MODEL.md`; the testable
|
||||
rules carry PoR IDs.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:DATA-MODEL | settings-table | settings.value_enc is AES-GCM; WOO_BASE_URL is not a settings row | ported | SETTINGS-5, SETTINGS-20, SETTINGS-30 | — |
|
||||
| tag:DATA-MODEL | ids-client-uuid | slab ids are client-generated UUIDv4 | ported | APP-20 | — |
|
||||
| tag:DATA-MODEL | timestamps-utc | timestamps are UTC ISO-8601 | ported | APP-20 | — |
|
||||
| tag:DATA-MODEL | status-enum | status draft/calibrated/ready/publishing/published/failed | ported | APP-5 | — |
|
||||
| tag:DATA-MODEL | rounded-values-stored | rounded values are what gets stored | ported | CAPTURE-160 | — |
|
||||
| tag:DATA-MODEL | inches-eighth | inches stored as 1/8 increments | ported | CAPTURE-70 | — |
|
||||
| tag:DATA-MODEL | sqft-bdft-2dp | sqft and bdft to 2 decimals | ported | CAPTURE-85, CAPTURE-90 | — |
|
||||
| tag:DATA-MODEL | sku-charset | sku `[A-Z0-9-]{3,24}` | ported | REVIEW-45 | — |
|
||||
| tag:DATA-MODEL | species-leaf-1 | one species leaf | ported | METADATA-10 | — |
|
||||
| tag:DATA-MODEL | wood-category-1plus | one or more wood-category leaves | ported | METADATA-10 | — |
|
||||
| tag:DATA-MODEL | edge-1 | one edge type | ported | METADATA-15 | — |
|
||||
| tag:DATA-MODEL | figure-1plus | one or more figure terms | ported | METADATA-15 | — |
|
||||
| tag:DATA-MODEL | grade-1plus | one or more grade terms | superseded | METADATA-15 | grade is exactly one |
|
||||
| tag:DATA-MODEL | thickness-roundup | thickness term by round-up | ported | METADATA-15 | — |
|
||||
| tag:DATA-MODEL | moisture-default | moisture defaults kiln-dried, not inferred | ported | METADATA-15 | — |
|
||||
| tag:DATA-MODEL | fig-tags-1plus | fig-* tags 1+ | superseded | METADATA-30 | tags optional |
|
||||
| tag:DATA-MODEL | feat-tags-0plus | feat-* tags 0+ | ported | METADATA-30 | — |
|
||||
| tag:DATA-MODEL | price-2dp-source | price 2 dp, price_source recommendation or override | ported | REVIEW-25 | — |
|
||||
| tag:DATA-MODEL | inference-thread-id | optional stateful inference thread id | superseded | — | thread work out of MVP |
|
||||
| tag:DATA-MODEL | state-machine | status transitions draft through failed | ported | APP-5 | — |
|
||||
| tag:DATA-MODEL | ready-set | ready set fields | ported | REVIEW-5 | — |
|
||||
| tag:DATA-MODEL | published-delete-row | published deletes the row and photos, keeps sync_log | ported | PUBLISH-25 | — |
|
||||
| tag:DATA-MODEL | failed-error-retry | failed stores error detail, retry available | ported | PUBLISH-30 | — |
|
||||
| tag:DATA-MODEL | slab-photos-only-inventory | only inventory photos; calibration photos do not exist | superseded | CAPTURE-115 | calibration photo exists; other batch photos are visual inventory |
|
||||
| tag:DATA-MODEL | no-hard-deletes | no hard deletes for slabs (audit) | superseded | PUBLISH-25 | the local slab row is deleted on success |
|
||||
| tag:DATA-MODEL | pricing-rules-flat | one flat price_per_bdft per species, no tiers | ported | SETTINGS-35 | — |
|
||||
| tag:DATA-MODEL | woo-categories-filter | only species and wood-category leaves are offered | ported | METADATA-5, METADATA-10 | — |
|
||||
| tag:DATA-MODEL | woo-attributes-five | only the five first-class attributes | ported | METADATA-15 | — |
|
||||
| tag:DATA-MODEL | woo-tags-scope | only fig-* and feat-*; Woo is authoritative | ported | METADATA-30 | — |
|
||||
| tag:DATA-MODEL | settings-credentials | woo_wp_username and woo_app_password settings | ported | SETTINGS-20 | — |
|
||||
| tag:DATA-MODEL | settings-store-url-env | store URL is not a settings row | ported | SETTINGS-5 | — |
|
||||
| tag:DATA-MODEL | settings-inference | inference base URL, key, model, enabled flags | ported | SETTINGS-30 | — |
|
||||
| tag:DATA-MODEL | settings-brand-geo | brand_voice and geo_context settings | ported | SETTINGS-30 | — |
|
||||
| tag:DATA-MODEL | settings-create-status | woo_create_status defaults draft | ported | SETTINGS-65 | — |
|
||||
| tag:DATA-MODEL | settings-taxonomy-stamps | taxonomy_last_sync_at and taxonomy_anchor | ported | SETTINGS-55, SETTINGS-60 | — |
|
||||
| tag:DATA-MODEL | settings-crop-1600-080 | output_px_min 1600, fill_target 0.80 | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
|
||||
| tag:DATA-MODEL | settings-client-prefs | sheet and slider prefs in client localStorage | ported | CAPTURE-145 | — |
|
||||
| tag:DATA-MODEL | settings-aes | AES master key env, value_enc = nonce plus ciphertext | ported | SETTINGS-25 | — |
|
||||
| tag:DATA-MODEL | sync-log | append-only sync_log, not deleted on publish | ported | PUBLISH-30 | — |
|
||||
| tag:DATA-MODEL | inference-log | append-only inference_log audit | ported | METADATA-66 | — |
|
||||
| tag:DATA-MODEL | contract-details | money float internals, JSON arrays, payload_hash, soft-delete mechanics | dropped | — | schema contract detail |
|
||||
| tag:DATA-MODEL | prompt-files | prompts bind-mounted as text files, read fresh, reset script | superseded | METADATA-105 | ruling: prompts live in the DB with all revisions |
|
||||
@@ -1,48 +0,0 @@
|
||||
# Coverage ledger — tag:DEPLOYMENT
|
||||
|
||||
Source `tag:DEPLOYMENT` (`docs/DEPLOYMENT.md` at tag `pre-simplification`, role: deploy
|
||||
playbook). Vetted 2026-09-18. Testable rules are OPS/SETTINGS; operational facts live in
|
||||
`deploy/`. Doc retired (see migration TODOs).
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:DEPLOYMENT | env-table | env carries WOO_BASE_URL, Woo credentials, inference key | ported | OPS-10, SETTINGS-5, SETTINGS-30 | — |
|
||||
| tag:DEPLOYMENT | rotate-creds | rotate Woo credentials then test-woo | duplicate | SETTINGS-40 | rotation is procedure; the verification is test-woo |
|
||||
| tag:DEPLOYMENT | aes-rekey | SLAB_AES_KEY change requires re-encrypting settings | deferred | — | deferred ruling, audit section 18 item 7 |
|
||||
| tag:DEPLOYMENT | preamble-hybrid-sot | client owns measure/crop; server-side measure/crop scaffolding not done | ported | CAPTURE-5, CAPTURE-135 | — |
|
||||
| tag:DEPLOYMENT | preamble-uat-draft | Woo UAT is a draft with `SLAB-UAT-*` on the production store | ported | PUBLISH-10 | — |
|
||||
| tag:DEPLOYMENT | preamble-inference-remote | inference is a remote configurable endpoint, not on the app host | ported | OPS-45 | — |
|
||||
| tag:DEPLOYMENT | lab-host-example | historical lab host `192.168.1.201` | dropped | — | non-normative historical example |
|
||||
| tag:DEPLOYMENT | host-inventory | docker ps, Caddy config, free port, volume convention | ported | OPS-15 | — |
|
||||
| tag:DEPLOYMENT | reachability | confirm the hostname is reachable from the phone network | ported | OPS-5 | — |
|
||||
| tag:DEPLOYMENT | inventory-before-bind | write values into deploy/INVENTORY.md and host .env before compose up | ported | OPS-15 | — |
|
||||
| tag:DEPLOYMENT | repo-layout | repository layout at scaffold | dropped | — | scaffold detail |
|
||||
| tag:DEPLOYMENT | store-url-env | WOO_BASE_URL in deploy/.env, injected into FastAPI | ported | SETTINGS-5, OPS-10 | — |
|
||||
| tag:DEPLOYMENT | store-url-https | HTTPS only; FastAPI refuses missing or http at startup | ported | SETTINGS-10 | — |
|
||||
| tag:DEPLOYMENT | store-url-not-settings-row | not a Settings input and not a SQLite settings row | ported | SETTINGS-5 | — |
|
||||
| tag:DEPLOYMENT | store-url-readonly | Settings UI shows the URL read-only | ported | SETTINGS-5 | — |
|
||||
| tag:DEPLOYMENT | app-password-out-of-compose | Woo application password stays out of compose; entered in Settings | ported | SETTINGS-20 | — |
|
||||
| tag:DEPLOYMENT | house-rules-localhost-caddy | services bind 127.0.0.1; Caddy is the only public surface | ported | OPS-20 | — |
|
||||
| tag:DEPLOYMENT | house-rules-pinned-images | no latest tags; restart unless-stopped; mem_limit | ported | OPS-25 | — |
|
||||
| tag:DEPLOYMENT | caddy-fragment | Caddy fragment contents | dropped | — | deploy detail |
|
||||
| tag:DEPLOYMENT | secret-aes-key | SLAB_AES_KEY env, mode 600, 32-byte base64, never in the repo | ported | SETTINGS-25, OPS-10 | — |
|
||||
| tag:DEPLOYMENT | secret-woo-creds | Woo credentials AES-GCM in SQLite, dedicated low-priv user, not consumer keys | ported | SETTINGS-15, SETTINGS-20 | — |
|
||||
| tag:DEPLOYMENT | secret-inference-key | inference API key AES-GCM in SQLite | ported | SETTINGS-30 | — |
|
||||
| tag:DEPLOYMENT | woocred-https-store | store must already be on HTTPS for Application Passwords | ported | SETTINGS-15 | — |
|
||||
| tag:DEPLOYMENT | woocred-dedicated-user | dedicated low-privilege WordPress user | ported | SETTINGS-15 | — |
|
||||
| tag:DEPLOYMENT | woocred-generate-password | generate the application password in the WP profile | dropped | — | procedure |
|
||||
| tag:DEPLOYMENT | woocred-enter-creds | enter credentials in Settings; AES-GCM; browser never keeps them | ported | SETTINGS-20 | — |
|
||||
| tag:DEPLOYMENT | backup-sqlite-images | WAL-safe SQLite backup plus images tar; 7 daily and 4 weekly | ported | OPS-30 | — |
|
||||
| tag:DEPLOYMENT | backup-prompt-files | prompt files live in git and need no separate backup | superseded | — | prompts move to the DB with all revisions (GAP-8) |
|
||||
| tag:DEPLOYMENT | backup-offbox | mirror the backup to an off-box share | ported | OPS-30 | — |
|
||||
| tag:DEPLOYMENT | restore-tested | stop, restore DB and images, start; tested once | ported | OPS-35 | — |
|
||||
| tag:DEPLOYMENT | runbook | operations runbook | dropped | — | procedure |
|
||||
| tag:DEPLOYMENT | smoke-health | health ok from the phone over HTTPS | ported | APP-45 | — |
|
||||
| tag:DEPLOYMENT | smoke-test-woo | test-woo ok, create status draft, inference off | ported | SETTINGS-40, SETTINGS-65 | — |
|
||||
| tag:DEPLOYMENT | smoke-capture-to-ready | create a slab, upload fixtures, poll to ready | ported | APP-5 | — |
|
||||
| tag:DEPLOYMENT | smoke-bdft-price | verify bdft and price against the fixture | ported | CAPTURE-90, REVIEW-25 | — |
|
||||
| tag:DEPLOYMENT | smoke-uat-draft | publish a `SLAB-UAT-*` slab; Woo product must be draft | ported | PUBLISH-10 | — |
|
||||
| tag:DEPLOYMENT | smoke-cleanup | leave or delete the UAT draft | dropped | — | procedure |
|
||||
| tag:DEPLOYMENT | scaling-postgres-redis | Postgres/Redis/GPU scaling paths | dropped | — | post-POC |
|
||||
| tag:DEPLOYMENT | scaling-never-lower-1600 | never lower output_px_min below 1600 | superseded | CAPTURE-105 | PoR floor is 800 px |
|
||||
| tag:DEPLOYMENT | scaling-rembg | RemBG is not part of the hybrid happy path | dropped | — | design note |
|
||||
@@ -1,82 +0,0 @@
|
||||
# Coverage ledger — tag:IMPL-PLAN
|
||||
|
||||
Source `tag:IMPL-PLAN` (`docs/IMPL-PLAN.md` at tag `pre-simplification`). Vetted 2026-09-18.
|
||||
Process history and duplicates; doc retired (see migration TODOs).
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:IMPL-PLAN | ground-rules | deterministic core tested first, no feature without a passing test, inference toggleable | ported | QUALITY-5, QUALITY-10, QUALITY-20 | — |
|
||||
| tag:IMPL-PLAN | gates-abc | Gate A docs freeze, Gate B host, Gate C first draft listing | dropped | — | superseded by MILESTONES and the PoR |
|
||||
| tag:IMPL-PLAN | phase0-client-modules | client pure modules for the deterministic core | ported | CAPTURE-5, CAPTURE-80, CAPTURE-85, CAPTURE-90 | — |
|
||||
| tag:IMPL-PLAN | phase0-fastapi-skeleton | app factory, health, SQLite, settings AES, draft store stub | ported | APP-30, APP-45, SETTINGS-20, SETTINGS-25, APP-40 | — |
|
||||
| tag:IMPL-PLAN | phase0-tests-tv | unit tests for TV-1 through TV-10 | ported | QUALITY-15 | — |
|
||||
| tag:IMPL-PLAN | phase0-fixture-set | fixture photo set for Gate B review | dropped | — | process |
|
||||
| tag:IMPL-PLAN | phase0-no-server-recompute | server stores client numbers, no server recompute | ported | CAPTURE-135, APP-40 | — |
|
||||
| tag:IMPL-PLAN | phase0-u2net-stub | real U2Net stubbed, deferred | ported | CAPTURE-165 | — |
|
||||
| tag:IMPL-PLAN | phase0-species-synced-only | species offered are exactly the synced leaves | ported | METADATA-5 | — |
|
||||
| tag:IMPL-PLAN | phase0-offline-deferred | offline PWA deferred | ported | CAPTURE-15 | — |
|
||||
| tag:IMPL-PLAN | phase0-no-fake-upscale | undersized source warns and requires retake | ported | CAPTURE-105 | — |
|
||||
| tag:IMPL-PLAN | phase0-acceptance-math | bdft identity, no divide-by-12, rounding | ported | CAPTURE-90, QUALITY-100 | — |
|
||||
| tag:IMPL-PLAN | phase0-health-compose | health up in compose after INVENTORY is filled | ported | OPS-15, APP-45 | — |
|
||||
| tag:IMPL-PLAN | phase0-settings-aes | AES round-trip, secrets never in GET | ported | SETTINGS-75, QUALITY-125 | — |
|
||||
| tag:IMPL-PLAN | phase0-draft-store | draft stores uploads without server recompute | ported | APP-40 | — |
|
||||
| tag:IMPL-PLAN | phase0-coverage-property | coverage target and property checks | ported | QUALITY-40, QUALITY-45 | — |
|
||||
| tag:IMPL-PLAN | phase0-exit-gate | Ty confirms the shown output | ported | QUALITY-30 | — |
|
||||
| tag:IMPL-PLAN | phase1-capture-1-5 | online capture of 1-5 photos | superseded | CAPTURE-25 | no cap |
|
||||
| tag:IMPL-PLAN | phase1-four-knobs | four knobs inline with live re-run | superseded | CAPTURE-40 | two knobs |
|
||||
| tag:IMPL-PLAN | phase1-client-measure-png | client sqft/bdft/widths and 3:4 PNG prep | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90, CAPTURE-95 | — |
|
||||
| tag:IMPL-PLAN | phase1-manual-fields | manual SKU, length, thickness, taxonomy | ported | CAPTURE-70 | — |
|
||||
| tag:IMPL-PLAN | phase1-calibrated-upload | calibrated draft upload, server stores only | ported | CAPTURE-135 | — |
|
||||
| tag:IMPL-PLAN | phase1-uat-draft | Woo draft create with `SLAB-UAT-*` on the production store | ported | PUBLISH-10 | — |
|
||||
| tag:IMPL-PLAN | phase1-inference-off | inference remains off for the slice | ported | QUALITY-20 | — |
|
||||
| tag:IMPL-PLAN | phase1-routers-openapi | routers per OPENAPI, generated openapi.yaml | dropped | — | contract detail |
|
||||
| tag:IMPL-PLAN | phase1-optimistic-concurrency | client_rev/server_rev, 409 paths | ported | APP-20 | — |
|
||||
| tag:IMPL-PLAN | phase1-status-machine-queue | status machine, queue only for publish and inference | ported | APP-5 | — |
|
||||
| tag:IMPL-PLAN | phase1-integration-tests | create to ready, revision conflict, duplicate SKU, encryption, anchor | ported | QUALITY-35 | — |
|
||||
| tag:IMPL-PLAN | phase1-put-stores-derived | PUT stores derived fields, does not recompute | ported | APP-40 | — |
|
||||
| tag:IMPL-PLAN | phase1-taxonomy-anchor | anchor advances only on real change; client re-pulls | ported | SETTINGS-55, SETTINGS-60 | — |
|
||||
| tag:IMPL-PLAN | phase2-offline | offline capture deferred | ported | CAPTURE-15 | — |
|
||||
| tag:IMPL-PLAN | phase3-review-override-paths | widths/sqft/bdft shown with manual override paths | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
|
||||
| tag:IMPL-PLAN | phase3-show-pngs | show processed PNGs, originals while retained | ported | PUBLISH-40 | — |
|
||||
| tag:IMPL-PLAN | phase3-inference-ui | 0.7 pre-fill, below empty, full ready set | ported | METADATA-45, REVIEW-5 | — |
|
||||
| tag:IMPL-PLAN | phase3-capture-four-knobs | four knobs on the mask screen | superseded | CAPTURE-40 | two knobs |
|
||||
| tag:IMPL-PLAN | phase3-publish-poll | publish button, poll, surface error detail | ported | PUBLISH-5, PUBLISH-30 | — |
|
||||
| tag:IMPL-PLAN | phase3-settings-ui | settings screen for creds, inference, pricing, brand/GEO, create status | ported | SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65 | — |
|
||||
| tag:IMPL-PLAN | phase3-secrets-masked | secrets never appear in GET | ported | SETTINGS-75 | — |
|
||||
| tag:IMPL-PLAN | phase4-woo-client | Woo client with Basic auth, taxonomy sync | ported | SETTINGS-15, SETTINGS-50 | — |
|
||||
| tag:IMPL-PLAN | phase4-auto-assign | auto-assign categories, five attributes, fig/feat tags | ported | METADATA-10, METADATA-15, METADATA-30 | — |
|
||||
| tag:IMPL-PLAN | phase4-media-create | media upload of processed PNGs, product create per status | ported | PUBLISH-40, PUBLISH-10 | — |
|
||||
| tag:IMPL-PLAN | phase4-dedupe | dedupe by SKU before create, 409 recovery | ported | PUBLISH-15 | — |
|
||||
| tag:IMPL-PLAN | phase4-stale-recovery | 422 stale-field recovery with current options | ported | PUBLISH-20 | — |
|
||||
| tag:IMPL-PLAN | phase4-sync-log-purge | sync_log, set published, purge images | ported | PUBLISH-25, PUBLISH-30 | — |
|
||||
| tag:IMPL-PLAN | phase4-taxonomy-sync | taxonomy sync endpoint against the real store | ported | SETTINGS-50, SETTINGS-55 | — |
|
||||
| tag:IMPL-PLAN | phase4-integration-respx | integration tests against a Woo mock | ported | QUALITY-35 | — |
|
||||
| tag:IMPL-PLAN | phase4-partial-failure | orphaned media logged, manual cleanup note | ported | PUBLISH-31 | — |
|
||||
| tag:IMPL-PLAN | phase5-call1-proxy | Call 1 proxy, all photos at 1024, taxonomy, 0.7 | ported | METADATA-35, METADATA-40, METADATA-45 | — |
|
||||
| tag:IMPL-PLAN | phase5-prefill-rules | at or above threshold pre-fill, below empty | ported | METADATA-45 | — |
|
||||
| tag:IMPL-PLAN | phase5-call2-proxy | Call 2 proxy, never auto, prose only, templates inject numbers | ported | METADATA-70, METADATA-65 | — |
|
||||
| tag:IMPL-PLAN | phase5-portable-context | portable Call 1 context, previous_response_id | superseded | — | thread work out of MVP |
|
||||
| tag:IMPL-PLAN | phase5-test-inference | test-inference probe | ported | SETTINGS-45 | mode removed |
|
||||
| tag:IMPL-PLAN | phase5-prompt-files | prompt files read fresh each call | superseded | METADATA-105 | prompts live in the DB with all revisions |
|
||||
| tag:IMPL-PLAN | phase5-no-invention | no numeric invention, LoRA deferred | ported | METADATA-65 | — |
|
||||
| tag:IMPL-PLAN | phase6-host-inventory | host inventory pass | ported | OPS-15 | — |
|
||||
| tag:IMPL-PLAN | phase6-deploy-backup | deploy, backup, restore tested | ported | OPS-5, OPS-30, OPS-35 | — |
|
||||
| tag:IMPL-PLAN | phase6-uat-suite | UAT suite on real slabs | ported | QUALITY-110 | — |
|
||||
| tag:IMPL-PLAN | phase6-e2e-browsers | end-to-end capture to publish on phone and PC browsers | ported | QUALITY-115 | — |
|
||||
| tag:IMPL-PLAN | phase6-manual-length-override | manual length override recalculates widths/sqft/bdft | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
|
||||
| tag:IMPL-PLAN | phase6-crop-80-1600 | crop 80 percent, shorter side 1600 | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent and 800 px |
|
||||
| tag:IMPL-PLAN | phase6-woo-product | Woo product with processed PNGs and taxonomy | ported | PUBLISH-40, PUBLISH-45 | — |
|
||||
| tag:IMPL-PLAN | phase6-unit-tests-logs | deterministic functions tested, inference calls logged | ported | QUALITY-5, METADATA-66 | — |
|
||||
| tag:IMPL-PLAN | risks-green-sheet | chroma key plus inline knobs, retake, never lower pixel floor | ported | CAPTURE-35, CAPTURE-41, CAPTURE-155 | — |
|
||||
| tag:IMPL-PLAN | risks-never-lower-1600 | never lower output_px_min below 1600 | superseded | CAPTURE-105 | PoR floor is 800 px |
|
||||
| tag:IMPL-PLAN | risks-no-vision | inference toggleable, manual fully functional | ported | METADATA-75 | — |
|
||||
| tag:IMPL-PLAN | risks-partial-publish | orphaned media, retained images, manual cleanup | ported | PUBLISH-31 | — |
|
||||
| tag:IMPL-PLAN | risks-sqlite | SQLite contention under concurrent polls | dropped | — | single owner, low volume |
|
||||
| tag:IMPL-PLAN | risks-host-port | host port or Caddy collision | ported | OPS-15 | — |
|
||||
| tag:IMPL-PLAN | risks-ruler-deferred | ruler detection deferred; manual length is authoritative | ported | CAPTURE-15 | — |
|
||||
| tag:IMPL-PLAN | dod-phases | all phases exit-gated by Ty | dropped | — | process |
|
||||
| tag:IMPL-PLAN | dod-acceptance | hybrid online acceptance met in Phase 6 UAT | ported | QUALITY-110 | — |
|
||||
| tag:IMPL-PLAN | dod-tests-green-off | pytest, integration, UAT green with inference off | ported | QUALITY-20 | — |
|
||||
| tag:IMPL-PLAN | dod-deploy-backup | deployed, HTTPS, backed up, restore tested | ported | OPS-5, OPS-30, OPS-35 | — |
|
||||
| tag:IMPL-PLAN | dod-gitea-source | repo on gitea is source of truth | ported | OPS-50 | — |
|
||||
| tag:IMPL-PLAN | dod-archive-frozen | archive is frozen history; live docs are the SoT | dropped | — | process |
|
||||
@@ -1,79 +0,0 @@
|
||||
# Coverage ledger — tag:OPENAPI
|
||||
|
||||
Source `tag:OPENAPI` (`docs/OPENAPI.md` at tag `pre-simplification`, role: contract). Walked
|
||||
2026-09-18. Wire detail (endpoints, schemas, examples) is contract material for
|
||||
`docs/contracts/API.md`; only the testable rules carry PoR IDs.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:OPENAPI | auth-no-client-auth | no client auth; trusted LAN/Tailscale plus HTTPS | ported | OPS-55 | — |
|
||||
| tag:OPENAPI | error-envelope | uniform error envelope { code, message, detail } | ported | APP-35 | — |
|
||||
| tag:OPENAPI | error-400-404 | 400 validation; 404 not_found | ported | APP-35 | — |
|
||||
| tag:OPENAPI | error-409-duplicate-sku | 409 duplicate_sku, SKU exists in Woo any status | ported | PUBLISH-15 | — |
|
||||
| tag:OPENAPI | error-409-revision-conflict | 409 revision_conflict with current_server_rev | ported | APP-20 | — |
|
||||
| tag:OPENAPI | error-422-unpublishable | 422 unpublishable with field keys, no raw code | ported | REVIEW-5, REVIEW-20 | — |
|
||||
| tag:OPENAPI | error-412-validation-stale | 412 validation_module_stale | dropped | — | validation module ruled cut 2026-09-18 |
|
||||
| tag:OPENAPI | error-502-woo | 502 woo_error carries the Woo error body | ported | PUBLISH-30 | — |
|
||||
| tag:OPENAPI | error-500-internal | 500 internal | ported | APP-35 | — |
|
||||
| tag:OPENAPI | revisions | client_rev/server_rev, mismatch 409 revision_conflict | ported | APP-20 | — |
|
||||
| tag:OPENAPI | photos-multipart-40mb | multipart, max 40 MB per request | dropped | — | contract detail |
|
||||
| tag:OPENAPI | photos-meta-role-kind | meta { kind: inventory, role, seq } | dropped | — | role/kind label discarded per CAPTURE migration |
|
||||
| tag:OPENAPI | pagination-full-arrays | lists return full arrays | ported | APP-35 | — |
|
||||
| tag:OPENAPI | api-version | /api/v1, breaking changes bump v2 | ported | APP-30 | — |
|
||||
| tag:OPENAPI | schema-sku | SKU string 3..24 | ported | REVIEW-45 | — |
|
||||
| tag:OPENAPI | schema-length-thickness | length_in and thickness_in 1/8 step | ported | CAPTURE-70 | — |
|
||||
| tag:OPENAPI | schema-derived | sqft, bdft, widths computed by client | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90 | — |
|
||||
| tag:OPENAPI | schema-species-woodcategory | species leaf 1, wood categories 1+ | ported | METADATA-10 | — |
|
||||
| tag:OPENAPI | schema-edge-figure | edge 1, figure 1+ | ported | METADATA-15 | — |
|
||||
| tag:OPENAPI | schema-grade-cardinality | grade 1+ | superseded | METADATA-15 | grade is exactly one |
|
||||
| tag:OPENAPI | schema-thickness-roundup | thickness term by round-up | ported | METADATA-15 | — |
|
||||
| tag:OPENAPI | schema-moisture-default | moisture defaults kiln-dried | ported | METADATA-15 | — |
|
||||
| tag:OPENAPI | schema-fig-tags-cardinality | fig-* 1+ | superseded | METADATA-30 | tags optional |
|
||||
| tag:OPENAPI | schema-feat-tags | feat-* 0+ | ported | METADATA-30 | — |
|
||||
| tag:OPENAPI | schema-price | price 2 dp, price_source recommendation or override | ported | REVIEW-25 | — |
|
||||
| tag:OPENAPI | schema-short-title | short_title field | superseded | REVIEW-10 | Woo canonical name is short_description |
|
||||
| tag:OPENAPI | slab-read-model | status, inference_status, woo_product_id, published_at, revisions | ported | APP-5 | — |
|
||||
| tag:OPENAPI | photo-read-410 | original/processed urls, 410 after publish | ported | PUBLISH-25 | — |
|
||||
| tag:OPENAPI | call1-result-shape | Call 1 taxonomy ids plus confidences | ported | METADATA-40 | — |
|
||||
| tag:OPENAPI | call1-free-text-to-feat | free-text observations mapped to synced feat-* tags | ported | METADATA-100 | — |
|
||||
| tag:OPENAPI | call2-result-shape | title, short_title, description | ported | METADATA-55 | short_title naming migrated to short_description |
|
||||
| tag:OPENAPI | price-rule-shape | flat price_per_bdft per species | ported | SETTINGS-35 | — |
|
||||
| tag:OPENAPI | settings-view-shape | settings read model; secrets masked | ported | SETTINGS-5, SETTINGS-30, SETTINGS-35, SETTINGS-65, SETTINGS-75 | — |
|
||||
| tag:OPENAPI | settings-view-crop | fill_target 0.80, output_px_min 1600 | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
|
||||
| tag:OPENAPI | meta-endpoint | GET /meta version, templates_configured | dropped | — | contract detail |
|
||||
| tag:OPENAPI | create-slab-calibrated | POST /slabs returns 201 calibrated, stores client numbers | ported | CAPTURE-135, APP-5 | — |
|
||||
| tag:OPENAPI | create-slab-auto-call1 | auto-run Call 1 only if inference_enabled | ported | METADATA-35 | — |
|
||||
| tag:OPENAPI | create-slab-confidence-persist | persist inferred values only at or above 0.7 | ported | METADATA-45 | — |
|
||||
| tag:OPENAPI | create-slab-dup-sku | 409 duplicate_sku on create | ported | PUBLISH-15 | — |
|
||||
| tag:OPENAPI | get-slabs-list | GET /slabs most recent first | dropped | — | contract detail |
|
||||
| tag:OPENAPI | get-slab-poll | GET /slabs/{id} poll with backoff | dropped | — | contract detail |
|
||||
| tag:OPENAPI | put-slab-stores-derived | PUT stores client derived fields, never recomputes | ported | APP-40 | — |
|
||||
| tag:OPENAPI | put-slab-dup-sku-actions | duplicate SKU actions edit_sku/open_existing, never raw code | ported | PUBLISH-15, PUBLISH-35 | — |
|
||||
| tag:OPENAPI | put-slab-stale-field | 422 stale_field with current options | ported | PUBLISH-20 | — |
|
||||
| tag:OPENAPI | put-slab-412 | 412 validation module stale | dropped | — | validation module ruled cut |
|
||||
| tag:OPENAPI | delete-slab-soft | DELETE soft, unpublished drafts only | dropped | — | contract detail |
|
||||
| tag:OPENAPI | u2net-endpoint | POST u2net post-POC, not exposed | ported | CAPTURE-165 | — |
|
||||
| tag:OPENAPI | photo-serve-410 | photo original/processed, 410 after publish | ported | PUBLISH-25 | — |
|
||||
| tag:OPENAPI | infer-taxon-endpoint | POST infer-taxon, prompt fresh, 1024 plus taxonomy, 202 | ported | METADATA-35 | — |
|
||||
| tag:OPENAPI | infer-taxon-image-source | original photos downscaled to 1024 | superseded | METADATA-35 | PoR sends the clean processed photos |
|
||||
| tag:OPENAPI | infer-taxon-timeout | timeout over 45s returns 504 | ported | METADATA-75 | — |
|
||||
| tag:OPENAPI | infer-content-endpoint | POST infer-content, 202, requires test-inference ok | ported | METADATA-55 | — |
|
||||
| tag:OPENAPI | infer-content-context-thread | full resend default, previous_response_id, per-slab thread | superseded | — | thread work out of MVP |
|
||||
| tag:OPENAPI | publish-preconditions | publish requires the full ready set, else 422 | ported | REVIEW-5 | — |
|
||||
| tag:OPENAPI | publish-dup-sku | 409 duplicate_sku before create | ported | PUBLISH-15 | — |
|
||||
| tag:OPENAPI | publish-stale | 422 stale_field, draft stays | ported | PUBLISH-20, PUBLISH-30 | — |
|
||||
| tag:OPENAPI | publish-success | success sets woo_product_id, purges images, deletes draft | ported | PUBLISH-25 | — |
|
||||
| tag:OPENAPI | publish-woo-fail | Woo failure returns failed, sync_log, 502 | ported | PUBLISH-30 | — |
|
||||
| tag:OPENAPI | taxonomy-sync | sync rebuilds cache, anchor only on change, previous retained on failure | ported | SETTINGS-50, SETTINGS-55 | — |
|
||||
| tag:OPENAPI | pricing-crud | pricing rule CRUD | ported | SETTINGS-35 | — |
|
||||
| tag:OPENAPI | settings-get | GET settings returns SettingsView | ported | SETTINGS-75 | — |
|
||||
| tag:OPENAPI | settings-put | PUT settings partial, secrets re-encrypted, password never echoed | ported | SETTINGS-20, SETTINGS-30, SETTINGS-65 | — |
|
||||
| tag:OPENAPI | settings-put-woo-url-reject | woo_base_url not writable | ported | SETTINGS-85 | — |
|
||||
| tag:OPENAPI | settings-put-consumer-key-reject | reject consumer key/secret payload | ported | SETTINGS-15 | — |
|
||||
| tag:OPENAPI | test-woo-fail-closed | test-woo fails closed on missing credentials or non-https | ported | SETTINGS-80 | — |
|
||||
| tag:OPENAPI | test-inference-shape | test-inference ok, mode, vision_capable, detail | ported | SETTINGS-45 | mode removed by ruling |
|
||||
| tag:OPENAPI | validation-module-endpoint | GET validation/module, hash, 412 on mismatch | dropped | — | ruled cut 2026-09-18 |
|
||||
| tag:OPENAPI | examples-worked | worked request/response examples | dropped | — | non-normative examples |
|
||||
| tag:OPENAPI | example-taxonomy-sync-key | example uses synced_at while spec says taxonomy_last_sync_at | dropped | — | contract detail inconsistency |
|
||||
| tag:OPENAPI | versioning-client-pin | client pins the version it was built against | ported | APP-30 | — |
|
||||
| tag:OPENAPI | out-of-contract-v1 | bulk, staff/role, webhook push, analytics out of contract | dropped | — | contract detail |
|
||||
@@ -1,36 +0,0 @@
|
||||
# Coverage ledger — tag:PROMPTS
|
||||
|
||||
Source `tag:PROMPTS` (`docs/PROMPTS.md` at tag `pre-simplification`, role: prompt spec).
|
||||
Walked 2026-09-18. Prompt templates are contract material for `docs/contracts/PROMPTS.md`;
|
||||
the testable rules carry PoR IDs.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:PROMPTS | management-location | prompts are server text files under backend/app/prompts, mounted read-only | superseded | METADATA-105 | ruling: prompts live in the DB with all revisions |
|
||||
| tag:PROMPTS | management-edit-host | edit the text files on the host, no restart | superseded | — | prompts move to the DB |
|
||||
| tag:PROMPTS | management-reset-script | python -m app.prompts.reset restores defaults | superseded | — | replaced by DB revision history |
|
||||
| tag:PROMPTS | management-version-log | inference_log stores prompt content or hash per call | ported | METADATA-66 | — |
|
||||
| tag:PROMPTS | call1-purpose | classify species, edge, figure, grade from photos using synced lists only | ported | METADATA-40 | — |
|
||||
| tag:PROMPTS | call1-inputs-photos | 1-5 inventory photos downscaled to 1024 | ported | METADATA-35 | downsample rule |
|
||||
| tag:PROMPTS | call1-photo-cap | 1-5 inventory photos | superseded | CAPTURE-25 | no photo cap |
|
||||
| tag:PROMPTS | call1-inputs-taxonomy | species, edge, figure, grade lists with ids | ported | METADATA-35, METADATA-40 | — |
|
||||
| tag:PROMPTS | call1-inputs-metadata | user metadata SKU, length, thickness | ported | METADATA-35 | — |
|
||||
| tag:PROMPTS | call1-system-constraint | only use provided taxonomy values, do not invent | ported | METADATA-40 | — |
|
||||
| tag:PROMPTS | call1-schema | taxonomy ids plus confidences and free-text observations | ported | METADATA-40 | — |
|
||||
| tag:PROMPTS | call1-free-text-to-feat | free-text observations mapped to synced feat-* tags | ported | METADATA-100 | — |
|
||||
| tag:PROMPTS | call1-prepopulation | Call 1 is the primary pre-fill source when inference is enabled | ported | METADATA-45 | — |
|
||||
| tag:PROMPTS | call1-confidence-gate | at or above 0.7 pre-fill, below leaves empty | ported | METADATA-45 | — |
|
||||
| tag:PROMPTS | call1-ready-not-defined | Call 1 does not define the ready gate | ported | METADATA-75 | — |
|
||||
| tag:PROMPTS | call1-manual-wins | manual entry wins; Call 1 is assist only | ported | METADATA-50 | — |
|
||||
| tag:PROMPTS | call2-purpose | the model writes descriptive prose only | ported | METADATA-60 | — |
|
||||
| tag:PROMPTS | call2-inputs-context | portable Call 1 context, previous_response_id | superseded | — | thread work out of MVP |
|
||||
| tag:PROMPTS | call2-confirmed-inferred | explicit confirmed versus inferred labels | ported | METADATA-60 | PoR states metadata confirmed or changed in review |
|
||||
| tag:PROMPTS | call2-deterministic-measurements | deterministic measurements, never invented | ported | METADATA-60, METADATA-65 | — |
|
||||
| tag:PROMPTS | call2-brand-geo | brand voice, GEO context, templates | ported | METADATA-60 | — |
|
||||
| tag:PROMPTS | call2-validation-rules | shared-validation rules in Call 2 assembly | dropped | — | validation module ruled cut |
|
||||
| tag:PROMPTS | call2-system-no-invention | measurements are not invented; only provided data is used | ported | METADATA-65 | — |
|
||||
| tag:PROMPTS | call2-template-assembly | server assembles title and description from templates | ported | METADATA-70, METADATA-80 | — |
|
||||
| tag:PROMPTS | call2-llm-not-dimensions | the LLM never touches dimensions | ported | METADATA-65 | — |
|
||||
| tag:PROMPTS | call2-llm-disabled | content_llm_enabled false skips the LLM, templates are used | ported | METADATA-70 | — |
|
||||
| tag:PROMPTS | tuning-workflow | iterate prompts against real slabs | dropped | — | procedure |
|
||||
| tag:PROMPTS | title-example-emdash | example title uses an em dash and a multiplication sign | superseded | — | title separator is the hyphen; migration cleanup |
|
||||
@@ -1,51 +0,0 @@
|
||||
# Coverage ledger — tag:TECH-SPEC-PIPELINE
|
||||
|
||||
Source `tag:TECH-SPEC-PIPELINE` (`docs/TECH-SPEC-PIPELINE.md` at tag `pre-simplification`).
|
||||
Vetted 2026-09-18. Rules are CAPTURE, vectors are QUALITY. Doc retired (see migration TODOs).
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:TECH-SPEC-PIPELINE | invariants-determinism | pure function of pixels and user inputs; no network in the math | ported | CAPTURE-5 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | invariants-manual-authoritative | user-entered length, thickness, SKU, price always win | ported | METADATA-50 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | invariants-fallback-chain | every computed value has a defined fallback; never silently guesses | ported | CAPTURE-155 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | invariants-units | inches in 1/8 steps; sqft and bdft 2 decimals | ported | CAPTURE-70, CAPTURE-160 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | invariants-client-authoritative | all deterministic processing in the browser; server stores | ported | CAPTURE-135, APP-40 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | browser-compat-floor | iOS Safari, Android Chrome, desktop Chrome/Edge/Firefox; hard stop | ported | CAPTURE-10 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | load-bearing-apis | canvas or OffscreenCanvas, image decode/crop/encode, fetch | ported | CAPTURE-140 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | units-table | per-quantity source and rounding | ported | CAPTURE-70, CAPTURE-85, CAPTURE-90, REVIEW-25, CAPTURE-160 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | bdft-not-rounded-whole | bdft is never rounded up to whole board feet | ported | CAPTURE-90 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | pixels-hidden | pixel-to-inch scale is an implementation detail | dropped | — | internal detail |
|
||||
| tag:TECH-SPEC-PIPELINE | bg-removal-load-bearing | the mask defines the measurement footprint; the eye is the validator | ported | CAPTURE-50 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | sheet-detect | 2-5 percent border strip, HSV green or black, else user picks | ported | CAPTURE-35 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | overlay-source-photo | overlay the mask on the source photo and confirm the edge | ported | CAPTURE-50 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | knobs-inline-live | knobs inline on capture, live re-run, persisted, no server round trip | ported | CAPTURE-45, CAPTURE-145, CAPTURE-150 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | knobs-four | sheet, sensitivity, edge offset, feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
|
||||
| tag:TECH-SPEC-PIPELINE | algorithms-floodfill | chroma key or threshold with border-connected flood fill | ported | CAPTURE-35, CAPTURE-41 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | bg-poc-client-u2net-deferred | client-only mask with retake; U2Net deferred, contract preserved | ported | CAPTURE-165 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | edge-quality-contiguous | the mask must be one contiguous slab contour, else retake | ported | CAPTURE-55 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | contour-length-axis | min-area bounding rect; long side is the length axis, prevailing length | ported | CAPTURE-60 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | axis-rotate-confirm | user rotates and confirms; nearly square picks the axis | ported | CAPTURE-60, CAPTURE-65 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | scale-formula | scale = longest_axis_px / user length | ported | CAPTURE-75 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | widths | sample every 6 inches perpendicular; min, max, average | ported | CAPTURE-80 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | sqft-formula | sqft = mask_pixels / scale squared / 144, 2 decimals | ported | CAPTURE-85 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | bdft-formula | bdft = sqft times thickness_in, 2 decimals | ported | CAPTURE-90 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | normalization-3-4-png | 3:4 portrait, transparent PNG, slab centered | ported | CAPTURE-95, CAPTURE-100 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | normalization-80-fill-1600 | 80 percent fill and 1600 px floor | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
|
||||
| tag:TECH-SPEC-PIPELINE | normalization-every-photo | applies to every inventory photo | ported | CAPTURE-30, CAPTURE-110 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | dimension-overlay-v1_5 | auto-dimension overlay is v1.5, out of this spec | superseded | CAPTURE-120, CAPTURE-125 | the overlay is shipped |
|
||||
| tag:TECH-SPEC-PIPELINE | pricing | species $/bdft, recommendation, override, no-rule behavior | ported | REVIEW-25, SETTINGS-35 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | constants-code | FILL_TARGET, ASPECT, OUTPUT_PX_MIN, WIDTH_SAMPLE, INCH_STEP, decimals | superseded | CAPTURE-100, CAPTURE-105 | fill and pixel floor superseded |
|
||||
| tag:TECH-SPEC-PIPELINE | factory-bg-defaults | sheet auto, green hue/sat/val, black threshold | ported | CAPTURE-35, CAPTURE-41 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv1-green-sheet | interior green patch not touching the border survives; U2Net not invoked | ported | QUALITY-55 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv2-black-sheet | dark interior wood survives; flood fill isolates border-connected regions | ported | QUALITY-60 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv3-scale-sqft-bdft | 96 in, 1.5 in, 0.64 sqft yields 0.96 bdft | ported | QUALITY-65 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv4-width-samples | 6 inch stations on a 96 inch axis report min, max, average | ported | QUALITY-70 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv5-angled-end | one square end and one 45 degree cut; axis follows prevailing length | ported | QUALITY-75 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv6-crop | 3:4, fill at extremes, transparent PNG, shorter side floor | ported | QUALITY-80 | PoR uses 90 percent fill and the 800 px floor |
|
||||
| tag:TECH-SPEC-PIPELINE | tv7-too-small | undersized source warns, is not upscaled, not publishable until retake | ported | QUALITY-85 | PoR floor is 800 px |
|
||||
| tag:TECH-SPEC-PIPELINE | tv8-pricing | 0.96 bdft at 12.50 gives 12.00; override stored | ported | QUALITY-90 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv9-thickness-range | 1 in rounds up into the synced Woo thickness band | ported | QUALITY-95 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | tv10-rounding | half-up 2 decimal rounding cases | ported | QUALITY-100 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | assumptions | top-down first photo, user length, declared thickness, projected area | ported | CAPTURE-20, CAPTURE-70, CAPTURE-85, CAPTURE-90, CAPTURE-135 | — |
|
||||
| tag:TECH-SPEC-PIPELINE | cross-reference-table | cross-references to other docs | dropped | — | stale refs; migration |
|
||||
| tag:TECH-SPEC-PIPELINE | issue4-version-pins | exact minimum version pins tracked under issue #4 | dropped | — | tracked as an issue, not a requirement |
|
||||
@@ -1,29 +0,0 @@
|
||||
# Coverage ledger — tag:TEST-SPEC
|
||||
|
||||
Source `tag:TEST-SPEC` (`docs/TEST-SPEC.md` at tag `pre-simplification`). Rows seeded by the
|
||||
settings/auth/health pilot 2026-09-18. TEST-SPEC is reconciled LAST, against a finished PoR.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:TEST-SPEC | GC-1 | SLAB-UAT-* creates a Woo draft regardless of woo_create_status | ported | PUBLISH-10 | — |
|
||||
| tag:TEST-SPEC | GC-2 | duplicate SKU returns 409 with edit_sku and open_existing | ported | PUBLISH-15 | — |
|
||||
| tag:TEST-SPEC | GC-3 | test-woo returns ok with taxonomy and always resyncs on success | ported | SETTINGS-40 | — |
|
||||
| tag:TEST-SPEC | GC-4 | WOO_BASE_URL compose-only, GET read-only, PUT rejects a write with 422 | ported | SETTINGS-85 | — |
|
||||
| tag:TEST-SPEC | GC-5 | FastAPI refuses to start on missing or http WOO_BASE_URL | ported | SETTINGS-10 | — |
|
||||
| tag:TEST-SPEC | W-3 | taxonomy anchor unchanged on no-op, bumps only on stored-subset hash change | ported | SETTINGS-55 | sync and rebuild live in SETTINGS-50 |
|
||||
| tag:TEST-SPEC | W-4 | test-woo always runs a taxonomy resync after a successful test | duplicate | SETTINGS-40 | same rule as GC-3 |
|
||||
| tag:TEST-SPEC | AES-1 | credential encrypt/decrypt round-trips; ciphertext never appears in GET | ported | SETTINGS-75, QUALITY-125 | — |
|
||||
| tag:TEST-SPEC | AES-2 | missing SLAB_AES_KEY refuses startup, no plaintext fallback | ported | SETTINGS-25 | — |
|
||||
| tag:TEST-SPEC | API-10 | test-woo fails closed on missing credentials or non-HTTPS URL | ported | SETTINGS-80 | — |
|
||||
| tag:TEST-SPEC | FUND-21 | 401/400/404 mapped to an actionable error, not raw passthrough | ported | APP-50 | — |
|
||||
| tag:TEST-SPEC | H-1 | health 200 with woo_reachable false when credentials are not configured | ported | APP-45 | — |
|
||||
| tag:TEST-SPEC | INS-1 | health non-200 on DB or volume failure; woo_reachable false when the store is unreachable | ported | APP-45 | — |
|
||||
| tag:TEST-SPEC | UIE-8 | Settings screen: WP creds and test-woo, inference and test, pricing, brand/GEO, create status, read-only URL | ported | SETTINGS-5, SETTINGS-15, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65 | screen composition itself belongs to the contract/UX docs |
|
||||
| tag:TEST-SPEC | OPS-3 | compose health, test-woo ok, create status draft at deploy | ported | QUALITY-110 | — |
|
||||
| tag:TEST-SPEC | FUND-3, FUND-5, FUND-6, FUND-7 | keyboard tab order, visible focus ring, 320 px reflow | ported | APP-60 | found by TEST-SPEC reconciliation |
|
||||
| tag:TEST-SPEC | FUND-24 | no secret material in logs | ported | APP-65 | found by TEST-SPEC reconciliation |
|
||||
| tag:TEST-SPEC | V-3, API-8 | shared validation module hash, 412 | dropped | — | validation module ruled cut |
|
||||
| tag:TEST-SPEC | C2-2 | previous_response_id, per-slab thread | superseded | — | thread work out of MVP |
|
||||
| tag:TEST-SPEC | UI-1, UIE-9, UIE-10 | four knobs | superseded | CAPTURE-40 | two controls |
|
||||
| tag:TEST-SPEC | UIE-1, E2E-4 | manual override of computed fields | superseded | REVIEW-30, REVIEW-40 | computed fields read-only |
|
||||
| tag:TEST-SPEC | TV-6, TV-7, E2E-5 | 80 percent fill, 1600 px | superseded | CAPTURE-100, CAPTURE-105 | 90 percent, 800 px |
|
||||
@@ -1,78 +0,0 @@
|
||||
# Coverage ledger — tag:UX and tag:UX-DESIGN
|
||||
|
||||
Sources: `tag:UX` (`docs/UX.md`, SIGNED presentation spec) and `tag:UX-DESIGN`
|
||||
(`docs/UX-DESIGN.md`, non-normative wireframes). Walked 2026-09-18. UX migrates to
|
||||
`docs/contracts/UX.md`; UX-DESIGN is retired.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| tag:UX | jobs | five jobs: photo to listing, practice drafts, retake, fill fields, publish | ported | CAPTURE-5, PUBLISH-10, CAPTURE-50, METADATA-50, PUBLISH-5 | — |
|
||||
| tag:UX | photos-1-to-5 | 1 to 5 inventory photos | superseded | CAPTURE-25 | no cap |
|
||||
| tag:UX | no-calibration-photos | there are no calibration photos; only inventory photos exist | superseded | CAPTURE-25, CAPTURE-30 | the calibration photo exists |
|
||||
| tag:UX | first-photo-topdown | first photo top-down, lens parallel to the broad face | ported | CAPTURE-20 | — |
|
||||
| tag:UX | mask-overlay-only-check | the overlay on the source photo is the only quality check | ported | CAPTURE-50 | — |
|
||||
| tag:UX | knobs-four | sheet, sensitivity, edge offset, feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
|
||||
| tag:UX | knobs-live-persist | live re-run, no apply step, no server round trip, persisted | ported | CAPTURE-45, CAPTURE-145, CAPTURE-150 | — |
|
||||
| tag:UX | u2net-deferred | server matting deferred, no control | ported | CAPTURE-165 | — |
|
||||
| tag:UX | length-axis-confirm | bounding rect and axis, rotate, nearly square choice | ported | CAPTURE-60, CAPTURE-65 | — |
|
||||
| tag:UX | numbers-manual | length, thickness, SKU entered by hand | ported | CAPTURE-70 | — |
|
||||
| tag:UX | widths-min-max-avg | widths every 6 inches; min, max, average | ported | CAPTURE-80 | — |
|
||||
| tag:UX | sqft-bdft-2dp | square feet and board feet to two decimals | ported | CAPTURE-85, CAPTURE-90 | — |
|
||||
| tag:UX | bdft-identity | one square foot at one inch is one board foot, no divide-by-12 | ported | CAPTURE-90 | — |
|
||||
| tag:UX | server-no-recompute | computed in the browser and stored as sent | ported | CAPTURE-135, APP-40 | — |
|
||||
| tag:UX | photo-prep-80-1600 | 3:4 at 80 percent fill, shorter side 1600 or more | superseded | CAPTURE-100, CAPTURE-105 | PoR is 90 percent fill and an 800 px floor |
|
||||
| tag:UX | continue-calibrated | Continue uploads originals, PNGs, numbers; status calibrated | ported | CAPTURE-135, APP-5 | — |
|
||||
| tag:UX | vision-fill-threshold | Call 1 pre-fills at or above 0.7 after the draft uploads | ported | METADATA-35, METADATA-45 | — |
|
||||
| tag:UX | inference-off-empty | with inference off every field starts empty | ported | METADATA-70 | — |
|
||||
| tag:UX | inference-never-blocks | inference never blocks publish and never auto-publishes | ported | METADATA-75 | — |
|
||||
| tag:UX | call2-not-auto | Call 2 fires only when the owner taps it | ported | METADATA-55 | — |
|
||||
| tag:UX | sku-charset | SKU `[A-Z0-9-]{3,24}` | ported | REVIEW-45 | PoR allows alphanumeric (lowercase too) |
|
||||
| tag:UX | length-thickness-editable | length and thickness editable in review | ported | REVIEW-40 | — |
|
||||
| tag:UX | calc-fields-override | square feet, board feet, widths have a manual override path | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
|
||||
| tag:UX | species-exactly-one | exactly one species leaf from the synced list | ported | METADATA-10 | — |
|
||||
| tag:UX | wood-categories-1plus | at least one wood category | ported | METADATA-10 | — |
|
||||
| tag:UX | edge-one | one edge type | ported | METADATA-15 | — |
|
||||
| tag:UX | figure-1plus | at least one figure term | ported | METADATA-15 | — |
|
||||
| tag:UX | grade-1plus | at least one grade term | superseded | METADATA-15 | grade is exactly one |
|
||||
| tag:UX | thickness-band-roundup | thickness band rounds up into the synced Woo range | ported | METADATA-15 | — |
|
||||
| tag:UX | moisture-default | moisture defaults kiln-dried, never inferred | ported | METADATA-15 | — |
|
||||
| tag:UX | fig-tags-1plus | at least one fig-* tag | superseded | METADATA-30 | tags are optional |
|
||||
| tag:UX | feat-tags-0plus | zero or more feat-* tags | ported | METADATA-30 | — |
|
||||
| tag:UX | price-reco-override | recommendation from species $/bdft, override sticks | ported | REVIEW-25 | — |
|
||||
| tag:UX | price-no-rule-manual | no rule leaves the price empty; the owner types it | ported | REVIEW-25 | price is required before create |
|
||||
| tag:UX | title-desc-not-required-to-ready | title, short title, description not required to reach ready | superseded | REVIEW-10 | they are required on the product |
|
||||
| tag:UX | templates-always-run | templates always run and inject the numbers | superseded | METADATA-70, METADATA-80 | the model writes when Call 2 runs; template is the fallback |
|
||||
| tag:UX | inline-nudges | per-field inline nudge; no single submit-time list | ported | REVIEW-20 | — |
|
||||
| tag:UX | generate-text-button | Generate text runs Call 2 and fills title, short title, description | ported | METADATA-55 | — |
|
||||
| tag:UX | draft-vs-live | `woo_create_status` default draft; SLAB-UAT-* always draft | ported | PUBLISH-10, SETTINGS-65 | — |
|
||||
| tag:UX | published-not-live | published means the store create succeeded, not that it is live | ported | PUBLISH-50 | — |
|
||||
| tag:UX | publish-success | success cleans up the local draft and photos | ported | PUBLISH-25 | — |
|
||||
| tag:UX | publish-dup-sku | duplicate SKU on the field, edit SKU or open existing | ported | PUBLISH-15 | — |
|
||||
| tag:UX | publish-stale-value | stale value on the field, pick from the current list | ported | PUBLISH-20 | — |
|
||||
| tag:UX | publish-store-unreachable | plain message, no status number or stack trace, retry | ported | PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-38 | — |
|
||||
| tag:UX | publish-log-retry | the server logs every attempt; retry from the same screen | ported | PUBLISH-30 | — |
|
||||
| tag:UX | browser-matrix | iOS Safari, Android Chrome, desktop Chrome/Edge/Firefox | ported | CAPTURE-10 | — |
|
||||
| tag:UX | unsupported-hard-stop | hard stop with an update or switch link, no polyfill | ported | CAPTURE-10 | — |
|
||||
| tag:UX | deferred-features | photo quality, OCR, ruler, server measuring, U2Net, offline deferred | ported | CAPTURE-15, CAPTURE-165 | — |
|
||||
| tag:UX | where-jobs-live-table | where each job lives | dropped | — | stale refs; migration |
|
||||
| tag:UX-DESIGN | wireframes-non-normative | wireframes are non-normative and defer to UX.md | dropped | — | non-normative by its own text |
|
||||
| tag:UX-DESIGN | settings-screen | settings fields, test connections, pricing, brand/GEO | ported | SETTINGS-5, SETTINGS-20, SETTINGS-30, SETTINGS-35, SETTINGS-65 | — |
|
||||
| tag:UX-DESIGN | settings-vision-content-flags | Enable Vision and Enable Content default off | ported | SETTINGS-90 | — |
|
||||
| tag:UX-DESIGN | settings-test-inference | test connection shows stateful/stateless/fail | ported | SETTINGS-45 | mode kept as diagnostic; Call 2 does not use thread IDs |
|
||||
| tag:UX-DESIGN | generate-text-availability | Generate text appears only when content generation is enabled and reachable; disabled when the service is down | ported | METADATA-110 | — |
|
||||
| tag:UX-DESIGN | settings-reset-knobs | reset capture knobs to default | ported | CAPTURE-145 | — |
|
||||
| tag:UX-DESIGN | first-run-guidance | first-run card | dropped | — | presentation detail; fold into the UX contract |
|
||||
| tag:UX-DESIGN | capture-knobs-four | four knobs on capture | superseded | CAPTURE-40 | two knobs |
|
||||
| tag:UX-DESIGN | type-values-screen | SKU, length, thickness, computed values, done | ported | CAPTURE-70, CAPTURE-85, CAPTURE-90 | — |
|
||||
| tag:UX-DESIGN | calc-values-override | typed number wins over the computed value | superseded | REVIEW-30, REVIEW-40 | calculated fields are read-only |
|
||||
| tag:UX-DESIGN | review-screen-fields | all store fields on review | ported | REVIEW-5, METADATA-10, METADATA-15, METADATA-30, REVIEW-25 | minus grade/fig cardinality |
|
||||
| tag:UX-DESIGN | review-grade-fig-mandatory | grade and fig-* mandatory | superseded | METADATA-15, METADATA-30 | grade exactly one; tags optional |
|
||||
| tag:UX-DESIGN | ai-badge | AI badge on Call 1-filled fields | dropped | — | presentation detail; fold into the UX contract |
|
||||
| tag:UX-DESIGN | per-field-nudges | per-field nudges as the user works | ported | REVIEW-20 | — |
|
||||
| tag:UX-DESIGN | submit-time-validation | submit-time validation fires after all fields are filled | superseded | REVIEW-20 | no single submit-time list |
|
||||
| tag:UX-DESIGN | title-desc-screen | separate Title/Description screen, exit review returns to calibrated | ported | APP-10, REVIEW-40 | — |
|
||||
| tag:UX-DESIGN | publish-states | publishing, success, failed with retry | ported | PUBLISH-5, PUBLISH-25, PUBLISH-30 | — |
|
||||
| tag:UX-DESIGN | error-states | mask, axis, too-small, duplicate SKU, stale value, unreachable | ported | CAPTURE-55, CAPTURE-105, PUBLISH-15, PUBLISH-20, PUBLISH-30 | — |
|
||||
| tag:UX-DESIGN | vision-off-below-threshold | empty fields with nudges when vision is off or below threshold | ported | METADATA-45, METADATA-70 | — |
|
||||
| tag:UX-DESIGN | browser-gate-message | hard-stop message says update Safari or Chrome | superseded | CAPTURE-10 | matrix includes PC browsers |
|
||||
| tag:UX-DESIGN | uat-cases-derived | fourteen UAT cases derived from wireframes | duplicate | REVIEW-20, PUBLISH-10, PUBLISH-15, PUBLISH-20, METADATA-45, CAPTURE-105 | TEST-SPEC owns the test plan |
|
||||
@@ -1,59 +0,0 @@
|
||||
# Coverage ledger — UAT-PRACTICE.md
|
||||
|
||||
Source `cur:UAT-PRACTICE` (`docs/UAT-PRACTICE.md`, role: practice). Walked 2026-09-18.
|
||||
Four conflicts were ruled superseded; the rest port to the PoR.
|
||||
|
||||
| Source | Atom | Requirement | Disposition | PoR ID | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| cur:UAT-PRACTICE | inventory-filled | deploy/INVENTORY filled before deploy | ported | OPS-15 | — |
|
||||
| cur:UAT-PRACTICE | lan-tailscale | reachable over LAN/Tailscale | ported | OPS-5 | — |
|
||||
| cur:UAT-PRACTICE | https-only | HTTPS only, valid cert | ported | OPS-5 | — |
|
||||
| cur:UAT-PRACTICE | health-passes | health endpoint returns ok | ported | APP-30 | — |
|
||||
| cur:UAT-PRACTICE | store-url-set | WOO_BASE_URL in host .env, https | ported | SETTINGS-5 | — |
|
||||
| cur:UAT-PRACTICE | wp-store-https | WordPress store must already be on HTTPS | ported | SETTINGS-10, SETTINGS-15 | — |
|
||||
| cur:UAT-PRACTICE | dedicated-low-priv-user | dedicated low-privilege WP user | ported | SETTINGS-15 | — |
|
||||
| cur:UAT-PRACTICE | generate-password | generate application password in WP profile | dropped | — | procedure, not a requirement |
|
||||
| cur:UAT-PRACTICE | enter-creds-encrypted | username + application password in Settings, server-encrypted | ported | SETTINGS-20 | — |
|
||||
| cur:UAT-PRACTICE | test-woo-resync | test-woo reports ok and resyncs taxonomy | ported | SETTINGS-40 | — |
|
||||
| cur:UAT-PRACTICE | fastapi-sole-caller | FastAPI is the only store caller | ported | APP-25 | — |
|
||||
| cur:UAT-PRACTICE | rotate-creds | rotate application password then test-woo | duplicate | SETTINGS-40 | rotation is procedure; the verification is test-woo |
|
||||
| cur:UAT-PRACTICE | sku-prefix | practice SKU starts with `SLAB-UAT-` | ported | PUBLISH-10 | — |
|
||||
| cur:UAT-PRACTICE | always-draft | `SLAB-UAT-*` always creates a Woo draft | ported | PUBLISH-10 | — |
|
||||
| cur:UAT-PRACTICE | create-status-default-draft | `woo_create_status` defaults to draft | ported | SETTINGS-65 | — |
|
||||
| cur:UAT-PRACTICE | customers-never-see | a draft is hidden from the storefront | ported | PUBLISH-10, PUBLISH-50 | — |
|
||||
| cur:UAT-PRACTICE | supported-browsers | iOS Safari, Android Chrome, desktop Chrome/Edge/Firefox; hard stop | ported | CAPTURE-10 | — |
|
||||
| cur:UAT-PRACTICE | matrix-issue-refs | signed matrix and issue #4 provenance | dropped | — | stale provenance, not a requirement |
|
||||
| cur:UAT-PRACTICE | photo-count-1-to-5 | take 1 to 5 photos | superseded | CAPTURE-25 | PoR is one or more; no cap in code |
|
||||
| cur:UAT-PRACTICE | first-photo-topdown | first photo top-down, lens parallel | ported | CAPTURE-20 | — |
|
||||
| cur:UAT-PRACTICE | four-knobs | Sheet, Sensitivity, Edge offset, Feather | superseded | CAPTURE-40 | controls are exactly Sheet and Sensitivity |
|
||||
| cur:UAT-PRACTICE | confirm-mask | confirm the mask | ported | CAPTURE-50 | — |
|
||||
| cur:UAT-PRACTICE | length-axis | bounding rectangle, rotate, nearly square choice | ported | CAPTURE-60, CAPTURE-65 | — |
|
||||
| cur:UAT-PRACTICE | enter-length-thickness-sku | length/thickness 1/8 inch, SKU typed | ported | CAPTURE-70 | — |
|
||||
| cur:UAT-PRACTICE | computed-numbers | sqft, bdft, widths, bdft identity | ported | CAPTURE-80, CAPTURE-85, CAPTURE-90 | — |
|
||||
| cur:UAT-PRACTICE | continue-upload-status | Continue uploads originals, PNGs, numbers; status calibrated | ported | APP-5, CAPTURE-135 | — |
|
||||
| cur:UAT-PRACTICE | species-exactly-one | pick exactly one species | ported | METADATA-10 | — |
|
||||
| cur:UAT-PRACTICE | wood-categories | pick at least one wood category | ported | METADATA-10 | — |
|
||||
| cur:UAT-PRACTICE | grade-at-least-one | at least one grade | superseded | METADATA-15 | grade is exactly one |
|
||||
| cur:UAT-PRACTICE | figure-at-least-one | at least one figure | ported | METADATA-15 | — |
|
||||
| cur:UAT-PRACTICE | thickness-band-roundup | thickness band rounds up | ported | METADATA-15 | — |
|
||||
| cur:UAT-PRACTICE | moisture-default | moisture default kiln-dried | ported | METADATA-15 | — |
|
||||
| cur:UAT-PRACTICE | fig-tags-at-least-one | pick at least one `fig-*` tag | superseded | METADATA-30 | tags are optional |
|
||||
| cur:UAT-PRACTICE | feat-tags-zero-or-more | zero or more `feat-*` tags | ported | METADATA-30 | — |
|
||||
| cur:UAT-PRACTICE | price-reco-override | recommendation from species `$/bdft`, override sticks | ported | REVIEW-25 | — |
|
||||
| cur:UAT-PRACTICE | price-missing-rate | no rate starts empty, type it, publish allowed | ported | REVIEW-25 | price required before create; manual entry when no rate |
|
||||
| cur:UAT-PRACTICE | title-description-templates | type or leave for templates; templates inject numbers | ported | METADATA-70, REVIEW-10 | — |
|
||||
| cur:UAT-PRACTICE | inline-nudges-ready | inline nudge; cannot reach ready until filled | ported | REVIEW-20 | — |
|
||||
| cur:UAT-PRACTICE | sku-check-create | server checks SKU, creates if new | ported | PUBLISH-5 | — |
|
||||
| cur:UAT-PRACTICE | success-cleanup | success message, local draft and photos gone, status published | ported | PUBLISH-25, PUBLISH-50 | — |
|
||||
| cur:UAT-PRACTICE | published-not-live | published means store create succeeded, not live | ported | PUBLISH-50 | — |
|
||||
| cur:UAT-PRACTICE | store-draft-fields | confirm draft status and fields in the store | ported | PUBLISH-10, PUBLISH-40, PUBLISH-45, PUBLISH-55, METADATA-15 | — |
|
||||
| cur:UAT-PRACTICE | storefront-hidden | not visible on the live storefront | ported | PUBLISH-10, PUBLISH-50 | — |
|
||||
| cur:UAT-PRACTICE | repeat-pc | repeat the walk on a desktop browser | ported | CAPTURE-10 | — |
|
||||
| cur:UAT-PRACTICE | pass-fail-checklist | pass/fail checks table | duplicate | REVIEW-5, PUBLISH-10, CAPTURE-90 | restates rows above |
|
||||
| cur:UAT-PRACTICE | duplicate-sku-recovery | duplicate SKU recovery, edit or open existing | ported | PUBLISH-15 | — |
|
||||
| cur:UAT-PRACTICE | fail-no-success | no success message, plain error, server log, retry | ported | PUBLISH-30, PUBLISH-35, PUBLISH-37 | — |
|
||||
| cur:UAT-PRACTICE | fail-stale-value | value no longer valid, pick from current list | ported | PUBLISH-20, REVIEW-35 | — |
|
||||
| cur:UAT-PRACTICE | fail-store-unreachable | store unreachable, plain message, retry | ported | PUBLISH-30, PUBLISH-36, PUBLISH-38 | — |
|
||||
| cur:UAT-PRACTICE | cleanup-draft | leave or delete the UAT draft; app does not delete store products | dropped | — | procedure |
|
||||
| cur:UAT-PRACTICE | final-poc-publish | final POC publish is a separate gated step | dropped | — | scope/procedure |
|
||||
| cur:UAT-PRACTICE | where-rules-live | "where each rule lives" table | dropped | — | stale doc refs; migration |
|
||||
@@ -1,9 +0,0 @@
|
||||
.git
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.svelte-kit
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/README.md
|
||||
.audit
|
||||
docs/archive
|
||||
+1
-11
@@ -35,14 +35,4 @@ images/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Playwright e2e (#65) — keep README; ignore churn
|
||||
frontend/tests/e2e/artifacts/*.png
|
||||
frontend/tests/e2e/artifacts/run-log.txt
|
||||
frontend/tests/e2e/test-results/
|
||||
frontend/tests/e2e/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
|
||||
.uat-data/
|
||||
.venv-overnight/
|
||||
deploy/certs/
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Shipped milestones
|
||||
|
||||
Role: history. Historical log of shipped milestones. Cites requirement IDs; defines none.
|
||||
|
||||
Not yet written.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Project
|
||||
|
||||
Role: vision. Overall project vision and current state. Cites requirement IDs; defines none.
|
||||
|
||||
Not yet written.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Roadmap
|
||||
|
||||
Role: roadmap. Phases across all milestones. Cites requirement IDs; defines none.
|
||||
|
||||
Not yet written.
|
||||
@@ -1,27 +0,0 @@
|
||||
# M3 — roadmap
|
||||
|
||||
Role: roadmap. Objectives for v0.5 / M3. Cites requirement IDs; defines none.
|
||||
|
||||
## Objective
|
||||
|
||||
A Photos-ready slab becomes submission-ready for WooCommerce, and a practice `SLAB-UAT-*` draft
|
||||
matching the reviewed ready set exists in the store. Create works with inference off.
|
||||
|
||||
## Capabilities in scope
|
||||
|
||||
- Woo connection (WordPress application password) and taxonomy sync.
|
||||
- Review to the full ready set, with per-slab pricing and override.
|
||||
- Inference assist (Call 1 / Call 2) is optional; create is independent of inference
|
||||
(`METADATA-75`).
|
||||
- Publish: create the Woo product; `SLAB-UAT-*` always a draft.
|
||||
|
||||
## Sources of truth
|
||||
|
||||
- Behavior and acceptance: `docs/capabilities/CREATE.md`.
|
||||
- Scope and the M3 ID subset: `docs/MILESTONES.md`.
|
||||
- Interfaces: `docs/contracts/` (`API.md`, `WOO-API.md`, `DATA-MODEL.md`, `PROMPTS.md`, `UX.md`,
|
||||
`CONTENT-WOO.md`, `TEST-SPEC.md`).
|
||||
|
||||
## Exit
|
||||
|
||||
Per `docs/capabilities/CREATE.md` (`QUALITY-110`).
|
||||
@@ -1,86 +0,0 @@
|
||||
# M3 — task breakdown
|
||||
|
||||
Role: breakdown. Ordered task list for v0.5 / M3. Cites requirement IDs; defines none.
|
||||
|
||||
Objectives: `roadmap.md` in this folder. Behavior and acceptance: `docs/capabilities/CREATE.md`.
|
||||
Scope and IDs: `docs/MILESTONES.md`. Decisions live in `docs/contracts/`, not here.
|
||||
|
||||
TDD every slice: the failing test precedes its implementation; never parallelize RED and GREEN of
|
||||
the same scenario. Execute slices **serially** (one local GPU; see `AGENTS.md`). Frontend ships
|
||||
**with** the slice that first needs the screen, not as a trailing blob.
|
||||
|
||||
## Hard dependencies
|
||||
|
||||
```
|
||||
S0 full schema.sql + factory + health
|
||||
└─ S1 crypto + Settings
|
||||
├─ S2 slabs CRUD (needs S0 tables; tests seed fixture taxonomy rows)
|
||||
│ └─ S3 photos (needs slab_id)
|
||||
└─ S4 Woo + taxonomy (needs S1 credentials only — not S2/S3)
|
||||
│
|
||||
S5 pricing + title/short-desc + ready ← S2 AND S4
|
||||
├─ S6 publish ← S3 + S4 + S5
|
||||
└─ S7 inference last ← S2 + S3 + S4 + S5; not S6
|
||||
```
|
||||
|
||||
Default serial order for Atlas: **S0 → S1 → S2 → S3 → S4 → S5 → S6 → S7 → Exit**.
|
||||
|
||||
S4 does not wait on S2/S3. If S2 is blocked after S1, start S4. S7 does not wait on S6
|
||||
(`METADATA-75`).
|
||||
|
||||
## Slices
|
||||
|
||||
| Slice | Delivers | M3 IDs | Hard deps |
|
||||
|---|---|---|---|
|
||||
| S0 | factory; **full** `schema.sql` from `docs/contracts/DATA-MODEL.md` (including empty taxonomy tables); config/startup guards; health/meta; JSON logs; Dockerfile | APP-30, APP-45, APP-55, APP-65, SETTINGS-10, SETTINGS-25, OPS-25 | — |
|
||||
| S1 | AES-GCM crypto + Settings API/UI (masked, write-only secrets). Missing key refuses startup (S0); this slice encrypts/decrypts. Decrypt failure → owner re-enters credentials. | SETTINGS-5/15/20/25/30/75/85/90, QUALITY-125 | S0 |
|
||||
| S2 | slabs CRUD, optimistic concurrency, soft delete. Fixture `woo_categories` rows for FK tests. Frontend: API client + slab list. | APP-5/15/20/35/40, REVIEW-45 | S0 |
|
||||
| S3 | photos (multipart → BLOBs, original/processed, 410 after purge). Frontend: photo attach on the draft. | CAPTURE-95/105/130, PUBLISH-40 | S2 |
|
||||
| S4 | Woo client (application-password Basic) + taxonomy sync + test-woo. Fills the S0 taxonomy tables. Owns `taxonomy_anchor` / `taxonomy_last_sync_at`. Frontend: Settings test-woo. | METADATA-5/10/15/20/30, SETTINGS-40/50/51/55/60/80, QUALITY-35 | S1 |
|
||||
| S5 | species `$/bdft` rates; deterministic title + short_description; server-derived `ready`. Inference off: owner types `description` by hand (`METADATA-70`). Frontend: Review screen. | REVIEW-5/10/15/20/25/30/35/40/50, METADATA-70/80/85/90, SETTINGS-35, QUALITY-90/95 | S2, S4 |
|
||||
| S6 | publish sequence, UAT draft, duplicate/stale/partial failure; upload processed PNGs; publish-time taxonomy refresh when last sync is stale (`SETTINGS-60`). Frontend: Publish screen. | PUBLISH-5/10/15/20/25/30/31/35/36/37/38/40/45/50/55/60, SETTINGS-60 | S3, S4, S5 |
|
||||
| S7 | inference last: Call 1, Call 2 (one static seed; calculated title/short_description injected into the Call 2 prompt), prompt revision REST. Never blocks create. Frontend: Generate text control. | METADATA-35/40/45/50/55/60/65/66/75/95/100/105/106/110/115, SETTINGS-45/70 | S2, S3, S4, S5 |
|
||||
| Exit | inference-off suite, Woo-mock integration, Compose UI E2E, real `SLAB-UAT-*` draft | QUALITY-20/35/50/105/110/120 | S0–S7 |
|
||||
|
||||
IDs split on purpose (do not “move” them to one slice):
|
||||
|
||||
| ID | Slices | Split |
|
||||
|---|---|---|
|
||||
| SETTINGS-25 | S0 + S1 | S0 refuses startup without the key; S1 implements AES-GCM |
|
||||
| SETTINGS-60 | S4 + S6 | S4 owns anchor + cache rebuild; S6 refreshes on publish when stale |
|
||||
| PUBLISH-40 | S3 + S6 | S3 stores PNG BLOBs; S6 uploads them to Woo |
|
||||
| QUALITY-35 | S4 + Exit | S4 writes the Woo-mock tests; Exit re-runs them |
|
||||
|
||||
## Parked work items
|
||||
|
||||
1. The seven per-leaf Call 2 prose templates are **not M3**. S7 ships the single static `call2`
|
||||
seed in `docs/contracts/PROMPTS.md` §6. The model keys off the already-calculated title,
|
||||
short_description, fact block, photos, and metadata. Per-leaf templates are later.
|
||||
2. `Specialty cuts` is out of M3: no title pattern, no prose template, no Call 2 prompt. Owner will
|
||||
not inventory that leaf in M3.
|
||||
3. S7 ships the prompt revision REST interface (`METADATA-106`) so UAT prompt tuning is an API call
|
||||
on the trusted LAN. There is no prompt editing UI.
|
||||
4. Authentication for the prompt revision interface is `later` (`METADATA-107`), not M3.
|
||||
|
||||
## Frontend layout
|
||||
|
||||
New code stays outside the Capture union: `frontend/src/api/*`, `frontend/src/create/*`.
|
||||
|
||||
| With slice | Screen |
|
||||
|---|---|
|
||||
| S1 | Settings (store creds, inference flags, secret masking) |
|
||||
| S2 | Slab list / draft resume |
|
||||
| S5 | Review (ready set, primary wood category, price, title/short_description) |
|
||||
| S6 | Publish |
|
||||
| S7 | Generate text |
|
||||
|
||||
## Verification
|
||||
|
||||
Slice bar: `docs/contracts/TEST-SPEC.md`. Docs gate: `python tools/check_requirements.py`.
|
||||
Exit bar: `docs/MILESTONES.md` and `docs/capabilities/CREATE.md`.
|
||||
|
||||
## Not yet validated
|
||||
|
||||
- Long-horizon multi-step agentic loops (more than one or two tool round-trips).
|
||||
- M3 implementation has not started.
|
||||
|
||||
@@ -1,69 +1,197 @@
|
||||
# AGENTS.md
|
||||
# AGENTS.md — SlabUploader
|
||||
|
||||
`docs/REQUIREMENTS.md` is the only normative text. All other docs cite IDs; none define requirements.
|
||||
Working contract for coding agents. Read before writing code.
|
||||
|
||||
## Structure contract (read before creating or moving any doc)
|
||||
Status: **Gate A freeze** · signed 2026-08-31 PT · docs cut to `main` 2026-09-06 · SoT: Gitea `Ty_Tech/SlabUploader` on **gitea-atd** (not mirrors).
|
||||
|
||||
- Requirements live only in `docs/REQUIREMENTS.md`; milestone scope in `docs/MILESTONES.md`.
|
||||
- Before creating or moving any doc under `docs/`, look up its home in `docs/STRUCTURE.md`.
|
||||
- If no home exists, stop — do not create a new artifact.
|
||||
- To change a requirement: edit the single row in `REQUIREMENTS.md`; update `Test`; if scope changed, update `MILESTONES.md`. Do not create a doc.
|
||||
- Run `python tools/check_requirements.py` after any docs change; it must pass.
|
||||
---
|
||||
|
||||
## Agent runtime rules
|
||||
## 0. Where each doc lives
|
||||
|
||||
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
|
||||
| Doc | Owns |
|
||||
|---|---|
|
||||
| `docs/TECH-SPEC-PIPELINE.md` | Deterministic math, image prep, constants, test vectors |
|
||||
| `docs/ARCHITECTURE.md` | Hybrid topology, what-runs-where, secrets why, status machine |
|
||||
| `AGENTS.md` | Hard rules for coding agents (locks below) |
|
||||
| `docs/DATA-MODEL.md` | Store row / schema facts |
|
||||
| `docs/OPENAPI.md` | Wire / HTTP contracts |
|
||||
| `docs/CONTENT-WOO.md` | Listing copy and Woo payload |
|
||||
| `docs/PROMPTS.md` | Call 1 / Call 2 prompt files |
|
||||
| `docs/DEPLOYMENT.md` | Compose, Caddy, secrets placement, ops |
|
||||
| `docs/UX.md` | Presentation: what the mill owner sees and does |
|
||||
| `docs/TEST-SPEC.md` | Engineer test bar: pyramid cases, phased by gate, through PROD |
|
||||
| `docs/UAT-PRACTICE.md` | Walkable practice UAT for `SLAB-UAT-*` |
|
||||
| `docs/IMPL-PLAN.md` | Phases 0–6. Phase 2 is a deferred stub (offline PWA / OCR / ruler). |
|
||||
| `README.md` | Orientation only |
|
||||
| `docs/archive/PRD-2026-08.txt` | Frozen archive. Not a source for new work. |
|
||||
|
||||
## Build & test (verified commands)
|
||||
Live docs agree. If two live files disagree, that is a docs bug: fix the files, then continue.
|
||||
|
||||
Frontend (`frontend/`, Svelte 4 + Vite + TS, no router):
|
||||
### Known open gaps
|
||||
|
||||
```
|
||||
cd frontend && npm install
|
||||
npm run test # node --import tsx --test src/**/*.test.ts (unit)
|
||||
npm run build # tsc --noEmit && vite build (type-check + build)
|
||||
npm run test:e2e # playwright test (see Pitfalls: mocks the API)
|
||||
```
|
||||
- Issue #4: exact minimum browser version pins and deeper load-bearing API inventory. Phone + PC families are already locked in `docs/UX.md` / TECH-SPEC.
|
||||
|
||||
Backend (`backend/`, FastAPI + stdlib sqlite, no ORM, deps pinned in `backend/requirements.txt`):
|
||||
---
|
||||
|
||||
```
|
||||
cd backend && python -m pytest tests/ -q
|
||||
uvicorn backend.app:app --host 127.0.0.1 --port 18081
|
||||
```
|
||||
## 0b. Gates (human map)
|
||||
|
||||
No Makefile, no CI workflows, no Gitea CI — nothing runs in a pipeline; the only automation is the local commands above plus the human UAT walk (`deploy/uat_walk.sh`).
|
||||
| Label | Human name | What it means |
|
||||
|---|---|---|
|
||||
| Gate A | Docs freeze | Product locks written into the repo |
|
||||
| Gate B | Run-host setup | Inventory the real host, deploy skeleton |
|
||||
| Gate C | First draft listing | One Woo draft listing with inference OFF |
|
||||
|
||||
## Conventions
|
||||
Phase map: **Phase 0 = Gates A+B.** **Gate C = early Phase 1** claimable publish slice. Call 1/2 after Gate C, still inside POC. Depth: `docs/IMPL-PLAN.md`.
|
||||
|
||||
- The LIVE FastAPI routes in `backend/*.py` are the API source of truth; `docs/contracts/API.md` is aspirational and can drift (it describes a polling model the backend never shipped). Read the router, not the doc, before wiring a client.
|
||||
- Frontend is one Svelte app, no router: `App.svelte` switches on a `writable` flow store. Screens live under `frontend/src/<area>/`.
|
||||
- Commit subjects: `feat(scope): …` / `fix(scope): …` / `test(scope): …` / `chore(scope): …`, often citing a requirement ID or Gitea number.
|
||||
### Freeze provenance (Gate A)
|
||||
|
||||
## Gates (must pass before claiming work complete)
|
||||
| SHA | What locked |
|
||||
|---|---|
|
||||
| `a94d81d` | gitea-atd `Ty_Tech/SlabUploader` as remotes SoT |
|
||||
| `457711b` | AGENTS.md + hybrid IMPL Phase 0 |
|
||||
| `4ec478f` | Contradiction patches (README/PRD/Woo/DEPLOYMENT) |
|
||||
| `4d9f307` / `4e97770` | Inference review + hybrid capture pipeline (pre-freeze baseline) |
|
||||
|
||||
Run from repo root:
|
||||
Docs refactor is on `main` (cut 2026-09-06). Product locks above are unchanged.
|
||||
|
||||
```
|
||||
python tools/check_requirements.py
|
||||
python tools/check_coverage.py
|
||||
```
|
||||
### Known stale PRD lines
|
||||
|
||||
Exit 0 on both = pass.
|
||||
`docs/archive/PRD-2026-08.txt` is frozen history. These PRD lines are superseded by live docs:
|
||||
FR17 (fig-* 1+, feat-* 0+), FR19 (species $/bdft only), FR40 (client-side, not server-side
|
||||
processing), NFR server normalization timing, FR42 (env-agnostic DEPLOYMENT + deploy/INVENTORY.md).
|
||||
|
||||
## Pitfalls
|
||||
---
|
||||
|
||||
- E2E mocks the API: `tests/e2e/*.spec.ts` use Playwright `page.route('**/api/v1/**')` with fixture ids — there is **no live backend** under e2e. A green `test:e2e` is not proof the real backend works; that's the human `deploy/uat_walk.sh` (real Woo, never a mock — QUALITY-105).
|
||||
- Ports on the 5060 UAT host (192.168.1.202): FastAPI `18083`, FE `18082`/`10881` are typically already up (compose on `deploy/docker-compose.yml`). Check before binding; don't assume `18081` is free.
|
||||
- `SLAB_UAT_UNMASK_SECRETS=1` makes `GET /settings` return decrypted Woo/inference secrets — dev-test only, never set in prod (SETTINGS-75 masking is the default).
|
||||
- Never hardcode live Woo category/term ids into app code (QUALITY-105 / METADATA-5); resolve groupers by slug (`species`, `wcat`) at runtime.
|
||||
- `tsc` won't catch Svelte template errors; `npm run build` (which runs `tsc --noEmit`) is the real type gate for the frontend.
|
||||
## 1. Topology (locked — hybrid)
|
||||
|
||||
## Pointers (read these for everything else)
|
||||
**Client (SvelteKit) owns the happy path:**
|
||||
|
||||
- Docs map and shipped status: `docs/README.md`
|
||||
- Scope and ID subsets: `docs/MILESTONES.md`
|
||||
- M3 behavior: `docs/capabilities/CREATE.md`
|
||||
- Hosts, ports, UAT environment: `docs/DEPLOYMENT.md` and `deploy/INVENTORY.md`
|
||||
- Task ordering for M3: `.planning/milestones/v0.5/M3/task_breakdown.md`
|
||||
- Product invariants and acceptance criteria: the requirement rows in `docs/REQUIREMENTS.md` (cite by ID)
|
||||
- Capture 1–5 photos
|
||||
- Sheet detect + chroma-key / black threshold + **four knobs inline on capture** (sheet, sensitivity, edge offset, feather) with **live re-run** and mask overlay on the source photo
|
||||
- Mask overlay + **user confirm** before measurement
|
||||
- Length axis overlay (rotate / confirm)
|
||||
- sqft, bdft, 6" width samples
|
||||
- 3:4 transparent PNG, 80% fill at extremes, shorter side ≥ 1600 when source allows
|
||||
- Draft payload: originals + processed PNGs + length/thickness/SKU/sqft/bdft/widths
|
||||
|
||||
**Server (FastAPI) owns:**
|
||||
|
||||
- Store drafts / settings / taxonomy cache
|
||||
- U2Net "Try harder" — **deferred from POC**, not deleted. Architecture keeps the one-photo server path for awkward backgrounds post-POC. POC mask tuning is **client-side only** (sheet + four knobs, live re-run).
|
||||
- WooCommerce (taxonomy sync, SKU check, create product) — browser never calls Woo
|
||||
- Inference **proxy** (Call 1 / Call 2) — keys never in browser
|
||||
- Prompt files (server text files, read fresh)
|
||||
|
||||
Happy path never leaves the phone until the user continues after confirmed mask + numbers.
|
||||
|
||||
Deferred work and non-goals are in `docs/ARCHITECTURE.md` and `docs/IMPL-PLAN.md`.
|
||||
|
||||
Depth: `docs/ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deferred work
|
||||
|
||||
The MVP is online-only. Measuring lives in the browser. Manual SKU entry, manual length, and client-side measure/crop are the source of truth. Undersized source warns and requires retake. The server stores client-sent numbers; there is no server happy-path pipeline for the deterministic core.
|
||||
|
||||
Phase 2 is a deferred stub for offline PWA, OCR, and ruler detection. Post-POC items: LoRA / fine-tune, figure-/category-weighted pricing (v1.5), auto-dimension overlay (v1.5), bulk publish, staff roles, analytics, SKU update path. Depth: `docs/ARCHITECTURE.md` §4 and `docs/IMPL-PLAN.md` Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deterministic core (non-negotiable)
|
||||
|
||||
Encode TECH-SPEC exactly:
|
||||
|
||||
- **bdft = sqft × thickness_in** (identity: 1 sqft @ 1" = 1 bdft).
|
||||
- Units: inches in 1/8" steps; sqft/bdft 2 decimals; price half-up 2 decimals.
|
||||
- Manual length, thickness, SKU, price always win.
|
||||
- Image prep: sheet/sliders → mask confirm → length axis confirm → 3:4 PNG / 80% fill / ≥1600px when source allows.
|
||||
- Undersized source warns and requires retake. Photo is not publishable until retake.
|
||||
- Pricing: species `$/bdft` from Settings, seeded by Woo species sync; `rec = bdft × price_per_bdft`; user override sticks; missing rule leaves rec empty, manual allowed.
|
||||
- Port TECH-SPEC **TV-1 through TV-10** (including TV-10 Rounding). Client (or shared pure modules the client uses) is authoritative for math; server **stores** client-sent numbers.
|
||||
|
||||
Depth: `docs/TECH-SPEC-PIPELINE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Secrets and settings
|
||||
|
||||
**Server-side only:**
|
||||
|
||||
- Woo WordPress username + application password (dedicated low-privilege WP user for product create/edit + media)
|
||||
- Inference API key and endpoint (treat inference URL as sensitive)
|
||||
- AES master key (`SLAB_AES_KEY` env only; never in DB or repo)
|
||||
|
||||
**`WOO_BASE_URL`:** compose / host `.env` only (`https://` required). Settings shows it read-only. Not a SQLite settings row. FastAPI rejects `http://` at startup.
|
||||
|
||||
**Server:** AES-GCM at rest in SQLite for Woo Application Password + inference secrets. Settings UI POSTs username + application password to FastAPI; server encrypts. All Woo and inference calls are server-side. Rotate by revoking the application password in WP Users → Profile and re-entering in Settings. WooCommerce consumer keys (`ck_`/`cs_`) are not implemented.
|
||||
|
||||
**Browser may persist (non-secret):** sheet mode, sensitivity, edge offset, feather (localStorage OK; reset-to-default required).
|
||||
|
||||
Why/flow: `docs/ARCHITECTURE.md` §5. Ciphertext key inventory: `docs/DATA-MODEL.md` settings table.
|
||||
|
||||
---
|
||||
|
||||
## 5. Woo UAT and create status (Ty locked 2026-08-31)
|
||||
|
||||
- Store URL: **`WOO_BASE_URL`** from Docker Compose (`https://` only). Intended UAT/POC store is Whidbey Wood Store production; the compose value is the SoT, not a Settings field.
|
||||
- Mid-phase UAT creates: Woo status **draft**, SKU prefix **`SLAB-UAT-*`**
|
||||
- Settings field **`woo_create_status`**: **`draft | publish`**, default **`draft`**
|
||||
- Hard rule: if SKU matches `SLAB-UAT-*`, force **Woo draft** even if Settings say publish
|
||||
- Final POC success: one real (non-UAT) SKU → **Woo publish** only after Ty review
|
||||
- Duplicate SKU → stop; UI maps **409** to the SKU field (edit SKU or open existing)
|
||||
- After successful publish: purge originals + processed images on server
|
||||
|
||||
Woo draft/publish status is separate from slab lifecycle status (§7).
|
||||
|
||||
Payload and publish sequence depth: `docs/CONTENT-WOO.md`. Key only: DATA-MODEL `woo_create_status`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Inference (inside POC, sequenced)
|
||||
|
||||
- Feature-flagged; **full suite must pass with inference OFF**.
|
||||
- **Gate C** claimable path: inference **OFF**; manual taxonomy + deterministic numbers + Woo draft path.
|
||||
- **Call 1 (vision) / Call 2 (text) only after Gate C is green**, still inside overall POC before "done."
|
||||
- Call 1 auto only if `inference_enabled`; Call 2 never auto-fires (user taps Generate text).
|
||||
- Call 1 body: all inventory photos @1024. Taxonomy lists sent to Call 1 are **exactly the Woo-synced species / attribute sets**.
|
||||
- **Confidence 0.7 (default):** ≥ 0.7 → pre-populate species, wood categories, edge, figure, grade, and feat-* from Call 1 (**mutable**). < 0.7 → those fields stay **empty**. Ready still requires the full named set in §7. Presentation: `docs/UX.md`.
|
||||
- **Taxonomy cache:** `taxonomy_last_sync_at` vs `taxonomy_anchor` (bump only on stored-subset hash change, including deletions). Client re-pulls iff the anchor advanced. `test-woo` always syncs; publish if last_sync >1h. No per-nav sync. No Woo taxonomy timestamp.
|
||||
- Call 2 portable context: default full resend; never require `previous_response_id`. `test-inference` two-turn probe `stateful|stateless|fail`. Fail blocks **Call 2 enablement only**.
|
||||
- Templates inject deterministic numbers. LLM does not invent dimensions.
|
||||
|
||||
Prompt file mechanics: `docs/PROMPTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Slab status machine (app-internal only)
|
||||
|
||||
`draft → calibrated → ready → publishing → published` (or `failed` with detail).
|
||||
|
||||
- **calibrated:** mask + axis confirmed; length/thickness/SKU entered; client computed sqft/bdft/widths; draft uploaded.
|
||||
- **ready:** SKU, length, thickness, client sqft/bdft/widths, ≥1 inventory PNG, exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag, ≥1 grade, thickness store band (round-up), 1 moisture (default kiln-dried; not inferred), price. feat-* 0+. Title/description may be typed, generated, or templated at publish. Vision may prefill the taxonomy subset only when confidence ≥ 0.7.
|
||||
- Inference does not gate publish. Below-threshold Call 1 does not prefill; empty mandatory fields block ready.
|
||||
|
||||
Slab `draft` is separate from Woo draft (`woo_create_status`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Test and delivery expectations
|
||||
|
||||
- No feature without a passing test and shown execution output (PR/commit message).
|
||||
- Deterministic core unit-tested before UI consumes it.
|
||||
- One phase = reviewable, shippable increment; Ty exit-gates phases.
|
||||
- Phase naming: Phase 0–6 stay delivery chapters. **Phase 0 = Gates A+B.** **Gate C = early Phase 1** claimable publish slice.
|
||||
- Secrets, real Woo application passwords, and AES keys stay out of the repo.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deploy sketch
|
||||
|
||||
- Target: inventoried Docker host. Docker Compose (`frontend` + `fastapi`) + Caddy → `https://${APP_HOSTNAME}` (LAN/Tailscale).
|
||||
- Fill `deploy/INVENTORY.md` before binding ports or merging Caddy (co-resident apps can collide; bolt.diy is one example).
|
||||
- Health: `GET https://${APP_HOSTNAME}/api/health`.
|
||||
- `WOO_BASE_URL` in compose (HTTPS). Settings displays it read-only.
|
||||
- Inference is a remote configurable endpoint, not on the app host by default.
|
||||
|
||||
Depth: `docs/DEPLOYMENT.md`.
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Prototype: per-user Woo credentials
|
||||
|
||||
Branch: `proto-per-user-woo-creds` (cut from `overnight-whole-app`).
|
||||
|
||||
## Goal
|
||||
|
||||
2–3 owners can share one SlabUploader install and one Woo store URL, each using
|
||||
their own WordPress user + application password. Inference URL/key stay global.
|
||||
No full auth, ACL, or publish mutex in this prototype.
|
||||
|
||||
## Credential scheme
|
||||
|
||||
| Layer | What |
|
||||
| --- | --- |
|
||||
| Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` |
|
||||
| Gate | Full-screen `WooSetupGate` on App mount when this browser lacks device creds |
|
||||
| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls (**required**) |
|
||||
| Server | `client_for_request(conn, headers)` → override only; **no Settings fallback** on those routes |
|
||||
| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — used by **health probe** only; **never** updated by the device-creds / gate path |
|
||||
|
||||
Woo-touching calls that **require** headers (401 `woo_device_creds_required` if missing):
|
||||
|
||||
- `POST /api/v1/slabs/{id}/publish`
|
||||
- `POST /api/v1/admin/taxonomy/sync`
|
||||
- `POST /api/v1/settings/test-woo`
|
||||
|
||||
Inference / Call 2 / Settings PUT for inference keys are unchanged (shared Settings).
|
||||
Optional test escape hatch: `SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1` (prefer sending headers in tests).
|
||||
|
||||
## Setup gate
|
||||
|
||||
1. On App mount, if this browser has no device username + app password → blocking overlay.
|
||||
2. Fields: device WP user + app password (required); Woo base URL display-only from Settings;
|
||||
inference URL/key show configured / not set (masked) and may be entered → PUT Settings
|
||||
for inference only.
|
||||
3. Primary CTA: **Save device creds & Test Woo**. Gate dismisses only after Test Woo succeeds.
|
||||
4. Returning browser with device creds → skip gate.
|
||||
5. Publish (or Test Woo) Woo auth failure / missing headers → reopen gate.
|
||||
|
||||
## Flow (request → WooClient)
|
||||
|
||||
1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`).
|
||||
2. `withWooHeaders()` merges `X-Woo-*` onto the fetch.
|
||||
3. Backend `client_for_request` builds a **new** `WooClient` for that request only
|
||||
(Basic auth from override plaintext). Missing headers → `WooDeviceCredsRequired`.
|
||||
4. Publish / taxonomy / media use that client instance — no process-global
|
||||
“current user”.
|
||||
|
||||
## UAT with two browsers
|
||||
|
||||
1. Deploy this branch to the **proto** stack only (`slab-proto-woo-creds` on
|
||||
`:10892` / `:10893`). Do **not** touch overnight `:10890`; never `:10880`.
|
||||
2. In WordPress, create two application passwords for two WP users on the same store.
|
||||
3. Browser A (or profile A): setup gate → enter user A + app password →
|
||||
**Save device creds & Test Woo** → gate dismisses.
|
||||
4. Browser B: same for user B (separate localStorage).
|
||||
5. Confirm Test Woo succeeds for both, and that device passwords were not written
|
||||
into shop-default Settings.
|
||||
6. Publish different slabs from each browser (do not work the same slab concurrently).
|
||||
7. Optional: clear device creds in Settings → reload → gate reappears.
|
||||
|
||||
## Risks left (prototype)
|
||||
|
||||
- **LAN can forge headers** — there is no app login; any client on the network can
|
||||
send arbitrary `X-Woo-*` values. Treat as trusted LAN only.
|
||||
- Device passwords sit in **localStorage** (XSS-readable). Acceptable for this
|
||||
prototype only.
|
||||
- No slab ownership ACL / taxonomy mutex / publish CAS — operators must not edit
|
||||
the same slab at once.
|
||||
- Taxonomy cache remains shared; a sync from either user refreshes the same rows.
|
||||
@@ -1,22 +1,56 @@
|
||||
# SlabUploader
|
||||
|
||||
Lists one-of-a-kind wood slabs on Whidbey Wood Store.
|
||||
Browser app for listing one-of-a-kind wood slabs on the Whidbey Wood Store.
|
||||
|
||||
Start at [`docs/README.md`](docs/README.md) for goals, what already shipped, and Milestone 3.
|
||||
You photograph a slab, confirm the cut and the numbers, review the listing, and create the WooCommerce product. The browser does the measuring and crop. The server stores the draft, talks to the store, and (later) runs vision. Runs on phones and PCs.
|
||||
|
||||
| Artifact | File |
|
||||
Owner: Ty (Whidbey). Store: www.whidbeywoodstore.com. Runs in Docker Compose behind Caddy on a host you inventory first (`APP_HOSTNAME`).
|
||||
|
||||
## Where we are
|
||||
|
||||
Gate A freeze signed 2026-08-31. All live docs sit on `main`. Read `AGENTS.md` before writing code.
|
||||
|
||||
The bar for this first version: a real slab, photographed on a phone or a PC, listed on the real store. Practice listings are WooCommerce **drafts** with SKUs that start `SLAB-UAT-`. After Ty reviews, one real SKU goes **live**. Online only for now; offline capture is deferred.
|
||||
|
||||
Build order is in `docs/IMPL-PLAN.md`. Empty app on the real host first, then the first draft listing, then vision.
|
||||
|
||||
## Documentation
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| Docs map | `docs/README.md` |
|
||||
| Structure contract | `docs/STRUCTURE.md` |
|
||||
| Requirements (normative) | `docs/REQUIREMENTS.md` |
|
||||
| Milestones (ID subsets) | `docs/MILESTONES.md` |
|
||||
| Product overview | `docs/PRODUCT.md` |
|
||||
| Invariants and agent context | `AGENTS.md` |
|
||||
| Milestone 1 Capture (shipped) | `docs/capabilities/CAPTURE.md` |
|
||||
| Milestone 2 Photos (shipped) | `docs/capabilities/PHOTOS.md` |
|
||||
| Milestone 3 Create (paused — requirements reconciliation) | `docs/capabilities/CREATE.md` |
|
||||
| UAT practice walk | `docs/UAT-PRACTICE.md` |
|
||||
| Hosting | `deploy/` |
|
||||
| `AGENTS.md` | Hard rules. Read first. |
|
||||
| `docs/ARCHITECTURE.md` | Hybrid topology, what runs where, secrets, status machine |
|
||||
| `docs/TECH-SPEC-PIPELINE.md` | Math, image pipeline, constants, test vectors TV-1–TV-10 |
|
||||
| `docs/DATA-MODEL.md` | SQLite tables, slab statuses, settings |
|
||||
| `docs/OPENAPI.md` | Browser to server API |
|
||||
| `docs/CONTENT-WOO.md` | Titles, descriptions, WooCommerce payload |
|
||||
| `docs/PROMPTS.md` | Call 1 / Call 2 prompt files (server text files) |
|
||||
| `docs/DEPLOYMENT.md` | Compose, Caddy, secrets placement, ops |
|
||||
| `docs/UX.md` | Presentation: what the mill owner sees and does |
|
||||
| `docs/UAT-PRACTICE.md` | Walkable practice UAT for `SLAB-UAT-*` |
|
||||
| `docs/IMPL-PLAN.md` | Phases 0–6, exit gates, deferred stub (Phase 2) |
|
||||
| `deploy/INVENTORY.md` | Host worksheet before binding ports |
|
||||
| `docs/archive/PRD-2026-08.txt` | Frozen history, not a source for new work |
|
||||
|
||||
Owner: Ty. Store: https://www.whidbeywoodstore.com/
|
||||
Repo: `Ty_Tech/SlabUploader`
|
||||
## Short map
|
||||
|
||||
- Client (SvelteKit) owns the happy path: mask knobs, sqft, bdft, 3:4 PNG. Server (FastAPI) stores drafts, talks to WooCommerce, and proxies vision. Server matting ("Try harder") is deferred from POC; POC mask tuning is client-side only.
|
||||
- Board feet = square feet × thickness in inches. No divide-by-12. Test vectors TV-1 through TV-10 run in the browser.
|
||||
- Secrets never sit in the browser. Encrypted on the server. Store login is a WordPress application password on a low-privilege user; WooCommerce consumer keys are not implemented.
|
||||
- Practice SKUs `SLAB-UAT-*` are always drafts, even if Settings say publish.
|
||||
- Vision stays off until the first draft listing works. Species suggestion and Generate text come after, still inside this project.
|
||||
- Two containers (`frontend`, `fastapi`) plus Caddy. One SQLite, image volumes. Inventory the host first.
|
||||
|
||||
Where deferred items live: `docs/ARCHITECTURE.md` §4 and `docs/IMPL-PLAN.md` Phase 2 (offline PWA, OCR, ruler — deferred stub).
|
||||
|
||||
## Repo
|
||||
|
||||
Source of truth: Gitea `Ty_Tech/SlabUploader` on gitea-atd
|
||||
(`https://gitea.vps1.afterthedemo.com/Ty_Tech/SlabUploader`). TrueNAS and GitHub copies are not the source of truth for this work.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Read `AGENTS.md` and Phase 0 in `docs/IMPL-PLAN.md`.
|
||||
2. Build the client math modules and a thin FastAPI health/settings app. Measuring lives in the browser.
|
||||
3. Ty signs each phase.
|
||||
4. Live docs on `main` are the source of truth. The archive PRD is frozen history only.
|
||||
|
||||
+12
-15
@@ -1,24 +1,21 @@
|
||||
# S0 minimal backend Dockerfile (OPS-25: pinned, not latest)
|
||||
FROM python:3.14.3-slim-bookworm
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# system deps minimal
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r /app/requirements.txt
|
||||
COPY backend/ .
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
# Create data and images directories
|
||||
RUN mkdir -p /data /images /app/prompts
|
||||
|
||||
ENV PYTHONPATH=/app \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
# Bind mount for prompts (read-only)
|
||||
# Volumes for data and images are handled by docker-compose
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# non-root? later; for S0 root ok
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""SlabUploader backend package (S0 foundation)."""
|
||||
__all__ = ["app", "db"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Alembic configuration for SlabUploader
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = sqlite:///data/slab.db
|
||||
|
||||
# Templates used to generate migration files
|
||||
file_template = %%(year)s_%%(month)s_%%(day)s_%%(rev)s_%%(slug)s
|
||||
|
||||
# Timezone for file timestamps
|
||||
timezone = UTC
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = INFO
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %Y-%m-%dT%H:%M:%S%z
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Alembic environment configuration.
|
||||
|
||||
Sets up sys.path so that app packages are importable when running
|
||||
alembic from the backend directory.
|
||||
|
||||
Alembic uses sync connections for migrations even with an async app.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure the backend root is on the path so app.* is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Import models so that Base.metadata includes all tables
|
||||
from app.db.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode using sync engine."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=Base.metadata,
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=Base.metadata,
|
||||
render_as_batch=True,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "batch"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,178 @@
|
||||
"""initial schema.
|
||||
|
||||
Revision ID: initial
|
||||
Revises:
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# slabs
|
||||
op.create_table(
|
||||
"slabs",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("sku", sa.String(length=24), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="draft"),
|
||||
sa.Column("length_in", sa.Float(), nullable=False),
|
||||
sa.Column("thickness_in", sa.Float(), nullable=False),
|
||||
sa.Column("sqft", sa.Float(), nullable=True),
|
||||
sa.Column("bdft", sa.Float(), nullable=True),
|
||||
sa.Column("width_min_in", sa.Float(), nullable=True),
|
||||
sa.Column("width_max_in", sa.Float(), nullable=True),
|
||||
sa.Column("width_avg_in", sa.Float(), nullable=True),
|
||||
sa.Column("species_id", sa.Integer(), nullable=True),
|
||||
sa.Column("wood_category_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("edge_type_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("figure_term_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("grade_term_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("thickness_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("moisture_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("fig_tag_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("feat_tag_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("price_per_bdft", sa.Float(), nullable=True),
|
||||
sa.Column("price", sa.Float(), nullable=False),
|
||||
sa.Column("price_source", sa.Text(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("short_title", sa.Text(), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("content_source", sa.Text(), nullable=True),
|
||||
sa.Column("client_rev", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("server_rev", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
|
||||
sa.Column("updated_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
|
||||
sa.Column("inference_thread_id", sa.Text(), nullable=True),
|
||||
sa.CheckConstraint("length_in >= 0"),
|
||||
sa.CheckConstraint("thickness_in >= 0"),
|
||||
sa.CheckConstraint("price >= 0"),
|
||||
sa.UniqueConstraint("sku"),
|
||||
)
|
||||
op.create_index("idx_slabs_status", "slabs", ["status"])
|
||||
op.create_index("idx_slabs_updated_at", "slabs", ["updated_at"])
|
||||
|
||||
# slab_photos
|
||||
op.create_table(
|
||||
"slab_photos",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("role", sa.Text(), nullable=True),
|
||||
sa.Column("seq", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("original_path", sa.Text(), nullable=True),
|
||||
sa.Column("processed_path", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.ForeignKeyConstraint(["slab_id"], ["slabs.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
# pricing_rules
|
||||
op.create_table(
|
||||
"pricing_rules",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("species", sa.Text(), nullable=False),
|
||||
sa.Column("price_per_bdft", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.UniqueConstraint("species"),
|
||||
)
|
||||
|
||||
# woo_categories
|
||||
op.create_table(
|
||||
"woo_categories",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_attributes
|
||||
op.create_table(
|
||||
"woo_attributes",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.Text(), server_default="select"),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_attribute_terms
|
||||
op.create_table(
|
||||
"woo_attribute_terms",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("attribute_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["attribute_id"], ["woo_attributes.woo_id"]),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_tags
|
||||
op.create_table(
|
||||
"woo_tags",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# settings
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("key", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("value_enc", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
# sync_log
|
||||
op.create_table(
|
||||
"sync_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=True),
|
||||
sa.Column("sku", sa.Text(), nullable=True),
|
||||
sa.Column("woo_product_id", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.Text(), nullable=True),
|
||||
sa.Column("http_status", sa.Integer(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
sa.Column("payload_hash", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
# inference_log
|
||||
op.create_table(
|
||||
"inference_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=True),
|
||||
sa.Column("call", sa.Text(), nullable=True),
|
||||
sa.Column("request_payload", sa.Text(), nullable=True),
|
||||
sa.Column("response_payload", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("inference_log")
|
||||
op.drop_table("sync_log")
|
||||
op.drop_table("settings")
|
||||
op.drop_table("woo_tags")
|
||||
op.drop_table("woo_attribute_terms")
|
||||
op.drop_table("woo_attributes")
|
||||
op.drop_table("woo_categories")
|
||||
op.drop_table("pricing_rules")
|
||||
op.drop_table("slab_photos")
|
||||
op.drop_index("idx_slabs_updated_at", table_name="slabs")
|
||||
op.drop_index("idx_slabs_status", table_name="slabs")
|
||||
op.drop_table("slabs")
|
||||
-314
@@ -1,314 +0,0 @@
|
||||
"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25).
|
||||
Base /api/v1; health at /api/health (non-200 on storage not writable).
|
||||
Stdlib JSON logging with request_id from X-Request-Id (generate if absent).
|
||||
Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY.
|
||||
No secrets in logs. Minimal, no S1+.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
||||
from . import settings as settings_mod
|
||||
|
||||
__version__ = "0.5.0-m3"
|
||||
|
||||
# --- Structured JSON logging (stdlib only) ---
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
"""JSON lines; redacts any secret-looking content (APP-65)."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
rid = getattr(record, "request_id", None) or request_id_var.get("")
|
||||
msg = record.getMessage()
|
||||
# Redact secrets: never log creds, keys, aes material
|
||||
lower = msg.lower()
|
||||
if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")):
|
||||
msg = "[REDACTED secret material]"
|
||||
data: dict[str, Any] = {
|
||||
"ts": self.formatTime(record, self.datefmt),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"request_id": rid,
|
||||
"msg": msg,
|
||||
}
|
||||
if record.exc_info:
|
||||
data["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _configure_logging() -> None:
|
||||
level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(getattr(logging, level, logging.INFO))
|
||||
# uvicorn access logs also json? for S0 keep simple; they inherit
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
_configure_logging()
|
||||
logger = logging.getLogger("slab.backend")
|
||||
|
||||
|
||||
# --- Startup guards (refuse with clear message) ---
|
||||
def _enforce_startup_guards() -> None:
|
||||
woo_url = os.getenv("WOO_BASE_URL")
|
||||
if not woo_url:
|
||||
msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
if not woo_url.startswith("https://"):
|
||||
msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
if not os.getenv("SLAB_AES_KEY"):
|
||||
msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
# db/images envs are required by db.py on use; guards here only per spec
|
||||
|
||||
|
||||
_enforce_startup_guards()
|
||||
|
||||
|
||||
# --- Request ID middleware (threads X-Request-Id or generates) --
|
||||
|
||||
|
||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
token = request_id_var.set(rid)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-Id"] = rid
|
||||
return response
|
||||
finally:
|
||||
request_id_var.reset(token)
|
||||
|
||||
|
||||
# --- App ---
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("startup: applying schema (idempotent)")
|
||||
try:
|
||||
init_db()
|
||||
logger.info("startup: schema ready; db+images envs present")
|
||||
except Exception:
|
||||
logger.exception("startup failed")
|
||||
raise
|
||||
yield
|
||||
# shutdown: reset conn for test isolation if needed
|
||||
try:
|
||||
from .db import _reset_for_tests
|
||||
_reset_for_tests()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SlabUploader",
|
||||
version=__version__,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(RequestIdMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _unwrap_error(request: Request, exc: HTTPException):
|
||||
if isinstance(exc.detail, dict) and "error" in exc.detail:
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
|
||||
|
||||
#84: woo_reachable is a real authenticated probe (signal only — store outage
|
||||
does not fail health).
|
||||
"""
|
||||
db_ok = probe_writable()
|
||||
img_ok = probe_images_writable()
|
||||
if not (db_ok and img_ok):
|
||||
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
|
||||
raise HTTPException(status_code=503, detail="storage not writable")
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
"woo_reachable": _probe_woo_reachable(),
|
||||
}
|
||||
|
||||
|
||||
def _probe_woo_reachable() -> bool:
|
||||
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
|
||||
|
||||
Storage health never depends on this result. Missing creds → False.
|
||||
"""
|
||||
try:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
if not base or not username or not enc_password:
|
||||
return False
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
# One lightweight authenticated call (categories page 1).
|
||||
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
|
||||
return code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/v1/meta")
|
||||
async def meta() -> dict[str, Any]:
|
||||
"""Per API.md §3."""
|
||||
return {"version": __version__}
|
||||
|
||||
|
||||
@app.get("/api/v1/settings")
|
||||
async def get_settings() -> dict[str, Any]:
|
||||
return settings_mod.get_settings()
|
||||
|
||||
|
||||
@app.put("/api/v1/settings")
|
||||
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return settings_mod.update_settings(payload)
|
||||
|
||||
|
||||
from . import taxonomy as taxonomy_mod
|
||||
from .woo import (
|
||||
WooClient,
|
||||
WooAuthError,
|
||||
WooConnectionError,
|
||||
WooCredentialsMissing,
|
||||
WooDeviceCredsRequired,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
client_for_request,
|
||||
settings_woo_creds,
|
||||
)
|
||||
|
||||
|
||||
def _woo_credentials() -> tuple[str, str | None, str | None]:
|
||||
"""Shop-default Woo creds (Settings). Used by health probe only."""
|
||||
return settings_woo_creds(get_connection())
|
||||
|
||||
|
||||
@app.post("/api/v1/settings/test-woo")
|
||||
async def test_woo(request: Request) -> dict[str, Any]:
|
||||
"""Probe Woo + sync taxonomy. Requires X-Woo-Username / X-Woo-App-Password
|
||||
(device creds); never reads shop-default Settings for this route.
|
||||
"""
|
||||
try:
|
||||
client = client_for_request(get_connection(), request.headers)
|
||||
except WooDeviceCredsRequired as e:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content=_error("woo_device_creds_required", str(e)),
|
||||
)
|
||||
except WooCredentialsMissing as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": str(e),
|
||||
"taxonomy_anchor": None,
|
||||
"taxonomy_last_sync_at": None,
|
||||
}
|
||||
try:
|
||||
result = taxonomy_mod.sync(client, get_connection())
|
||||
except WooAuthError as e:
|
||||
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
except WooForbiddenError as e:
|
||||
return {"ok": False, "detail": f"request blocked by store (403): {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
except WooConnectionError as e:
|
||||
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
except WooError as e:
|
||||
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "taxonomy synced",
|
||||
"taxonomy_anchor": result["taxonomy_anchor"],
|
||||
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/taxonomy")
|
||||
async def get_taxonomy() -> dict[str, Any]:
|
||||
return taxonomy_mod.get_cached(get_connection())
|
||||
|
||||
|
||||
@app.post("/api/v1/admin/taxonomy/sync")
|
||||
async def post_taxonomy_sync(request: Request) -> dict[str, Any]:
|
||||
"""Refresh taxonomy cache. Requires X-Woo-* device headers (no Settings fallback)."""
|
||||
try:
|
||||
client = client_for_request(get_connection(), request.headers)
|
||||
except WooDeviceCredsRequired as e:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content=_error("woo_device_creds_required", str(e)),
|
||||
)
|
||||
except WooCredentialsMissing as e:
|
||||
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
||||
try:
|
||||
result = taxonomy_mod.sync(client, get_connection())
|
||||
except WooError as e:
|
||||
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "taxonomy synced",
|
||||
"taxonomy_anchor": result["taxonomy_anchor"],
|
||||
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
|
||||
}
|
||||
|
||||
|
||||
# expose for tests
|
||||
|
||||
from .slabs import router as slabs_router
|
||||
app.include_router(slabs_router)
|
||||
|
||||
from .photos import router as photos_router
|
||||
app.include_router(photos_router)
|
||||
|
||||
from .pricing import router as pricing_router
|
||||
app.include_router(pricing_router)
|
||||
|
||||
from .review import router as review_router
|
||||
app.include_router(review_router)
|
||||
|
||||
from .publish import router as publish_router
|
||||
app.include_router(publish_router)
|
||||
|
||||
from .inference import router as inference_router
|
||||
app.include_router(inference_router)
|
||||
|
||||
# expose for tests
|
||||
__all__ = ["app", "__version__"]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Health endpoint router."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
async def health_check() -> dict:
|
||||
"""Deploy health and smoke path.
|
||||
|
||||
Returns:
|
||||
Health status with woo_reachable flag. When Woo credentials
|
||||
are not configured, woo_reachable is False.
|
||||
|
||||
Contract (OPENAPI):
|
||||
GET /api/health → { "status": "ok", "version": "...", "woo_reachable": bool }
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"woo_reachable": False,
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Settings endpoint: GET returns masked settings, PUT stores encrypted secrets.
|
||||
|
||||
Per OPENAPI.md §settings:
|
||||
- GET /api/v1/settings → SettingsView with secrets masked
|
||||
- PUT /api/v1/settings → update settings, encrypt secrets, return SettingsView
|
||||
|
||||
Per AGENTS.md §4:
|
||||
- Woo credentials encrypted AES-GCM at rest
|
||||
- Ciphertext never appears in GET settings
|
||||
- Missing SLAB_AES_KEY refuses startup
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Setting
|
||||
from app.security.aes import decrypt_setting, encrypt_setting, get_aes_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
"""Partial settings update payload.
|
||||
|
||||
Woo consumer keys and woo_base_url are rejected (woo_base_url is compose-only).
|
||||
Consumer keys are included as fields so they can be explicitly rejected.
|
||||
"""
|
||||
|
||||
woo_base_url: str | None = None
|
||||
woo_wp_username: str | None = None
|
||||
woo_app_password: str | None = None
|
||||
woo_consumer_key: str | None = None
|
||||
woo_consumer_secret: str | None = None
|
||||
inference_base_url: str | None = None
|
||||
inference_api_key: str | None = None
|
||||
inference_model: str | None = None
|
||||
inference_enabled: bool | None = None
|
||||
content_llm_enabled: bool | None = None
|
||||
brand_voice: str | None = None
|
||||
geo_context: str | None = None
|
||||
woo_create_status: str | None = Field(default=None, description="'draft' | 'publish'")
|
||||
aspect_ratio: str | None = None
|
||||
output_px: int | None = None
|
||||
output_px_min: int | None = None
|
||||
fill_target: float | None = None
|
||||
|
||||
|
||||
class SettingsView(BaseModel):
|
||||
"""Settings read model. Secrets masked."""
|
||||
|
||||
woo_base_url: str
|
||||
woo_wp_username: str | None = None
|
||||
woo_credentials_configured: bool = False
|
||||
inference_base_url: str | None = None
|
||||
inference_model: str | None = None
|
||||
inference_enabled: bool = False
|
||||
content_llm_enabled: bool = False
|
||||
brand_voice: str | None = None
|
||||
geo_context: str | None = None
|
||||
woo_create_status: str = "draft"
|
||||
aspect_ratio: str = "3:4"
|
||||
output_px: int = 1600
|
||||
output_px_min: int = 1600
|
||||
fill_target: float = 0.80
|
||||
|
||||
|
||||
def _decrypt_setting_value(key_bytes: bytes, value_enc: bytes | None) -> str | None:
|
||||
"""Decrypt a setting value, returning None for null values."""
|
||||
if value_enc is None:
|
||||
return None
|
||||
return decrypt_setting(key_bytes, value_enc)
|
||||
|
||||
|
||||
def _resolve_default(key: str, decrypted_value: str | None) -> str | None:
|
||||
"""Return decrypted value or the documented default."""
|
||||
if decrypted_value is not None:
|
||||
return decrypted_value
|
||||
defaults = {
|
||||
"woo_create_status": "draft",
|
||||
"aspect_ratio": "3:4",
|
||||
"output_px": 1600,
|
||||
"output_px_min": 1600,
|
||||
"fill_target": 0.80,
|
||||
}
|
||||
return defaults.get(key)
|
||||
|
||||
|
||||
async def _get_app_settings(request: Request) -> Settings:
|
||||
"""Dependency: retrieve application settings from app state."""
|
||||
return request.app.state.settings
|
||||
|
||||
|
||||
async def _build_settings_view(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
|
||||
"""Build a SettingsView from the database and app settings."""
|
||||
key_bytes = get_aes_key(app_settings.aes_key)
|
||||
|
||||
session = await get_session()
|
||||
rows = await session.execute(text("SELECT key, value_enc FROM settings"))
|
||||
all_rows = rows.all()
|
||||
await session.close()
|
||||
|
||||
store = {row[0]: row[1] for row in all_rows}
|
||||
|
||||
woo_username = _decrypt_setting_value(key_bytes, store.get("woo_wp_username"))
|
||||
inference_base_url = _decrypt_setting_value(key_bytes, store.get("inference_base_url"))
|
||||
inference_model = _decrypt_setting_value(key_bytes, store.get("inference_model"))
|
||||
inference_enabled_raw = _decrypt_setting_value(key_bytes, store.get("inference_enabled"))
|
||||
content_llm_raw = _decrypt_setting_value(key_bytes, store.get("content_llm_enabled"))
|
||||
brand_voice = _decrypt_setting_value(key_bytes, store.get("brand_voice"))
|
||||
geo_context = _decrypt_setting_value(key_bytes, store.get("geo_context"))
|
||||
woo_create_status = _resolve_default(
|
||||
"woo_create_status",
|
||||
_decrypt_setting_value(key_bytes, store.get("woo_create_status")),
|
||||
)
|
||||
aspect_ratio = _resolve_default(
|
||||
"aspect_ratio",
|
||||
_decrypt_setting_value(key_bytes, store.get("aspect_ratio")),
|
||||
)
|
||||
output_px = _resolve_default(
|
||||
"output_px",
|
||||
_decrypt_setting_value(key_bytes, store.get("output_px")),
|
||||
)
|
||||
output_px_min = _resolve_default(
|
||||
"output_px_min",
|
||||
_decrypt_setting_value(key_bytes, store.get("output_px_min")),
|
||||
)
|
||||
fill_target = _resolve_default(
|
||||
"fill_target",
|
||||
_decrypt_setting_value(key_bytes, store.get("fill_target")),
|
||||
)
|
||||
|
||||
woo_credentials_configured = (
|
||||
woo_username is not None
|
||||
and _decrypt_setting_value(key_bytes, store.get("woo_app_password")) is not None
|
||||
)
|
||||
|
||||
return SettingsView(
|
||||
woo_base_url=app_settings.woo_base_url,
|
||||
woo_wp_username=woo_username,
|
||||
woo_credentials_configured=woo_credentials_configured,
|
||||
inference_base_url=inference_base_url,
|
||||
inference_model=inference_model,
|
||||
inference_enabled=(
|
||||
inference_enabled_raw == "true" if inference_enabled_raw is not None else False
|
||||
),
|
||||
content_llm_enabled=(
|
||||
content_llm_raw == "true" if content_llm_raw is not None else False
|
||||
),
|
||||
brand_voice=brand_voice,
|
||||
geo_context=geo_context,
|
||||
woo_create_status=woo_create_status,
|
||||
aspect_ratio=aspect_ratio,
|
||||
output_px=output_px,
|
||||
output_px_min=output_px_min,
|
||||
fill_target=fill_target,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/settings", response_model=SettingsView)
|
||||
async def get_settings(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
|
||||
"""GET settings. Secrets (app password, inference key) are never returned.
|
||||
|
||||
Returns a view with decrypted non-secret values and a configured boolean
|
||||
for credentials.
|
||||
"""
|
||||
return await _build_settings_view(app_settings)
|
||||
|
||||
|
||||
@router.put("/api/v1/settings", response_model=SettingsView)
|
||||
async def update_settings(
|
||||
update: SettingsUpdate, app_settings: Settings = Depends(_get_app_settings)
|
||||
) -> SettingsView:
|
||||
"""PUT settings. Encrypt secrets, store non-secrets. Return updated view.
|
||||
|
||||
Rejects consumer key fields. Returns 422 for invalid woo_create_status.
|
||||
"""
|
||||
# Reject consumer keys and woo_base_url (compose-only, read-only in UI)
|
||||
payload = update.model_dump(exclude_unset=True)
|
||||
if "woo_consumer_key" in payload or "woo_consumer_secret" in payload:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="WooCommerce consumer keys are not supported. Use WordPress application passwords.",
|
||||
)
|
||||
if "woo_base_url" in payload:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="woo_base_url is set via compose environment, not editable via settings.",
|
||||
)
|
||||
|
||||
# Validate woo_create_status
|
||||
if update.woo_create_status is not None and update.woo_create_status not in (
|
||||
"draft",
|
||||
"publish",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="woo_create_status must be 'draft' or 'publish'",
|
||||
)
|
||||
|
||||
key_bytes = get_aes_key(app_settings.aes_key)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
session = await get_session()
|
||||
|
||||
for field, value in payload.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
ct = encrypt_setting(key_bytes, str(value))
|
||||
setting = await session.execute(
|
||||
text("SELECT key FROM settings WHERE key = :key"), {"key": field}
|
||||
)
|
||||
existing = setting.fetchone()
|
||||
if existing:
|
||||
await session.execute(
|
||||
text("UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key"),
|
||||
{"val": ct, "now": now, "key": field},
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
text("INSERT INTO settings (key, value_enc, updated_at) VALUES (:key, :val, :now)"),
|
||||
{"key": field, "val": ct, "now": now},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Return updated view
|
||||
return await _build_settings_view(app_settings)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Slabs API: create calibrated draft from multipart upload.
|
||||
|
||||
Per OPENAPI.md §slabs:
|
||||
- POST /api/v1/slabs accepts multipart/form-data with:
|
||||
- data: JSON SlabCreate payload
|
||||
- files[]: original image files
|
||||
- meta[]: photo metadata JSON objects
|
||||
- Returns 201 with status: calibrated and server_rev
|
||||
- Server stores client-sent numbers only; no server-side recompute.
|
||||
|
||||
Per DATA-MODEL.md:
|
||||
- slabs table: client-generated UUID, sku unique, status calibrated
|
||||
- slab_photos table: slab_id FK, kind inventory, role, seq, original_path
|
||||
- server_rev = 1 on create
|
||||
"""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Slab, SlabPhoto, SlabStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PhotoMeta(BaseModel):
|
||||
"""Photo metadata from the multipart request."""
|
||||
|
||||
kind: str = Field(default="inventory")
|
||||
role: str | None = None
|
||||
seq: int = 0
|
||||
|
||||
@field_validator("kind")
|
||||
@classmethod
|
||||
def validate_kind(cls, v: str) -> str:
|
||||
if v != "inventory":
|
||||
raise ValueError("kind must be 'inventory'")
|
||||
return v
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in ("topdown", "extra"):
|
||||
raise ValueError("role must be 'topdown' or 'extra'")
|
||||
return v
|
||||
|
||||
|
||||
class SlabCreateData(BaseModel):
|
||||
"""Client-sent slab data (the `data` multipart field).
|
||||
|
||||
Mirrors the OPENAPI.md SlabCreate schema. All numeric fields are
|
||||
client-computed; the server stores them as-is without recomputation.
|
||||
"""
|
||||
|
||||
id: str = Field(description="Client-generated UUID")
|
||||
sku: str = Field(min_length=3, max_length=24, pattern=r"^[A-Z0-9-]+$")
|
||||
length_in: float = Field(ge=0)
|
||||
thickness_in: float = Field(ge=0)
|
||||
sqft: float | None = Field(default=None, ge=0)
|
||||
bdft: float | None = Field(default=None, ge=0)
|
||||
width_min_in: float | None = Field(default=None, ge=0)
|
||||
width_max_in: float | None = Field(default=None, ge=0)
|
||||
width_avg_in: float | None = Field(default=None, ge=0)
|
||||
species_id: int | None = None
|
||||
wood_category_ids: list[int] | None = None
|
||||
edge_type_term_id: int | None = None
|
||||
figure_term_ids: list[int] | None = None
|
||||
grade_term_ids: list[int] | None = None
|
||||
thickness_term_id: int | None = None
|
||||
moisture_term_id: int | None = None
|
||||
fig_tag_ids: list[int] | None = None
|
||||
feat_tag_ids: list[int] | None = None
|
||||
price_per_bdft: float | None = Field(default=None, ge=0)
|
||||
price: float = Field(ge=0)
|
||||
price_source: str | None = Field(default=None, pattern=r"^(recommendation|override)$")
|
||||
title: str | None = None
|
||||
short_title: str | None = None
|
||||
description: str | None = None
|
||||
content_source: str | None = Field(default=None, pattern=r"^(manual|llm)$")
|
||||
client_rev: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class PhotoRead(BaseModel):
|
||||
"""Photo read model returned in slab responses."""
|
||||
|
||||
id: str
|
||||
kind: str = "inventory"
|
||||
role: str | None = None
|
||||
seq: int = 0
|
||||
original_url: str
|
||||
processed_url: str | None = None
|
||||
|
||||
|
||||
class SlabRead(BaseModel):
|
||||
"""Slab read model — all fields from create plus server state."""
|
||||
|
||||
id: str
|
||||
sku: str
|
||||
status: str
|
||||
length_in: float
|
||||
thickness_in: float
|
||||
sqft: float | None = None
|
||||
bdft: float | None = None
|
||||
width_min_in: float | None = None
|
||||
width_max_in: float | None = None
|
||||
width_avg_in: float | None = None
|
||||
species_id: int | None = None
|
||||
wood_category_ids: list[int] | None = None
|
||||
edge_type_term_id: int | None = None
|
||||
figure_term_ids: list[int] | None = None
|
||||
grade_term_ids: list[int] | None = None
|
||||
thickness_term_id: int | None = None
|
||||
moisture_term_id: int | None = None
|
||||
fig_tag_ids: list[int] | None = None
|
||||
feat_tag_ids: list[int] | None = None
|
||||
price_per_bdft: float | None = None
|
||||
price: float
|
||||
price_source: str | None = None
|
||||
title: str | None = None
|
||||
short_title: str | None = None
|
||||
description: str | None = None
|
||||
content_source: str | None = None
|
||||
client_rev: int
|
||||
server_rev: int = 1
|
||||
created_at: str
|
||||
updated_at: str
|
||||
inference_thread_id: str | None = None
|
||||
species_confidence: float | None = None
|
||||
woo_product_id: int | None = None
|
||||
published_at: str | None = None
|
||||
photos: list[PhotoRead] = []
|
||||
inference_status: str | None = None
|
||||
inference_error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGES_DIR = Path("/data/images")
|
||||
|
||||
|
||||
def _get_images_dir() -> Path:
|
||||
"""Return the images storage directory, creating it if needed.
|
||||
|
||||
For tests, falls back to a temp directory.
|
||||
"""
|
||||
try:
|
||||
_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _IMAGES_DIR
|
||||
except (PermissionError, OSError):
|
||||
# Fallback: use temp directory (e.g., in tests)
|
||||
return Path(tempfile.mkdtemp(prefix="slab_"))
|
||||
|
||||
|
||||
def _save_photo_file(photo_id: str, file_bytes: bytes, filename: str) -> str:
|
||||
"""Save an uploaded photo file to disk and return the relative path.
|
||||
|
||||
Stores as images/{photo_id}/{filename}. In test environments,
|
||||
uses a temp directory.
|
||||
"""
|
||||
base_dir = _get_images_dir()
|
||||
photo_dir = base_dir / photo_id
|
||||
photo_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = photo_dir / filename
|
||||
file_path.write_bytes(file_bytes)
|
||||
return str(f"images/{photo_id}/{filename}")
|
||||
|
||||
|
||||
def _slab_read_from_model(slab: Slab, photos: list[SlabPhoto] | None = None) -> SlabRead:
|
||||
"""Convert a Slab ORM model to SlabRead response."""
|
||||
photos_read = []
|
||||
if photos:
|
||||
for p in photos:
|
||||
original_url = (
|
||||
f"/api/v1/slabs/{slab.id}/photos/{p.id}/original"
|
||||
if p.original_path
|
||||
else None
|
||||
)
|
||||
photos_read.append(
|
||||
PhotoRead(
|
||||
id=p.id,
|
||||
kind=p.kind,
|
||||
role=p.role,
|
||||
seq=p.seq,
|
||||
original_url=original_url or "",
|
||||
processed_url=p.processed_path,
|
||||
)
|
||||
)
|
||||
return SlabRead(
|
||||
id=slab.id,
|
||||
sku=slab.sku,
|
||||
status=slab.status.value,
|
||||
length_in=slab.length_in,
|
||||
thickness_in=slab.thickness_in,
|
||||
sqft=slab.sqft,
|
||||
bdft=slab.bdft,
|
||||
width_min_in=slab.width_min_in,
|
||||
width_max_in=slab.width_max_in,
|
||||
width_avg_in=slab.width_avg_in,
|
||||
species_id=slab.species_id,
|
||||
wood_category_ids=slab.wood_category_ids,
|
||||
edge_type_term_id=slab.edge_type_term_id,
|
||||
figure_term_ids=slab.figure_term_ids,
|
||||
grade_term_ids=slab.grade_term_ids,
|
||||
thickness_term_id=slab.thickness_term_id,
|
||||
moisture_term_id=slab.moisture_term_id,
|
||||
fig_tag_ids=slab.fig_tag_ids,
|
||||
feat_tag_ids=slab.feat_tag_ids,
|
||||
price_per_bdft=slab.price_per_bdft,
|
||||
price=slab.price,
|
||||
price_source=slab.price_source,
|
||||
title=slab.title,
|
||||
short_title=slab.short_title,
|
||||
description=slab.description,
|
||||
content_source=slab.content_source,
|
||||
client_rev=slab.client_rev,
|
||||
server_rev=slab.server_rev,
|
||||
created_at=slab.created_at,
|
||||
updated_at=slab.updated_at,
|
||||
inference_thread_id=slab.inference_thread_id,
|
||||
photos=photos_read,
|
||||
inference_status=slab.inference_thread_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/api/v1/slabs", status_code=201)
|
||||
async def create_slab(request: Request) -> JSONResponse:
|
||||
"""Create a calibrated draft from a multipart upload.
|
||||
|
||||
The request body must be multipart/form-data with these fields:
|
||||
- `data` — JSON-encoded SlabCreate payload
|
||||
- `files[]` — original image files (1–5 photos)
|
||||
- `meta[]` — JSON-encoded photo metadata objects
|
||||
|
||||
Returns 201 with the created slab (status: calibrated, server_rev: 1).
|
||||
Server stores client-sent numbers only; no server-side recompute.
|
||||
|
||||
Per AGENTS.md §3: client is authoritative for deterministic math;
|
||||
server stores client-sent numbers.
|
||||
"""
|
||||
# Parse multipart form data
|
||||
form = await request.form()
|
||||
|
||||
# --- Extract and validate `data` field ---
|
||||
data_json = form.get("data")
|
||||
if not data_json:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "Missing 'data' field in multipart upload.",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
create_data = SlabCreateData.model_validate_json(data_json)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": f"Invalid data JSON: {e}",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Extract `files[]` and `meta[]` ---
|
||||
files: list[request.file_uploads] = form.getlist("files[]")
|
||||
metas: list[str] = form.getlist("meta[]")
|
||||
|
||||
# Require at least one photo
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "At least one photo file is required.",
|
||||
},
|
||||
)
|
||||
|
||||
# Pair files with metadata; extras get default meta
|
||||
if len(metas) < len(files):
|
||||
# Pad with default meta for files without explicit metadata
|
||||
default_meta = PhotoMeta(kind="inventory", role=None, seq=0)
|
||||
metas = list(metas) + [default_meta.model_dump_json()] * (len(files) - len(metas))
|
||||
|
||||
if len(files) != len(metas):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "files[] and meta[] counts must match.",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Validate meta objects and resolve photo files ---
|
||||
photo_metas = []
|
||||
for i, meta_json in enumerate(metas):
|
||||
try:
|
||||
meta = PhotoMeta.model_validate_json(meta_json)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": f"Invalid meta[{i}] JSON: {e}",
|
||||
},
|
||||
)
|
||||
photo_metas.append(meta)
|
||||
|
||||
# --- Database: create slab and photos ---
|
||||
session = await get_session()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
# Check for duplicate SKU
|
||||
existing = await session.execute(
|
||||
text("SELECT id FROM slabs WHERE sku = :sku"), {"sku": create_data.sku}
|
||||
)
|
||||
if existing.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"code": "duplicate_sku",
|
||||
"message": f"SKU '{create_data.sku}' already exists.",
|
||||
"actions": ["edit_sku", "open_existing"],
|
||||
},
|
||||
)
|
||||
|
||||
# Create slab record — status starts as calibrated
|
||||
slab = Slab(
|
||||
id=create_data.id,
|
||||
sku=create_data.sku,
|
||||
status=SlabStatus.CALIBRATED,
|
||||
length_in=create_data.length_in,
|
||||
thickness_in=create_data.thickness_in,
|
||||
sqft=create_data.sqft,
|
||||
bdft=create_data.bdft,
|
||||
width_min_in=create_data.width_min_in,
|
||||
width_max_in=create_data.width_max_in,
|
||||
width_avg_in=create_data.width_avg_in,
|
||||
species_id=create_data.species_id,
|
||||
wood_category_ids=create_data.wood_category_ids,
|
||||
edge_type_term_id=create_data.edge_type_term_id,
|
||||
figure_term_ids=create_data.figure_term_ids,
|
||||
grade_term_ids=create_data.grade_term_ids,
|
||||
thickness_term_id=create_data.thickness_term_id,
|
||||
moisture_term_id=create_data.moisture_term_id,
|
||||
fig_tag_ids=create_data.fig_tag_ids,
|
||||
feat_tag_ids=create_data.feat_tag_ids,
|
||||
price_per_bdft=create_data.price_per_bdft,
|
||||
price=create_data.price,
|
||||
price_source=create_data.price_source,
|
||||
title=create_data.title,
|
||||
short_title=create_data.short_title,
|
||||
description=create_data.description,
|
||||
content_source=create_data.content_source,
|
||||
client_rev=create_data.client_rev,
|
||||
server_rev=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
inference_thread_id=None,
|
||||
)
|
||||
session.add(slab)
|
||||
|
||||
# Create photo records and store files
|
||||
photos = []
|
||||
for file_upload, meta in zip(files, photo_metas):
|
||||
file_bytes = await file_upload.read()
|
||||
filename = file_upload.filename or f"photo_{meta.seq}.jpg"
|
||||
|
||||
photo_id = str(uuid.uuid4())
|
||||
original_path = _save_photo_file(photo_id, file_bytes, filename)
|
||||
|
||||
photo = SlabPhoto(
|
||||
id=photo_id,
|
||||
slab_id=slab.id,
|
||||
kind=meta.kind,
|
||||
role=meta.role,
|
||||
seq=meta.seq,
|
||||
original_path=original_path,
|
||||
processed_path=None,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(photo)
|
||||
photos.append(photo)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Build response
|
||||
result = _slab_read_from_model(slab, photos)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content=result.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
await session.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.exception("Failed to create slab")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"code": "internal",
|
||||
"message": f"Failed to create slab: {exc}",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""FastAPI application settings.
|
||||
|
||||
WOO_BASE_URL is the deploy-time SoT for the WooCommerce store.
|
||||
It must be HTTPS. FastAPI refuses startup when missing or HTTP.
|
||||
|
||||
SLAB_AES_KEY is the AES-GCM master key (32-byte base64). Required.
|
||||
Missing key refuses startup.
|
||||
|
||||
All Woo credentials and inference secrets are stored AES-GCM encrypted
|
||||
in the settings table at runtime, not passed as env vars.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="SLAB_", env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
aes_key: str | None = None
|
||||
woo_base_url: str | None = None
|
||||
app_hostname: str | None = None
|
||||
|
||||
# Image output constants (from TECH-SPEC)
|
||||
aspect_ratio: str = "3:4"
|
||||
output_px: int = 1600
|
||||
output_px_min: int = 1600
|
||||
fill_target: float = 0.80
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Refuse startup on invalid config."""
|
||||
if self.aes_key is None:
|
||||
raise ValueError("SLAB_AES_KEY environment variable is required")
|
||||
if len(self.aes_key) < 32:
|
||||
raise ValueError("SLAB_AES_KEY must be at least 32 bytes base64")
|
||||
|
||||
if self.woo_base_url is None:
|
||||
raise ValueError("WOO_BASE_URL environment variable is required")
|
||||
if not self.woo_base_url.startswith("https://"):
|
||||
raise ValueError("WOO_BASE_URL must be HTTPS")
|
||||
|
||||
@property
|
||||
def woo_credentials_configured(self) -> bool:
|
||||
"""Whether Woo application password has been stored (checked via DB at runtime)."""
|
||||
return False # Always false from config alone; checked via settings table at runtime
|
||||
@@ -0,0 +1,84 @@
|
||||
"""SQLite engine and session management.
|
||||
|
||||
Uses SQLAlchemy async engine with aiosqlite driver.
|
||||
|
||||
For tests, pass `test_db_url` via the settings or use a test fixture
|
||||
to point at an in-memory SQLite database.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default DB path for production
|
||||
DEFAULT_DB_URL = "sqlite+aiosqlite:////data/slab.db"
|
||||
|
||||
_engine: object = None
|
||||
_session_factory: object = None
|
||||
|
||||
_test_url: str | None = None
|
||||
|
||||
|
||||
def set_test_url(url: str) -> None:
|
||||
"""Override the database URL for testing. Must be called before create_app."""
|
||||
global _test_url
|
||||
_test_url = url
|
||||
|
||||
|
||||
def get_db_url() -> str:
|
||||
"""Return the database URL, preferring test URL if set."""
|
||||
if _test_url:
|
||||
return _test_url
|
||||
return DEFAULT_DB_URL
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize the async engine, session factory, and create tables.
|
||||
|
||||
For in-memory SQLite tests, creates tables directly.
|
||||
For file-based SQLite, attempts alembic migrations first, falling
|
||||
back to direct table creation.
|
||||
"""
|
||||
global _engine, _session_factory
|
||||
|
||||
db_url = get_db_url()
|
||||
logger.info("Connecting to %s", db_url)
|
||||
|
||||
_engine = create_async_engine(
|
||||
db_url,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
_session_factory = async_sessionmaker(
|
||||
_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
# Create all tables (works for both in-memory and file-based)
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info("Database initialized")
|
||||
|
||||
|
||||
async def close_db_connection() -> None:
|
||||
"""Dispose of the engine (closes all connections)."""
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""Get a database session."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||
return _session_factory()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""SQLAlchemy ORM models for the SlabUploader database.
|
||||
|
||||
These models map to the schema defined in docs/DATA-MODEL.md.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Index,
|
||||
JSON,
|
||||
CheckConstraint,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# SQLite doesn't have native JSON or enum types; we use TEXT/JSON via SQLAlchemy's
|
||||
# dialect-specific types.
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class SlabStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
CALIBRATED = "calibrated"
|
||||
READY = "ready"
|
||||
PUBLISHING = "publishing"
|
||||
PUBLISHED = "published"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Slab(Base):
|
||||
__tablename__ = "slabs"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
sku: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
|
||||
status: Mapped[SlabStatus] = mapped_column(
|
||||
Enum(SlabStatus, native_enum=False, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
default=SlabStatus.DRAFT,
|
||||
)
|
||||
length_in: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
thickness_in: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
sqft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_min_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_max_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_avg_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
species_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
wood_category_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
edge_type_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
figure_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
grade_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
thickness_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
moisture_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fig_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
feat_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
price_per_bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
price: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
price_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
short_title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
client_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
server_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
|
||||
inference_thread_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("length_in >= 0"),
|
||||
CheckConstraint("thickness_in >= 0"),
|
||||
CheckConstraint("price >= 0"),
|
||||
Index("idx_slabs_status", "status"),
|
||||
Index("idx_slabs_updated_at", "updated_at"),
|
||||
)
|
||||
|
||||
|
||||
class SlabPhoto(Base):
|
||||
__tablename__ = "slab_photos"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
slab_id: Mapped[str] = mapped_column(
|
||||
Text, ForeignKey("slabs.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
original_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class PricingRule(Base):
|
||||
__tablename__ = "pricing_rules"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
species: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
price_per_bdft: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class WooCategory(Base):
|
||||
__tablename__ = "woo_categories"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooAttribute(Base):
|
||||
__tablename__ = "woo_attributes"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
type: Mapped[str] = mapped_column(Text, default="select")
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooAttributeTerm(Base):
|
||||
__tablename__ = "woo_attribute_terms"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
attribute_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("woo_attributes.woo_id"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooTag(Base):
|
||||
__tablename__ = "woo_tags"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
value_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class SyncLog(Base):
|
||||
__tablename__ = "sync_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sku: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
woo_product_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class InferenceLog(Base):
|
||||
__tablename__ = "inference_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
call: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Logging configuration."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure root logger with structured formatting.
|
||||
|
||||
Format: ISO-8601 timestamp, level, logger name, message.
|
||||
Secrets (AES keys, Woo passwords, inference keys) are never logged.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
if not root.handlers:
|
||||
root.addHandler(handler)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""App factory and lifespan."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api import health as health_router
|
||||
from app.api import settings as settings_router
|
||||
from app.api import slabs as slabs_router
|
||||
from app.config import Settings
|
||||
from app.db.engine import close_db_connection, init_db
|
||||
from app.logging_config import configure_logging
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown hooks.
|
||||
|
||||
Handles database initialization, logging configuration,
|
||||
and cleanup on shutdown.
|
||||
"""
|
||||
configure_logging()
|
||||
logging.info("SlabUploader starting up")
|
||||
await init_db()
|
||||
yield
|
||||
logging.info("SlabUploader shutting down")
|
||||
await close_db_connection()
|
||||
|
||||
|
||||
def create_app(app_settings: Settings | None = None) -> FastAPI:
|
||||
"""Create a fresh FastAPI app instance.
|
||||
|
||||
Each call produces an independent app with its own database connection
|
||||
and cleared dependency overrides. This is the factory used by tests.
|
||||
|
||||
Args:
|
||||
app_settings: Application settings. Uses env vars if None.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application.
|
||||
"""
|
||||
if app_settings is None:
|
||||
app_settings = Settings()
|
||||
app_settings.validate()
|
||||
|
||||
app = FastAPI(title="SlabUploader", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# Store settings on app state for dependency injection
|
||||
app.state.settings = app_settings
|
||||
|
||||
app.include_router(health_router.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(slabs_router.router)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,9 @@
|
||||
# Call 1 prompt — species detection
|
||||
# Read fresh each invocation. Edit on host without restart.
|
||||
|
||||
Identify the slab species, figure, edge type, and grade from the provided images.
|
||||
|
||||
Return results constrained to the provided taxonomy only. Do not invent species,
|
||||
figure terms, or grade terms that are not in the taxonomy list.
|
||||
|
||||
Confidence threshold: 0.7. Below threshold, return empty for that field.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Call 2 prompt — content generation
|
||||
# Read fresh each invocation. Edit on host without restart.
|
||||
|
||||
Generate a title, short title, and description for the slab using the provided
|
||||
deterministic dimensions, species, and figure information.
|
||||
|
||||
Do not invent dimensions. Use the provided length, thickness, widths, sqft, and bdft.
|
||||
@@ -0,0 +1,94 @@
|
||||
"""AES-GCM settings encryption and decryption.
|
||||
|
||||
Per AGENTS.md §4 and DATA-MODEL.md settings table:
|
||||
- AES master key is SLAB_AES_KEY env var (32 bytes base64).
|
||||
- value_enc = nonce(12) || ciphertext.
|
||||
- Fresh nonce per encrypt.
|
||||
- Tampered ciphertext raises authentication failure.
|
||||
- No plaintext fallback.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when ciphertext integrity check fails (tamper or wrong key)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_aes_key(key_input: str | bytes) -> bytes:
|
||||
"""Decode the AES master key to 32 bytes.
|
||||
|
||||
Accepts either a base64-encoded string or raw bytes.
|
||||
|
||||
Args:
|
||||
key_input: Base64-encoded string or raw bytes (must be >= 32 bytes when decoded).
|
||||
|
||||
Returns:
|
||||
32 raw bytes suitable for AES-256-GCM.
|
||||
|
||||
Raises:
|
||||
ValueError: if key is too short or not valid base64.
|
||||
"""
|
||||
if isinstance(key_input, bytes):
|
||||
key_bytes = key_input
|
||||
else:
|
||||
key_bytes = base64.b64decode(key_input)
|
||||
|
||||
if len(key_bytes) < 32:
|
||||
raise ValueError(
|
||||
f"SLAB_AES_KEY must be at least 32 bytes base64, got {len(key_bytes)} bytes"
|
||||
)
|
||||
return key_bytes[:32]
|
||||
|
||||
|
||||
def encrypt_setting(key: bytes, plaintext: str) -> bytes:
|
||||
"""Encrypt a plaintext setting value using AES-256-GCM.
|
||||
|
||||
Args:
|
||||
key: 32-byte AES key (decoded from SLAB_AES_KEY).
|
||||
plaintext: The secret value to encrypt (e.g. woo_app_password).
|
||||
|
||||
Returns:
|
||||
Bytes of form nonce(12) || ciphertext. Each call generates a
|
||||
fresh 12-byte nonce.
|
||||
"""
|
||||
import os
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12)
|
||||
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return nonce + ct
|
||||
|
||||
|
||||
def decrypt_setting(key: bytes, ciphertext: bytes) -> str:
|
||||
"""Decrypt a setting ciphertext back to plaintext.
|
||||
|
||||
Args:
|
||||
key: 32-byte AES key (decoded from SLAB_AES_KEY).
|
||||
ciphertext: Bytes of form nonce(12) || ciphertext from encrypt_setting.
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext string.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: if ciphertext is tampered, truncated,
|
||||
or decrypted with the wrong key.
|
||||
"""
|
||||
if len(ciphertext) < 12:
|
||||
raise AuthenticationError("Ciphertext too short to contain nonce")
|
||||
|
||||
nonce = ciphertext[:12]
|
||||
ct = ciphertext[12:]
|
||||
|
||||
try:
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ct, None)
|
||||
except Exception as exc:
|
||||
raise AuthenticationError(
|
||||
f"Decryption failed: integrity check failed"
|
||||
) from exc
|
||||
|
||||
return plaintext.decode("utf-8")
|
||||
@@ -1,65 +0,0 @@
|
||||
"""S1 AES-GCM crypto helpers (SETTINGS-20, SETTINGS-25, QUALITY-125).
|
||||
|
||||
Master key = SLAB_AES_KEY env (32-byte base64). Ciphertext = nonce(12) || ct (GCM tag verified on decrypt).
|
||||
value_enc stores base64(nonce||ct). Decrypt failures are detectable (no crash, no secret leak).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.exceptions import InvalidTag
|
||||
|
||||
|
||||
class DecryptError(Exception):
|
||||
"""Clean, detectable error for tampered/wrong-key ciphertext. Never leaks raw blob."""
|
||||
pass
|
||||
|
||||
|
||||
def _get_key() -> bytes:
|
||||
key_b64 = os.environ.get("SLAB_AES_KEY")
|
||||
if not key_b64:
|
||||
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)")
|
||||
try:
|
||||
key = base64.b64decode(key_b64, validate=True)
|
||||
except Exception:
|
||||
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must be valid base64") from None
|
||||
if len(key) != 32:
|
||||
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must decode to exactly 32 bytes")
|
||||
return key
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Encrypt plaintext -> base64(nonce(12) || ciphertext) for storage in value_enc."""
|
||||
if not isinstance(plaintext, str):
|
||||
raise TypeError("plaintext must be str")
|
||||
key = _get_key()
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12)
|
||||
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
blob = nonce + ct
|
||||
return base64.b64encode(blob).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(ciphertext_b64: str) -> str:
|
||||
"""Decrypt base64 blob; verifies GCM tag. Raises DecryptError on tamper/wrong key (no leak)."""
|
||||
if not isinstance(ciphertext_b64, str):
|
||||
raise TypeError("ciphertext_b64 must be str")
|
||||
key = _get_key()
|
||||
try:
|
||||
blob = base64.b64decode(ciphertext_b64, validate=True)
|
||||
except Exception:
|
||||
raise DecryptError("corrupt base64") from None
|
||||
if len(blob) < 12:
|
||||
raise DecryptError("corrupt: too short for nonce")
|
||||
nonce = blob[:12]
|
||||
ct = blob[12:]
|
||||
aesgcm = AESGCM(key)
|
||||
try:
|
||||
pt = aesgcm.decrypt(nonce, ct, None)
|
||||
return pt.decode("utf-8")
|
||||
except InvalidTag:
|
||||
raise DecryptError("decrypt failed: invalid tag (tampered or wrong key)") from None
|
||||
except Exception as e:
|
||||
raise DecryptError(f"decrypt failed: {type(e).__name__}") from None
|
||||
@@ -1,167 +0,0 @@
|
||||
"""S0 db factory package: stdlib sqlite3 ONLY. ... (same as before)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DB_PATH_ENV = "SLAB_DB_PATH"
|
||||
IMAGES_DIR_ENV = "SLAB_IMAGES_DIR"
|
||||
SCHEMA_FILE = "schema.sql"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_conn: sqlite3.Connection | None = None
|
||||
_current_path: str | None = None
|
||||
|
||||
|
||||
def _get_schema_path() -> Path:
|
||||
# backend/db/__init__.py -> backend/db/schema.sql
|
||||
return Path(__file__).parent / SCHEMA_FILE
|
||||
|
||||
|
||||
def get_connection(db_path: str | None = None) -> sqlite3.Connection:
|
||||
global _conn, _current_path
|
||||
if db_path is None:
|
||||
db_path = os.environ.get(DB_PATH_ENV)
|
||||
if not db_path:
|
||||
raise RuntimeError(f"{DB_PATH_ENV} env var is required")
|
||||
db_path = str(Path(db_path).resolve())
|
||||
|
||||
with _lock:
|
||||
if _conn is None or _current_path != db_path:
|
||||
if _conn is not None:
|
||||
_conn.close()
|
||||
_conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_conn.execute("PRAGMA foreign_keys=ON")
|
||||
_conn.execute("PRAGMA busy_timeout=5000")
|
||||
_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
_current_path = db_path
|
||||
return _conn
|
||||
|
||||
def _has_schema_version(conn: sqlite3.Connection) -> bool:
|
||||
try:
|
||||
return conn.execute("SELECT 1 FROM schema_version LIMIT 1").fetchone() is not None
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
|
||||
# (table, column) pairs that must exist for the DB to be a valid M3 schema.
|
||||
# A pre-M3 (M2) volume lacks these, so any missing sentinel signals drift.
|
||||
_SCHEMA_SENTINELS = [
|
||||
("settings", "value"),
|
||||
("slabs", "deleted_at"),
|
||||
("slabs", "server_rev"),
|
||||
("slab_photos", "original_blob"),
|
||||
("sync_log", "payload_hash"),
|
||||
]
|
||||
|
||||
|
||||
def _schema_matches(conn: sqlite3.Connection) -> bool:
|
||||
for table, column in _SCHEMA_SENTINELS:
|
||||
try:
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
if column not in cols:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Tables the app owns and that must be rebuilt together on drift.
|
||||
_M3_TABLES = [
|
||||
"slabs", "slab_photos", "pricing_rules", "settings", "woo_categories",
|
||||
"woo_attributes", "woo_attribute_terms", "woo_tags", "sync_log",
|
||||
"inference_log", "prompts", "prompt_revisions",
|
||||
]
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Idempotent column additions for volumes created before a column existed.
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
|
||||
so new columns must be added here. Each guard is a no-op once the column is
|
||||
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
|
||||
"""
|
||||
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
|
||||
if photo_cols and "role" not in photo_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
|
||||
"CHECK(role IN ('calibration','overlay','batch'))"
|
||||
)
|
||||
|
||||
|
||||
def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
||||
conn = get_connection(db_path)
|
||||
schema_path = _get_schema_path()
|
||||
if not schema_path.exists():
|
||||
raise FileNotFoundError(f"Schema not found: {schema_path}")
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
schema = f.read()
|
||||
with _lock:
|
||||
conn.executescript(schema)
|
||||
conn.commit()
|
||||
if not _has_schema_version(conn):
|
||||
if not _schema_matches(conn):
|
||||
logger = logging.getLogger("slab.db")
|
||||
logger.info("Detected pre-M3 schema drift, rebuilding app tables")
|
||||
for t in _M3_TABLES:
|
||||
conn.execute(f"DROP TABLE IF EXISTS {t}")
|
||||
conn.executescript(schema)
|
||||
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
|
||||
conn.commit()
|
||||
# Safe to run every boot: no-op once the columns exist.
|
||||
with _lock:
|
||||
_migrate(conn)
|
||||
conn.commit()
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
def get_images_dir() -> Path:
|
||||
img = os.environ.get(IMAGES_DIR_ENV)
|
||||
if not img:
|
||||
raise RuntimeError(f"{IMAGES_DIR_ENV} env var is required")
|
||||
return Path(img).resolve()
|
||||
|
||||
|
||||
def probe_writable(conn: sqlite3.Connection | None = None) -> bool:
|
||||
try:
|
||||
c = conn or get_connection()
|
||||
c.execute("SELECT 1").fetchone()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def probe_images_writable() -> bool:
|
||||
try:
|
||||
img_dir = get_images_dir()
|
||||
if not img_dir.exists():
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
probe = img_dir / ".health-probe"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink(missing_ok=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Close global conn so temp dirs can be cleaned on Windows (test only)."""
|
||||
global _conn, _current_path
|
||||
with _lock:
|
||||
if _conn is not None:
|
||||
try:
|
||||
_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_conn = None
|
||||
_current_path = None
|
||||
@@ -1,162 +0,0 @@
|
||||
-- SlabUploader SQLite schema (S0)
|
||||
-- Every table/column from docs/contracts/DATA-MODEL.md using CREATE TABLE IF NOT EXISTS
|
||||
-- Taxonomy tables exist but remain EMPTY (S4 populates). Stdlib sqlite3 only.
|
||||
-- Pragmas applied by db factory: journal_mode=WAL, foreign_keys=ON, busy_timeout=5000, synchronous=NORMAL
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- 2. slabs
|
||||
CREATE TABLE IF NOT EXISTS slabs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','calibrated','ready','publishing','published','failed')),
|
||||
sku TEXT UNIQUE CHECK(length(sku) BETWEEN 3 AND 24),
|
||||
length_in REAL,
|
||||
thickness_in REAL,
|
||||
sqft REAL,
|
||||
bdft REAL,
|
||||
width_min_in REAL,
|
||||
width_max_in REAL,
|
||||
width_avg_in REAL,
|
||||
species_id INTEGER,
|
||||
wood_category_ids TEXT, -- JSON array of leaf ids
|
||||
edge_type_term_id INTEGER,
|
||||
figure_term_ids TEXT, -- JSON array
|
||||
grade_term_id INTEGER,
|
||||
thickness_term_id INTEGER,
|
||||
moisture_term_id INTEGER,
|
||||
fig_tag_ids TEXT, -- JSON array optional
|
||||
feat_tag_ids TEXT, -- JSON array optional
|
||||
price REAL,
|
||||
price_source TEXT CHECK(price_source IN ('recommendation','override') OR price_source IS NULL),
|
||||
title TEXT,
|
||||
short_description TEXT,
|
||||
description TEXT,
|
||||
content_source TEXT CHECK(content_source IN ('manual','llm','template') OR content_source IS NULL),
|
||||
species_confidence REAL,
|
||||
figure_confidence REAL,
|
||||
grade_confidence REAL,
|
||||
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
|
||||
calibration_photo_id TEXT,
|
||||
client_rev INTEGER NOT NULL DEFAULT 1,
|
||||
server_rev INTEGER NOT NULL DEFAULT 1,
|
||||
woo_product_id INTEGER,
|
||||
published_at TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (species_id) REFERENCES woo_categories(id),
|
||||
FOREIGN KEY (calibration_photo_id) REFERENCES slab_photos(id)
|
||||
);
|
||||
|
||||
-- 3. slab_photos
|
||||
CREATE TABLE IF NOT EXISTS slab_photos (
|
||||
id TEXT PRIMARY KEY,
|
||||
slab_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
|
||||
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
|
||||
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
|
||||
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
|
||||
original_blob BLOB,
|
||||
processed_blob BLOB,
|
||||
width_px INTEGER,
|
||||
height_px INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (slab_id) REFERENCES slabs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 4. pricing_rules
|
||||
CREATE TABLE IF NOT EXISTS pricing_rules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
species_id INTEGER NOT NULL UNIQUE,
|
||||
price_per_bdft REAL NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (species_id) REFERENCES woo_categories(id)
|
||||
);
|
||||
|
||||
-- 5. Taxonomy cache (empty at S0; columns chosen to support FKs + S4 sync; ambiguity resolved in learnings)
|
||||
CREATE TABLE IF NOT EXISTS woo_categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
parent_id INTEGER,
|
||||
woo_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS woo_attributes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
woo_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS woo_attribute_terms (
|
||||
id INTEGER PRIMARY KEY,
|
||||
attribute_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
woo_id INTEGER,
|
||||
FOREIGN KEY (attribute_id) REFERENCES woo_attributes(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS woo_tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
woo_id INTEGER
|
||||
);
|
||||
|
||||
-- 6. settings (key/value/value_enc per §6; secrets in value_enc = nonce||ciphertext)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
value_enc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
||||
|
||||
-- 7. prompts + prompt_revisions (append-only revisions)
|
||||
CREATE TABLE IF NOT EXISTS prompts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL CHECK(name IN ('call1','call2')),
|
||||
active_revision_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (active_revision_id) REFERENCES prompt_revisions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prompt_revisions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
prompt_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT,
|
||||
FOREIGN KEY (prompt_id) REFERENCES prompts(id)
|
||||
);
|
||||
|
||||
-- 8. sync_log (append-only)
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slab_id TEXT,
|
||||
woo_product_id INTEGER,
|
||||
status TEXT NOT NULL CHECK(status IN ('success','failed')),
|
||||
payload_hash TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 9. inference_log (append-only audit)
|
||||
CREATE TABLE IF NOT EXISTS inference_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slab_id TEXT,
|
||||
call INTEGER NOT NULL CHECK(call IN (1,2)),
|
||||
prompt_revision_id INTEGER,
|
||||
model TEXT,
|
||||
request_payload TEXT,
|
||||
response_payload TEXT,
|
||||
latency_ms INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (prompt_revision_id) REFERENCES prompt_revisions(id)
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
"""Photo BLOB storage (S3). Multipart upload of original + processed variants into
|
||||
slab_photos; GET original/processed while retained; 410 Gone once purged.
|
||||
|
||||
Cites CAPTURE-95/105/130 (lineup order), PUBLISH-40 (processed PNGs, no cap).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from .db import get_connection
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _get_slab(conn, slab_id: str):
|
||||
return conn.execute(
|
||||
"SELECT id, deleted_at FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _get_photo(conn, slab_id: str, photo_id: str):
|
||||
row = conn.execute(
|
||||
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role "
|
||||
"FROM slab_photos WHERE id=? AND slab_id=?",
|
||||
(photo_id, slab_id),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def _next_seq(conn, slab_id: str) -> int:
|
||||
row = conn.execute("SELECT COALESCE(MAX(seq),0)+1 FROM slab_photos WHERE slab_id=?", (slab_id,)).fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
@router.post("/{slab_id}/photos", status_code=201)
|
||||
async def upload_photo(
|
||||
slab_id: str,
|
||||
photo_id: str = Form(...),
|
||||
seq: int | None = Form(None),
|
||||
is_calibration: int = Form(0),
|
||||
role: str = Form("batch"),
|
||||
original: UploadFile = File(...),
|
||||
processed: UploadFile | None = File(None),
|
||||
) -> dict:
|
||||
conn = get_connection()
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if is_calibration not in (0, 1):
|
||||
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
|
||||
if role not in ("calibration", "overlay", "batch"):
|
||||
return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch"))
|
||||
orig_bytes = await original.read()
|
||||
proc_bytes = await processed.read() if processed is not None else None
|
||||
if not orig_bytes:
|
||||
return JSONResponse(status_code=400, content=_error("validation", "original photo is empty"))
|
||||
# Replace any existing photo with the same id on this slab (re-upload).
|
||||
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
|
||||
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
row = _get_photo(conn, slab_id, photo_id)
|
||||
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
|
||||
"role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
@router.get("/{slab_id}/photos/{photo_id}/original")
|
||||
async def get_original(slab_id: str, photo_id: str):
|
||||
conn = get_connection()
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
row = _get_photo(conn, slab_id, photo_id)
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
|
||||
if row["original_blob"] is None:
|
||||
return Response(status_code=410)
|
||||
return Response(content=row["original_blob"], media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/{slab_id}/photos/{photo_id}/processed")
|
||||
async def get_processed(slab_id: str, photo_id: str):
|
||||
conn = get_connection()
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
row = _get_photo(conn, slab_id, photo_id)
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
|
||||
if row["processed_blob"] is None:
|
||||
return Response(status_code=410)
|
||||
return Response(content=row["processed_blob"], media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/{slab_id}/photos")
|
||||
async def list_photos(slab_id: str) -> list[dict]:
|
||||
conn = get_connection()
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
rows = conn.execute(
|
||||
"SELECT id, seq, is_calibration, role, width_px, height_px, "
|
||||
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
|
||||
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.delete("/{slab_id}/photos/{photo_id}", status_code=204)
|
||||
async def delete_photo(slab_id: str, photo_id: str):
|
||||
"""#90: drop a photo the owner removed in Review (keep/remove edit). The
|
||||
remaining photos are re-numbered by the client via re-upload."""
|
||||
conn = get_connection()
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
cur = conn.execute(
|
||||
"DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
|
||||
return Response(status_code=204)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Species pricing rules (SETTINGS-35): one flat $/bdft per synced species leaf, no tiers.
|
||||
|
||||
Cites SETTINGS-35, REVIEW-25, API.md §9.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .db import get_connection
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin/pricing")
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _row_to_rule(row) -> dict:
|
||||
d = dict(row)
|
||||
d["species_id"] = row["species_id"]
|
||||
return d
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_rules() -> list[dict]:
|
||||
conn = get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT id, species_id, price_per_bdft, created_at, updated_at FROM pricing_rules ORDER BY species_id"
|
||||
).fetchall()
|
||||
return [_row_to_rule(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_rule(payload: dict[str, Any]) -> dict:
|
||||
species_id = payload.get("species_id")
|
||||
rate = payload.get("price_per_bdft")
|
||||
if species_id is None or rate is None:
|
||||
return JSONResponse(status_code=400, content=_error("validation", "species_id and price_per_bdft required"))
|
||||
if not isinstance(rate, (int, float)) or rate < 0:
|
||||
return JSONResponse(status_code=400, content=_error("validation", "price_per_bdft must be a non-negative number"))
|
||||
conn = get_connection()
|
||||
if conn.execute("SELECT 1 FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone():
|
||||
return JSONResponse(status_code=409, content=_error("duplicate", "rule exists for species", {"species_id": species_id}))
|
||||
now = _now()
|
||||
conn.execute(
|
||||
"INSERT INTO pricing_rules(species_id, price_per_bdft, created_at, updated_at) VALUES (?,?,?,?)",
|
||||
(species_id, round(float(rate), 2), now, now),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone()
|
||||
return _row_to_rule(row)
|
||||
|
||||
|
||||
@router.put("/{rule_id}")
|
||||
async def update_rule(rule_id: int, payload: dict[str, Any]) -> dict:
|
||||
conn = get_connection()
|
||||
cur = conn.execute("SELECT * FROM pricing_rules WHERE id=?", (rule_id,)).fetchone()
|
||||
if cur is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "rule not found"))
|
||||
rate = payload.get("price_per_bdft")
|
||||
if rate is None or not isinstance(rate, (int, float)) or rate < 0:
|
||||
return JSONResponse(status_code=400, content=_error("validation", "price_per_bdft must be a non-negative number"))
|
||||
species_id = payload.get("species_id", cur["species_id"])
|
||||
if species_id != cur["species_id"]:
|
||||
if conn.execute("SELECT 1 FROM pricing_rules WHERE species_id=? AND id<>?", (species_id, rule_id)).fetchone():
|
||||
return JSONResponse(status_code=409, content=_error("duplicate", "rule exists for species", {"species_id": species_id}))
|
||||
conn.execute(
|
||||
"UPDATE pricing_rules SET species_id=?, price_per_bdft=?, updated_at=? WHERE id=?",
|
||||
(species_id, round(float(rate), 2), _now(), rule_id),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM pricing_rules WHERE id=?", (rule_id,)).fetchone()
|
||||
return _row_to_rule(row)
|
||||
|
||||
|
||||
@router.delete("/{rule_id}", status_code=204)
|
||||
async def delete_rule(rule_id: int):
|
||||
conn = get_connection()
|
||||
cur = conn.execute("DELETE FROM pricing_rules WHERE id=?", (rule_id,))
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "rule not found"))
|
||||
from fastapi import Response
|
||||
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def rate_for_species(conn, species_id: int) -> float | None:
|
||||
row = conn.execute("SELECT price_per_bdft FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Publish sequence (S6). Cites PUBLISH-5/10/15/20/25/30/31/36/37/38/55/60.
|
||||
|
||||
Sequence (PUBLISH-5): validate ready set -> look up SKU (stop if exists) ->
|
||||
refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload processed
|
||||
PNGs -> POST /products -> finalize. SLAB-UAT-* always draft (PUBLISH-10).
|
||||
Failure: mark failed, retain photos, write sync_log, plain message (PUBLISH-30).
|
||||
Partial (media ok, product POST failed): orphan media left, logged (PUBLISH-31).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .db import get_connection
|
||||
from .woo import (
|
||||
WooAuthError,
|
||||
WooClient,
|
||||
WooConnectionError,
|
||||
WooCredentialsMissing,
|
||||
WooDeviceCredsRequired,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
client_for_request,
|
||||
settings_woo_creds,
|
||||
)
|
||||
from .review import compute_ready, _slab_dict
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _is_uat(sku: str) -> bool:
|
||||
return sku.startswith("SLAB-UAT-")
|
||||
|
||||
|
||||
def _slugify(name: str, sku: str) -> str:
|
||||
base = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return f"{base}-{sku.lower()}"
|
||||
|
||||
|
||||
def _taxonomy_stale(conn) -> bool:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()
|
||||
if not row or not row[0]:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(row[0])
|
||||
except ValueError:
|
||||
return True
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) - last > timedelta(hours=1)
|
||||
|
||||
|
||||
def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
payload: dict[str, Any] = {
|
||||
"type": "simple",
|
||||
"status": status,
|
||||
"sku": slab["sku"],
|
||||
"name": slab["title"],
|
||||
"slug": _slugify(slab["title"], slab["sku"]),
|
||||
"regular_price": f"{slab['price']:.2f}",
|
||||
"description": slab["description"],
|
||||
"short_description": slab["short_description"],
|
||||
"virtual": False,
|
||||
"downloadable": False,
|
||||
"catalog_visibility": "visible",
|
||||
"sold_individually": True,
|
||||
}
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
payload["categories"] = [{"id": slab["species_id"]}]+[{"id": c} for c in wood_cats]
|
||||
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
|
||||
if tags:
|
||||
payload["tags"] = [{"id": t} for t in tags]
|
||||
# Five locked attributes: resolve the attribute row from the synced cache by
|
||||
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
|
||||
# then fall back to case-insensitive name. Term *options* are always the
|
||||
# names of the selected synced terms.
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
|
||||
built_attrs = []
|
||||
for field, attr_slug, attr_name, pos in (
|
||||
("edge_type_term_id", "edge-type", "Edge Type", 0),
|
||||
("figure_term_ids", "pa_figure", "Figure", 1),
|
||||
("grade_term_id", "pa_grade", "Grade", 2),
|
||||
("thickness_term_id", "pa_thickness", "Thickness", 3),
|
||||
("moisture_term_id", "pa_moisture", "Moisture", 4),
|
||||
):
|
||||
raw = slab.get(field)
|
||||
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
|
||||
options = [terms[t]["name"] for t in term_ids if t in terms]
|
||||
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
|
||||
if attr_id and options:
|
||||
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
|
||||
if built_attrs:
|
||||
payload["attributes"] = built_attrs
|
||||
return payload
|
||||
|
||||
|
||||
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
|
||||
"""Match a locked attribute against the synced Woo set by slug, then name."""
|
||||
slug_l = slug.lower().lstrip()
|
||||
# Accept both "figure" and "pa_figure" style slugs from Woo.
|
||||
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
|
||||
for a in attrs.values():
|
||||
s = (a.get("slug") or "").lower()
|
||||
if s in slug_alts:
|
||||
return a["id"]
|
||||
name_l = name.lower()
|
||||
for a in attrs.values():
|
||||
if (a.get("name") or "").lower() == name_l:
|
||||
return a["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
|
||||
if isinstance(e, WooDeviceCredsRequired):
|
||||
return (
|
||||
"woo_device_creds_required",
|
||||
"Enter this device's WordPress user and application password.",
|
||||
False,
|
||||
)
|
||||
if isinstance(e, WooCredentialsMissing):
|
||||
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
||||
if isinstance(e, WooAuthError):
|
||||
return "woo_auth", "The store rejected the credentials. Check the app password.", False
|
||||
if isinstance(e, WooForbiddenError):
|
||||
# WAF / security plugin block — retrying the same request will not help.
|
||||
return (
|
||||
"woo_forbidden",
|
||||
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
|
||||
False,
|
||||
)
|
||||
if isinstance(e, WooConnectionError):
|
||||
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
|
||||
return "woo_failed", "The store create failed. You can retry.", True
|
||||
|
||||
|
||||
@router.post("/{slab_id}/publish")
|
||||
async def publish(slab_id: str, request: Request) -> dict:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
slab = _slab_dict(row)
|
||||
# 1. Ready set (PUBLISH-5)
|
||||
ready, missing = compute_ready(slab)
|
||||
if not ready:
|
||||
return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing}))
|
||||
# 2. SKU lookup (PUBLISH-15) — stop if it exists in the store.
|
||||
# Per-request Woo creds: require X-Woo-* device headers (no Settings fallback).
|
||||
try:
|
||||
client = client_for_request(conn, request.headers)
|
||||
except WooDeviceCredsRequired as e:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content=_error(
|
||||
"woo_device_creds_required",
|
||||
"Enter this device's WordPress user and application password.",
|
||||
),
|
||||
)
|
||||
except WooCredentialsMissing as e:
|
||||
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
|
||||
try:
|
||||
existing = _sku_exists(client, slab["sku"])
|
||||
except WooError as e:
|
||||
code, msg, rec = _classify(e)
|
||||
_log(conn, slab_id, None, "failed", msg)
|
||||
conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
|
||||
conn.commit()
|
||||
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
|
||||
if existing:
|
||||
_log(conn, slab_id, None, "failed", "SKU already exists in the store")
|
||||
return JSONResponse(status_code=409, content=_error("duplicate_sku", "SKU already exists", {"sku": slab["sku"]}))
|
||||
# 3. Refresh taxonomy if stale (SETTINGS-60).
|
||||
if _taxonomy_stale(conn):
|
||||
try:
|
||||
from . import taxonomy as taxonomy_mod
|
||||
|
||||
taxonomy_mod.sync(client, conn)
|
||||
except WooError as e:
|
||||
code, msg, rec = _classify(e)
|
||||
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
|
||||
# 4/5. Upload processed PNGs, then POST product.
|
||||
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
|
||||
# its dimension-overlay copy, then the remaining batch photos. The Review
|
||||
# photo strip stores the owner's keep order (leftmost = primary), so the
|
||||
# overlay (which can sit after a moved batch photo) is re-ordered here by
|
||||
# role rather than by stored seq. Ties fall back to stored seq.
|
||||
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
|
||||
photos = conn.execute(
|
||||
"SELECT id, processed_blob, role FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL "
|
||||
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
uploaded_ids: list[int] = []
|
||||
try:
|
||||
for p in photos:
|
||||
uploaded_ids.append(client.upload_media(p["processed_blob"], f"{slab['sku']}-{p['id']}.png"))
|
||||
payload = _build_payload(slab, conn, status)
|
||||
if uploaded_ids:
|
||||
payload["images"] = [{"id": i} for i in uploaded_ids]
|
||||
woo_product_id = _create_product(client, payload)
|
||||
except WooError as e:
|
||||
code, msg, rec = _classify(e)
|
||||
partial = bool(uploaded_ids)
|
||||
if partial:
|
||||
msg = f"{msg} The uploaded images were left in the store — remove them manually."
|
||||
_log(conn, slab_id, None, "failed", msg)
|
||||
conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
|
||||
conn.commit()
|
||||
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec, "partial_media": partial}))
|
||||
# 6. Finalize (PUBLISH-25): set woo_product_id, purge photos, discard local row.
|
||||
now = _now()
|
||||
conn.execute("UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?",
|
||||
(woo_product_id, now, now, slab_id))
|
||||
conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE slab_id=?", (slab_id,))
|
||||
_log(conn, slab_id, woo_product_id, "success", "published")
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "published",
|
||||
"woo_product_id": woo_product_id,
|
||||
"draft": _is_uat(slab["sku"]),
|
||||
}
|
||||
|
||||
|
||||
def _woo_creds(conn) -> tuple[str, str | None, str | None]:
|
||||
"""Shop-default Woo creds (Settings). Prefer client_for_request for HTTP paths."""
|
||||
return settings_woo_creds(conn)
|
||||
|
||||
|
||||
def _create_status(conn) -> str:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone()
|
||||
return (row[0] if row and row[0] else "draft")
|
||||
|
||||
|
||||
def _sku_exists(client: WooClient, sku: str) -> bool:
|
||||
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return len(client.get_products_by_sku(sku)) > 0
|
||||
|
||||
|
||||
def _create_product(client: WooClient, payload: dict) -> int:
|
||||
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return client.create_product(payload)
|
||||
|
||||
|
||||
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)",
|
||||
(slab_id, woo_product_id, status, None, message, _now()),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest>=8.3,<9
|
||||
httpx>=0.28,<1
|
||||
respx>=0.22,<1
|
||||
@@ -1,9 +1,11 @@
|
||||
# S0 backend requirements (pinned, no ORM)
|
||||
fastapi==0.115.2
|
||||
uvicorn[standard]==0.30.6
|
||||
python-multipart==0.0.9
|
||||
pytest==8.3.3
|
||||
httpx==0.27.2
|
||||
cryptography==43.0.1
|
||||
Pillow==11.3.0
|
||||
# stdlib only for sqlite3, logging, json, contextvars, threading
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.34,<1
|
||||
sqlalchemy>=2.0,<3
|
||||
aiosqlite>=0.20,<1
|
||||
alembic>=1.14,<2
|
||||
httpx>=0.28,<1
|
||||
pydantic>=2.9,<3
|
||||
pydantic-settings>=2.7,<3
|
||||
python-dotenv>=1.0,<2
|
||||
cryptography>=43.0,<45
|
||||
python-multipart>=0.0.30,<1
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
"""Review to ready (S5): deterministic title/short description, price suggestion,
|
||||
and server-derived `ready` status. Cites REVIEW-5/10/15/20/25/30/40/45/50,
|
||||
METADATA-80/85/90, SETTINGS-35.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .db import get_connection
|
||||
from .pricing import rate_for_species
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
# Wood-category leaf slug -> (title pattern, noun, carries_dims).
|
||||
# Patterns interpolate {figure}, {species}, {length}, {width}, {thickness}, {bdft}.
|
||||
# Figure is Title-Case adjective; absent figure drops the slot.
|
||||
_LEAF_PATTERNS = {
|
||||
"live-edge-wood": ("{figure}{species} Live Edge Slab", "Live Edge Slab", False),
|
||||
"dimensional-wood": ("{figure}{species} {live_edge}Dimensional", "Dimensional", False),
|
||||
"knife-blanks": ("{figure}{species} Knife Blank ({dims})", "Knife Blank", True),
|
||||
"bowl-blanks": ("{figure}{species} {live_edge}Bowl Blank ({dims})", "Bowl Blank", True),
|
||||
"logs-and-caps": ("{figure}{species} Log Cap", "Log Cap", False),
|
||||
"pen-blanks": ("{figure}{species} Pen Blank ({dims})", "Pen Blank", True),
|
||||
"pepper-mill-blanks": ("{figure}{species} Pepper Mill Blank ({dims})", "Pepper Mill Blank", True),
|
||||
}
|
||||
|
||||
# Required fields for a slab to reach `ready` (REVIEW-5). Measurement fields
|
||||
# (widths/sqft/bdft) are read-only client math but must be present.
|
||||
_REQUIRED = [
|
||||
"sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||
"width_min_in", "width_max_in", "width_avg_in",
|
||||
"species_id", "wood_category_ids", "edge_type_term_id",
|
||||
"figure_term_ids", # UX requires ≥1 figure (Create-desc gate + ready)
|
||||
"grade_term_id", "thickness_term_id", "moisture_term_id",
|
||||
"price", "price_source", "title", "short_description", "description",
|
||||
"content_source",
|
||||
]
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _split_length(length_in: float) -> tuple[int, float]:
|
||||
ft = int(length_in // 12)
|
||||
rem = round(length_in - ft * 12, 1)
|
||||
return ft, rem
|
||||
|
||||
|
||||
def _fmt1(v: float) -> str:
|
||||
# 1 dp, trim trailing .0
|
||||
s = f"{v:.1f}"
|
||||
return s[:-2] if s.endswith(".0") else s
|
||||
|
||||
|
||||
def _fmt2(v: float) -> str:
|
||||
# 2 dp, trim trailing zeros (thickness carries 1/8-inch precision)
|
||||
s = f"{v:.2f}".rstrip("0").rstrip(".")
|
||||
return s
|
||||
|
||||
|
||||
def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
|
||||
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
|
||||
|
||||
|
||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
|
||||
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
||||
)
|
||||
figure = figure.strip() if figure else ""
|
||||
live_edge = ""
|
||||
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
|
||||
# "Live Edge" is a product-form flag driven by the *synced* edge term
|
||||
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
|
||||
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
|
||||
title = pattern.format(
|
||||
figure=figure + " " if figure else "",
|
||||
species=species_name,
|
||||
live_edge=live_edge,
|
||||
noun=noun,
|
||||
dims=_dims(slab["length_in"], slab["width_avg_in"], slab["thickness_in"]) if carries_dims else "",
|
||||
)
|
||||
return re.sub(r"\s+", " ", title).strip()
|
||||
|
||||
|
||||
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
|
||||
"""True when the synced edge term name/slug indicates live edge."""
|
||||
if not edge_term_id:
|
||||
return False
|
||||
term = terms.get(edge_term_id) or {}
|
||||
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
|
||||
return "live" in blob
|
||||
|
||||
|
||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
title = build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
ft, rem = _split_length(slab["length_in"])
|
||||
length_str = f"{ft}ft {_fmt1(rem)}in"
|
||||
bdft = round(slab["bdft"], 1)
|
||||
line = f"{title}, {length_str} x {_fmt1(slab['width_avg_in'])}in x {_fmt2(slab['thickness_in'])}in, {_fmt1(bdft)} bdft"
|
||||
return re.sub(r"\s+", " ", line).strip()
|
||||
|
||||
|
||||
def _slab_dict(row) -> dict:
|
||||
d = dict(row)
|
||||
for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"):
|
||||
if d.get(f) is not None and isinstance(d[f], str):
|
||||
try:
|
||||
d[f] = json.loads(d[f])
|
||||
except (ValueError, TypeError):
|
||||
d[f] = []
|
||||
return d
|
||||
|
||||
|
||||
def _taxonomy_lookup(conn):
|
||||
cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()}
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
return cats, terms
|
||||
|
||||
|
||||
def missing_required(slab: dict) -> list[str]:
|
||||
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
|
||||
|
||||
|
||||
# REVIEW-45: SKU is 3 to 24 chars, alphanumeric and hyphen only. The middle tier
|
||||
# (not the DB CHECK) is the business-logic arbiter here; the DB constraint is a backstop.
|
||||
_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$")
|
||||
|
||||
|
||||
def sku_valid(sku: Any) -> bool:
|
||||
return isinstance(sku, str) and bool(_SKU_RE.match(sku))
|
||||
|
||||
|
||||
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
|
||||
missing = missing_required(slab)
|
||||
# A present-but-malformed SKU is not ready (business rule, enforced in the middle tier).
|
||||
if "sku" not in missing and not sku_valid(slab.get("sku")):
|
||||
missing.append("sku")
|
||||
return (len(missing) == 0), missing
|
||||
|
||||
|
||||
def price_suggestion(conn, slab: dict) -> float | None:
|
||||
if not slab.get("species_id") or slab.get("bdft") is None:
|
||||
return None
|
||||
rate = rate_for_species(conn, slab["species_id"])
|
||||
if rate is None:
|
||||
return None
|
||||
return round(slab["bdft"] * rate, 2)
|
||||
|
||||
|
||||
@router.post("/{slab_id}/review")
|
||||
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
||||
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
|
||||
|
||||
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
|
||||
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
|
||||
win (METADATA-50). Assembled values are written back to the slab so the lock
|
||||
sticks for publish.
|
||||
"""
|
||||
payload = payload or {}
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
slab = _slab_dict(row)
|
||||
|
||||
# --- Deterministic review: report ready/missing + the deterministic
|
||||
# title/short_description built from the current metadata. The client's
|
||||
# "Create title" / "Create short desc" buttons call this endpoint and write the
|
||||
# returned strings into the fields (#89: no separate lock action).
|
||||
# #80: persist owner overrides (price/sku) from the payload so the review
|
||||
# endpoint is self-contained. title/short_description are NOT overridden here —
|
||||
# they are produced by the deterministic build below, not persisted from the
|
||||
# payload (so the review POST can't clobber the owner's Create workflow).
|
||||
cats, terms = _taxonomy_lookup(conn)
|
||||
|
||||
def _as_cat_id(v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
primary_id = _as_cat_id(payload.get("primary_wood_category_id"))
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
if not primary_id:
|
||||
primary_id = _as_cat_id(wood_cats[0]) if wood_cats else None
|
||||
# Taxonomy rows use integer PKs; coerce so string JSON ids still resolve.
|
||||
primary_leaf = cats.get(primary_id, {}) if primary_id is not None else {}
|
||||
species_id = _as_cat_id(slab.get("species_id"))
|
||||
species_name = cats.get(species_id, {}).get("name", "") if species_id is not None else ""
|
||||
figure_term_ids = slab.get("figure_term_ids") or []
|
||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
||||
|
||||
# #80: persist owner overrides from the payload (price/sku only).
|
||||
overrides: dict[str, Any] = {}
|
||||
for k in ("price", "sku"):
|
||||
if k in payload and payload[k] is not None:
|
||||
overrides[k] = payload[k]
|
||||
if overrides:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in overrides)
|
||||
conn.execute(
|
||||
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
|
||||
[*overrides.values(), _now(), slab_id],
|
||||
)
|
||||
conn.commit()
|
||||
slab.update(overrides)
|
||||
|
||||
# Deterministic title / short_description, re-derived from the current
|
||||
# metadata on every review (the Create button's source of truth).
|
||||
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
|
||||
# title_built tells the client whether the Create-title path actually ran
|
||||
# (vs echoing a pre-typed title when species/primary are missing from taxonomy).
|
||||
title_build_missing: list[str] = []
|
||||
if not species_name:
|
||||
title_build_missing.append("species")
|
||||
if not primary_leaf:
|
||||
title_build_missing.append("primary_wood_category")
|
||||
title_built = not title_build_missing
|
||||
title = (
|
||||
build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
if title_built
|
||||
else slab.get("title") or None
|
||||
)
|
||||
short_desc = (
|
||||
build_short_description(slab, figure, species_name, primary_leaf, terms)
|
||||
if title_built
|
||||
else slab.get("short_description") or None
|
||||
)
|
||||
|
||||
price = _resolve_price(conn, slab, payload)
|
||||
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
||||
return {
|
||||
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
|
||||
"price": price, "price_suggestion": price_suggestion(conn, slab),
|
||||
"primary_wood_category_id": primary_id,
|
||||
"title_built": title_built,
|
||||
"title_build_missing": title_build_missing,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
|
||||
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
|
||||
else the stored price, else the suggestion (None if no rate)."""
|
||||
suggested = price_suggestion(conn, slab)
|
||||
if payload.get("price") is not None:
|
||||
return payload["price"]
|
||||
if slab.get("price") not in (None, 0):
|
||||
return slab["price"]
|
||||
return suggested
|
||||
@@ -1,144 +0,0 @@
|
||||
"""S1 Settings service (DATA-MODEL §6, API.md §10, SETTINGS-5/15/20/25/30/75/85/90).
|
||||
|
||||
Secrets (value_enc) are AES-GCM write-only; GET returns only {configured: bool}.
|
||||
woo_base_url is env read-only. Flags default false. Partial PUT. Decrypt fail -> configured:false (no 500, no leak).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .crypto import decrypt, encrypt, DecryptError
|
||||
from .db import get_connection
|
||||
|
||||
SECRET_KEYS: set[str] = {
|
||||
"woo_app_password",
|
||||
"inference_base_url",
|
||||
"inference_api_key",
|
||||
}
|
||||
PLAIN_KEYS: set[str] = {
|
||||
"woo_wp_username",
|
||||
"inference_model",
|
||||
"inference_enabled",
|
||||
"content_llm_enabled",
|
||||
"brand_voice",
|
||||
"geo_context",
|
||||
"woo_create_status",
|
||||
"taxonomy_last_sync_at",
|
||||
"taxonomy_anchor",
|
||||
}
|
||||
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
||||
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"inference_enabled": False,
|
||||
"content_llm_enabled": False,
|
||||
"woo_create_status": "draft",
|
||||
}
|
||||
|
||||
|
||||
def _row_to_value(row: dict) -> Any:
|
||||
if row is None:
|
||||
return None
|
||||
v = row.get("value")
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
if v.lower() in ("true", "false"):
|
||||
return v.lower() == "true"
|
||||
return v
|
||||
|
||||
|
||||
def get_settings() -> dict[str, Any]:
|
||||
conn = get_connection()
|
||||
rows = {}
|
||||
for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall():
|
||||
rows[r["key"]] = {"value": r["value"], "value_enc": r["value_enc"]}
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
result["woo_base_url"] = os.environ.get("WOO_BASE_URL", "")
|
||||
|
||||
# TEMPORARY UAT override (owner-directed, LAN/HTTP only): when set, secrets are
|
||||
# returned as their decrypted plaintext so they can be inspected on the dev-test
|
||||
# box. Default OFF -> SETTINGS-75 masking intact. Remove before prod.
|
||||
unmask = os.environ.get("SLAB_UAT_UNMASK_SECRETS") == "1"
|
||||
|
||||
for k in sorted(ALL_KEYS):
|
||||
row = rows.get(k, {})
|
||||
if k in SECRET_KEYS:
|
||||
enc = row.get("value_enc")
|
||||
if unmask:
|
||||
if enc:
|
||||
try:
|
||||
result[k] = decrypt(enc)
|
||||
except Exception:
|
||||
result[k] = ""
|
||||
else:
|
||||
result[k] = ""
|
||||
else:
|
||||
if enc:
|
||||
try:
|
||||
decrypt(enc) # verify roundtrip possible
|
||||
result[k] = {"configured": True}
|
||||
except Exception:
|
||||
result[k] = {"configured": False}
|
||||
else:
|
||||
result[k] = {"configured": False}
|
||||
else:
|
||||
if k in rows:
|
||||
val = _row_to_value(row)
|
||||
result[k] = val if val is not None else DEFAULTS.get(k)
|
||||
else:
|
||||
result[k] = DEFAULTS.get(k)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if "woo_base_url" in payload:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "validation",
|
||||
"message": "woo_base_url is read-only (SETTINGS-85: env only)",
|
||||
"detail": {"field": "woo_base_url"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
for k, v in payload.items():
|
||||
if k not in ALL_KEYS:
|
||||
continue # ignore unknown for partial
|
||||
if k in SECRET_KEYS:
|
||||
if v in (None, "", False):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)",
|
||||
(k, None),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
enc = encrypt(str(v))
|
||||
except Exception:
|
||||
enc = None
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,?)",
|
||||
(k, None, enc),
|
||||
)
|
||||
else:
|
||||
# plain
|
||||
if v in (None, ""):
|
||||
conn.execute("DELETE FROM settings WHERE key=?", (k,))
|
||||
else:
|
||||
if k in ("inference_enabled", "content_llm_enabled"):
|
||||
val_str = "true" if bool(v) else "false"
|
||||
else:
|
||||
val_str = str(v)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)",
|
||||
(k, val_str),
|
||||
)
|
||||
conn.commit()
|
||||
return get_settings()
|
||||
@@ -1,309 +0,0 @@
|
||||
"""Slab CRUD service + FastAPI router (S2).
|
||||
|
||||
Client-derived numbers are stored as sent (never recomputed). Optimistic
|
||||
concurrency via a single compare-and-set UPDATE on server_rev. Soft delete
|
||||
sets deleted_at; published slabs cannot be deleted.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from .db import get_connection
|
||||
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
# Client-settable slab columns (explicit allowlist; never build SQL from client keys).
|
||||
ALLOWED_FIELDS = [
|
||||
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||
"width_min_in", "width_max_in", "width_avg_in", "species_id",
|
||||
"wood_category_ids", "edge_type_term_id", "figure_term_ids", "grade_term_id",
|
||||
"thickness_term_id", "moisture_term_id", "fig_tag_ids", "feat_tag_ids",
|
||||
"price", "price_source", "title", "short_description", "description",
|
||||
"content_source", "species_confidence", "figure_confidence", "grade_confidence",
|
||||
"inference_status", "calibration_photo_id",
|
||||
]
|
||||
JSON_ARRAY_FIELDS = {"wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"}
|
||||
# Columns a client may set on update (id/status/revs/timestamps/deleted_at excluded).
|
||||
ALLOWED_UPDATE_FIELDS = set(ALLOWED_FIELDS) - {
|
||||
"id", "status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at",
|
||||
}
|
||||
TAXONOMY_TABLES = {
|
||||
"species_id": "woo_categories",
|
||||
"wood_category_ids": "woo_categories",
|
||||
"edge_type_term_id": "woo_attribute_terms",
|
||||
"figure_term_ids": "woo_attribute_terms",
|
||||
"grade_term_id": "woo_attribute_terms",
|
||||
"thickness_term_id": "woo_attribute_terms",
|
||||
"moisture_term_id": "woo_attribute_terms",
|
||||
"fig_tag_ids": "woo_tags",
|
||||
"feat_tag_ids": "woo_tags",
|
||||
}
|
||||
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
|
||||
FIELD_LABELS = {
|
||||
"species_id": "Species",
|
||||
"wood_category_ids": "Wood category",
|
||||
"edge_type_term_id": "Edge type",
|
||||
"figure_term_ids": "Figure",
|
||||
"grade_term_id": "Grade",
|
||||
"thickness_term_id": "Thickness",
|
||||
"moisture_term_id": "Moisture",
|
||||
"fig_tag_ids": "Figure tags",
|
||||
"feat_tag_ids": "Feature tags",
|
||||
}
|
||||
PRICE_SOURCES = {"recommendation", "override", "manual"}
|
||||
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> Dict[str, Any]:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _err(code: str, message: str, detail: Any = None, status: int = 400) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content=_error(code, message, detail))
|
||||
|
||||
|
||||
def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(payload)
|
||||
for f in JSON_ARRAY_FIELDS:
|
||||
if f in out and isinstance(out[f], list):
|
||||
out[f] = json.dumps(out[f])
|
||||
return out
|
||||
|
||||
|
||||
# #89: server-managed fields that a plain client PUT must not set directly.
|
||||
# (metadata_locked was dropped — the deterministic title/short-desc are built by
|
||||
# the review endpoint and written back by the client's Create buttons.)
|
||||
CLIENT_FORBIDDEN_FIELDS = set()
|
||||
|
||||
|
||||
def _row_to_slab(row) -> Dict[str, Any]:
|
||||
d = dict(row)
|
||||
for f in JSON_ARRAY_FIELDS:
|
||||
if d.get(f) is not None and isinstance(d[f], str):
|
||||
try:
|
||||
d[f] = json.loads(d[f])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def _sku_taken(conn, sku: str, exclude_id: str | None = None) -> bool:
|
||||
if exclude_id:
|
||||
return conn.execute(
|
||||
"SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL AND id<>?", (sku, exclude_id)
|
||||
).fetchone() is not None
|
||||
return conn.execute(
|
||||
"SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL", (sku,)
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def _check_taxonomy(conn, table: str, val: Any) -> bool:
|
||||
if isinstance(val, list):
|
||||
if not val:
|
||||
return True
|
||||
ph = ",".join(["?"] * len(val))
|
||||
rows = conn.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", val).fetchall()
|
||||
return {r[0] for r in rows} == set(val)
|
||||
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
|
||||
|
||||
|
||||
def _current_options(conn, table: str) -> list[dict[str, Any]]:
|
||||
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
|
||||
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
|
||||
def _normalize_tax_val(val: Any) -> Any:
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return val
|
||||
|
||||
|
||||
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
|
||||
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
|
||||
for field, table in TAXONOMY_TABLES.items():
|
||||
if field not in payload or payload[field] is None:
|
||||
continue
|
||||
val = _normalize_tax_val(payload[field])
|
||||
if not _check_taxonomy(conn, table, val):
|
||||
label = FIELD_LABELS.get(field, field)
|
||||
return _err(
|
||||
"stale_field",
|
||||
f"{label} is no longer in the store taxonomy",
|
||||
{"field": field, "current_options": _current_options(conn, table)},
|
||||
status=422,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
|
||||
if "price_source" in payload and payload["price_source"] is not None:
|
||||
if payload["price_source"] not in PRICE_SOURCES:
|
||||
return _err(
|
||||
"validation",
|
||||
"price_source is not a known value",
|
||||
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
|
||||
status=400,
|
||||
)
|
||||
if "inference_status" in payload and payload["inference_status"] is not None:
|
||||
if payload["inference_status"] not in INFERENCE_STATUSES:
|
||||
return _err(
|
||||
"validation",
|
||||
"inference_status is not a known value",
|
||||
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Capture-only fields required to create a slab. The Review screen fills the
|
||||
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
|
||||
# editable until create"), so create must accept a capture-math payload.
|
||||
# client_rev is server-managed (set to 1 at create) — never required from the client.
|
||||
REQUIRED_CREATE_FIELDS = [
|
||||
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||
"width_min_in", "width_max_in", "width_avg_in",
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
|
||||
if missing:
|
||||
return _err("validation", "missing fields", {"fields": missing}, status=400)
|
||||
# #83: reject unknown keys (client sent a field that is not client-settable).
|
||||
# The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped
|
||||
# — but a 400 tells the client its payload is malformed, not that the slab
|
||||
# was created with the extra keys silently ignored.
|
||||
unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"]
|
||||
if unknown:
|
||||
return _err("validation", "unknown fields", {"fields": unknown}, status=400)
|
||||
# Business rule (middle tier, not the DB): an empty/blank SKU at create means
|
||||
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
|
||||
# governs present SKUs, and NULL is the honest "absent" representation.
|
||||
sku = payload.get("sku")
|
||||
if isinstance(sku, str) and sku.strip() == "":
|
||||
payload = {**payload, "sku": None}
|
||||
enum_err = _validate_enums(payload)
|
||||
if enum_err is not None:
|
||||
return enum_err
|
||||
conn = get_connection()
|
||||
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||
if tax_err is not None:
|
||||
return tax_err
|
||||
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
|
||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
|
||||
# Resolve it from the measured thickness against the live synced Woo bands and
|
||||
# overwrite whatever the client sent (the UI has no band control).
|
||||
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
|
||||
if band is not None:
|
||||
payload = {**payload, "thickness_term_id": band}
|
||||
now = _now_iso()
|
||||
data = _encode(payload)
|
||||
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
|
||||
server_cols = ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
|
||||
server_vals = ["calibrated", 1, 1, None, now, now]
|
||||
cols = allowed_cols + server_cols
|
||||
# One value per col, in cols order: data values for the allowed fields, then the 6 server values.
|
||||
vals = [data[f] for f in allowed_cols] + server_vals
|
||||
ph = ",".join(["?"] * len(cols))
|
||||
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
|
||||
try:
|
||||
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# QUALITY-120: never surface a driver stack to the owner.
|
||||
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
|
||||
return _row_to_slab(row)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_slabs() -> List[Dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
rows = conn.execute("SELECT * FROM slabs WHERE deleted_at IS NULL ORDER BY updated_at DESC, id DESC").fetchall()
|
||||
return [_row_to_slab(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_slab(id: str) -> Dict[str, Any]:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
||||
if row is None:
|
||||
return _err("not_found", "Slab not found", status=404)
|
||||
return _row_to_slab(row)
|
||||
|
||||
|
||||
@router.put("/{id}")
|
||||
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if "client_rev" not in payload:
|
||||
return _err("validation", "client_rev required", status=400)
|
||||
# #89: server-managed fields a plain PUT must not set.
|
||||
for field in CLIENT_FORBIDDEN_FIELDS:
|
||||
if field in payload:
|
||||
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
|
||||
enum_err = _validate_enums(payload)
|
||||
if enum_err is not None:
|
||||
return enum_err
|
||||
conn = get_connection()
|
||||
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
||||
if current is None:
|
||||
return _err("not_found", "Slab not found", status=404)
|
||||
client_rev = payload["client_rev"]
|
||||
if client_rev != current["server_rev"]:
|
||||
return _err("revision_conflict", "client_rev mismatch",
|
||||
{"current_server_rev": current["server_rev"]}, status=409)
|
||||
if "sku" in payload and payload["sku"] != current["sku"]:
|
||||
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
|
||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||
if tax_err is not None:
|
||||
return tax_err
|
||||
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
|
||||
eff_thickness = payload.get("thickness_in", current["thickness_in"])
|
||||
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
|
||||
if band is not None:
|
||||
payload = {**payload, "thickness_term_id": band}
|
||||
data = _encode(payload)
|
||||
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
|
||||
if not cols:
|
||||
return _row_to_slab(current)
|
||||
set_clause = ", ".join(f"{f} = ?" for f in cols)
|
||||
vals = [data[f] for f in cols] + [client_rev + 1, _now_iso(), id, client_rev]
|
||||
cur = conn.execute(
|
||||
f"UPDATE slabs SET {set_clause}, server_rev=?, updated_at=? WHERE id=? AND server_rev=?",
|
||||
vals,
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
cur_server = conn.execute("SELECT server_rev FROM slabs WHERE id=?", (id,)).fetchone()
|
||||
return _err("revision_conflict", "client_rev mismatch",
|
||||
{"current_server_rev": cur_server[0] if cur_server else None}, status=409)
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=?", (id,)).fetchone()
|
||||
return _row_to_slab(row)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=204)
|
||||
async def delete_slab(id: str):
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
||||
if row is None:
|
||||
return _err("not_found", "Slab not found", status=404)
|
||||
if row["status"] == "published":
|
||||
return _err("publish_conflict", "Published slab cannot be deleted", status=409)
|
||||
conn.execute("UPDATE slabs SET deleted_at=? WHERE id=?", (_now_iso(), id))
|
||||
conn.commit()
|
||||
return Response(status_code=204)
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
|
||||
|
||||
Rebuild happens in one transaction; on any failure the prior cache is kept.
|
||||
Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen
|
||||
(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored
|
||||
subset, recorded only when it changes. Display names are HTML-unescaped so
|
||||
Woo entities like ``&`` store as ``&``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .woo import WooClient
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _decode_name(name: str | None) -> str:
|
||||
"""Decode HTML entities Woo puts in taxonomy display names."""
|
||||
return html.unescape(name or "")
|
||||
|
||||
|
||||
def _scoped_tags(tags: list[dict]) -> list[dict]:
|
||||
"""Keep product tags whose slug starts with fig- or feat- (hyphen required).
|
||||
|
||||
Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl.
|
||||
Filtering by name prefix kept 0 live tags; slug is authoritative.
|
||||
"""
|
||||
out = []
|
||||
for t in tags:
|
||||
slug = t.get("slug") or ""
|
||||
if slug.startswith("fig-") or slug.startswith("feat-"):
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_category(c: dict) -> tuple:
|
||||
"""Map a Woo REST category row into woo_categories insert values.
|
||||
|
||||
WooCommerce REST v3 returns `parent` (0 = top-level). Older fixtures may
|
||||
already use `parent_id`. Never invent ids — only store what the payload carries.
|
||||
"""
|
||||
raw_parent = c.get("parent_id", c.get("parent"))
|
||||
if raw_parent in (0, "0", None, ""):
|
||||
parent_id = None
|
||||
else:
|
||||
parent_id = int(raw_parent)
|
||||
woo_id = c.get("woo_id", c.get("id"))
|
||||
if woo_id is not None:
|
||||
woo_id = int(woo_id)
|
||||
return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id)
|
||||
|
||||
|
||||
def _subset_hash(categories: list[dict], attributes: list[dict],
|
||||
terms_by_attr: dict, scoped_tags: list[dict]) -> str:
|
||||
subset = {
|
||||
"categories": sorted(categories, key=lambda c: c["id"]),
|
||||
"attributes": sorted(attributes, key=lambda a: a["id"]),
|
||||
"terms": {str(k): sorted(v, key=lambda t: t["id"]) for k, v in terms_by_attr.items()},
|
||||
"tags": sorted(scoped_tags, key=lambda t: t["id"]),
|
||||
}
|
||||
return hashlib.sha256(json.dumps(subset, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sync(client: WooClient, conn) -> dict:
|
||||
"""Rebuild the taxonomy cache from the store. Raises WooError on failure (caller
|
||||
maps to 502; prior cache is retained because we only commit on success)."""
|
||||
categories = client.get_categories()
|
||||
tags = client.get_tags()
|
||||
attributes = client.get_attributes()
|
||||
terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes}
|
||||
scoped_tags = _scoped_tags(tags)
|
||||
|
||||
# Attributes/tags/terms are not referenced by slabs FKs — safe to rebuild.
|
||||
conn.execute("DELETE FROM woo_attribute_terms")
|
||||
conn.execute("DELETE FROM woo_attributes")
|
||||
conn.execute("DELETE FROM woo_tags")
|
||||
|
||||
# Categories: upsert then drop stale rows. A full DELETE fails when slabs or
|
||||
# pricing_rules still reference species_id (live UAT IntegrityError on test-woo).
|
||||
cat_rows = [_normalize_category(c) for c in categories]
|
||||
cat_ids = [r[0] for r in cat_rows]
|
||||
for row in cat_rows:
|
||||
conn.execute(
|
||||
"INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET "
|
||||
"name=excluded.name, slug=excluded.slug, parent_id=excluded.parent_id, woo_id=excluded.woo_id",
|
||||
row,
|
||||
)
|
||||
if cat_ids:
|
||||
placeholders = ",".join("?" * len(cat_ids))
|
||||
conn.execute(
|
||||
f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) "
|
||||
"AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
|
||||
"AND id NOT IN (SELECT species_id FROM pricing_rules)",
|
||||
cat_ids,
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM woo_categories "
|
||||
"WHERE id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
|
||||
"AND id NOT IN (SELECT species_id FROM pricing_rules)"
|
||||
)
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||
[(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes],
|
||||
)
|
||||
for attr_id, terms in terms_by_attr.items():
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
|
||||
[(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||
[(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags],
|
||||
)
|
||||
|
||||
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
|
||||
now = _now_iso()
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_last_sync_at', ?, NULL)", (now,))
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_anchor', ?, NULL)", (anchor,))
|
||||
conn.commit()
|
||||
return {"taxonomy_anchor": anchor, "taxonomy_last_sync_at": now}
|
||||
|
||||
|
||||
def get_cached(conn) -> dict:
|
||||
"""Return the cached taxonomy grouped for the admin view."""
|
||||
categories = [dict(r) for r in conn.execute("SELECT * FROM woo_categories ORDER BY name").fetchall()]
|
||||
attributes = [dict(r) for r in conn.execute("SELECT * FROM woo_attributes ORDER BY name").fetchall()]
|
||||
terms = [dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms ORDER BY name").fetchall()]
|
||||
tags = [dict(r) for r in conn.execute("SELECT * FROM woo_tags ORDER BY name").fetchall()]
|
||||
anchor_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_anchor'").fetchone()
|
||||
sync_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()
|
||||
terms_by_attr: dict[int, list] = {a["id"]: [] for a in attributes}
|
||||
for t in terms:
|
||||
terms_by_attr.setdefault(t["attribute_id"], []).append(t)
|
||||
grouped_attrs = [
|
||||
{
|
||||
"id": a["id"],
|
||||
"name": a["name"],
|
||||
"slug": a["slug"],
|
||||
"terms": [{"id": t["id"], "name": t["name"], "slug": t["slug"]} for t in terms_by_attr.get(a["id"], [])],
|
||||
}
|
||||
for a in attributes
|
||||
]
|
||||
return {
|
||||
"categories": categories,
|
||||
"attributes": grouped_attrs,
|
||||
"tags": tags,
|
||||
"taxonomy_anchor": anchor_row[0] if anchor_row else None,
|
||||
"taxonomy_last_sync_at": sync_row[0] if sync_row else None,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared test fixtures for all backend tests.
|
||||
|
||||
App factory pattern (FUND-8): each test gets a fresh app instance.
|
||||
FUND-9: TestClient manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import engine
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _test_db() -> None:
|
||||
"""Set up a fresh in-memory SQLite database for each test."""
|
||||
engine.set_test_url("sqlite+aiosqlite:///:memory:")
|
||||
yield
|
||||
engine._test_url = None
|
||||
engine._engine = None
|
||||
engine._session_factory = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
"""Create test settings with in-memory SQLite."""
|
||||
return Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk", # 32 bytes base64
|
||||
woo_base_url="https://test.example.com",
|
||||
app_hostname="test.local",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(settings: Settings) -> None:
|
||||
"""Yield a fresh FastAPI app with test settings, then clean up.
|
||||
|
||||
Each test gets its own app instance (FUND-8).
|
||||
TestClient manages the lifespan (FUND-9).
|
||||
dependency_overrides are cleared after each test (FUND-10).
|
||||
"""
|
||||
app_instance = create_app(settings)
|
||||
yield app_instance
|
||||
app_instance.dependency_overrides.clear()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""H-1: Health endpoint contract test.
|
||||
|
||||
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
|
||||
|
||||
H-1: GET /api/health returns 200 {status: ok} with woo_reachable false
|
||||
when credentials are not configured.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""GET /api/health contract tests."""
|
||||
|
||||
def test_returns_200(self) -> None:
|
||||
"""H-1: Health endpoint returns 200."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_ok_status(self) -> None:
|
||||
"""H-1: Health endpoint returns status ok."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_woo_reachable_false_when_credentials_not_configured(self) -> None:
|
||||
"""H-1: woo_reachable is false when Woo credentials not configured."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
assert data["woo_reachable"] is False
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_shape(self) -> None:
|
||||
"""H-1: Response contains exactly status, version, and woo_reachable."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "version" in data
|
||||
assert "woo_reachable" in data
|
||||
assert isinstance(data["status"], str)
|
||||
assert isinstance(data["version"], str)
|
||||
assert isinstance(data["woo_reachable"], bool)
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_with_test_client_lifespan(self) -> None:
|
||||
"""FUND-9: TestClient with app manages lifespan correctly."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,520 @@
|
||||
"""D-1: Draft store stub — POST /api/v1/slabs contract test.
|
||||
|
||||
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
|
||||
|
||||
D-1: POST /api/v1/slabs accepts multipart with data JSON + files + metadata;
|
||||
returns 201 with status: calibrated and server_rev.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestCreateSlab:
|
||||
"""D-1: POST /api/v1/slabs multipart contract."""
|
||||
|
||||
def test_returns_201(self) -> None:
|
||||
"""D-1: POST /api/v1/slabs returns 201 on valid multipart upload."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0042",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_status_calibrated(self) -> None:
|
||||
"""D-1: Response contains status: calibrated."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0099",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["status"] == "calibrated"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_server_rev(self) -> None:
|
||||
"""D-1: Response contains server_rev."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0077",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "server_rev" in body
|
||||
assert body["server_rev"] == 1
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_shape(self) -> None:
|
||||
"""D-1: Response contains required fields."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0033",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
|
||||
# Required fields per OPENAPI.md Slab read model
|
||||
required_fields = [
|
||||
"id", "sku", "status", "length_in", "thickness_in",
|
||||
"sqft", "bdft", "width_min_in", "width_max_in", "width_avg_in",
|
||||
"species_id", "wood_category_ids", "edge_type_term_id",
|
||||
"figure_term_ids", "grade_term_ids", "thickness_term_id",
|
||||
"moisture_term_id", "fig_tag_ids", "feat_tag_ids",
|
||||
"price_per_bdft", "price", "price_source", "title",
|
||||
"short_title", "description", "content_source",
|
||||
"client_rev", "server_rev", "created_at", "updated_at",
|
||||
"photos", "inference_status",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in body, f"Missing field: {field}"
|
||||
|
||||
# Verify stored values match sent values (server stores as-is)
|
||||
assert body["id"] == slab_id
|
||||
assert body["sku"] == "BW-0033"
|
||||
assert body["sqft"] == 0.64
|
||||
assert body["bdft"] == 0.96
|
||||
assert body["width_min_in"] == 14.0
|
||||
assert body["width_max_in"] == 22.0
|
||||
assert body["width_avg_in"] == 18.2
|
||||
assert body["price"] == 12.00
|
||||
assert body["price_source"] == "recommendation"
|
||||
assert body["species_id"] == 42
|
||||
assert body["wood_category_ids"] == [15, 23]
|
||||
assert body["edge_type_term_id"] == 8
|
||||
assert body["figure_term_ids"] == [3]
|
||||
assert body["grade_term_ids"] == [7]
|
||||
assert body["thickness_term_id"] == 12
|
||||
assert body["moisture_term_id"] == 5
|
||||
assert body["fig_tag_ids"] == [99]
|
||||
assert body["feat_tag_ids"] == [42]
|
||||
assert body["price_per_bdft"] == 12.50
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_multiple_photos(self) -> None:
|
||||
"""D-1: Multiple files[] and meta[] pairs are accepted."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0055",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
("files[]", ("extra1.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
# Note: httpx TestClient with multiple files[] + single meta[]
|
||||
# will pair them. The meta[] for the second file defaults.
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert len(body["photos"]) >= 2
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_missing_data_field_returns_400(self) -> None:
|
||||
"""D-1: Missing data field returns 400."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["detail"]["code"] == "validation"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_duplicate_sku_returns_409(self) -> None:
|
||||
"""D-1: Duplicate SKU returns 409."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
sku = "DUPLICATE-TEST"
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": sku,
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
# Create first slab
|
||||
response1 = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response1.status_code == 201
|
||||
|
||||
# Try to create second slab with same SKU
|
||||
response2 = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response2.status_code == 409
|
||||
body = response2.json()
|
||||
assert body["detail"]["code"] == "duplicate_sku"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_with_test_client_lifespan(self) -> None:
|
||||
"""FUND-9: TestClient with app manages lifespan correctly for slabs."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0011",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["status"] == "calibrated"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""GC-4/GC-5: WOO_BASE_URL contract tests.
|
||||
|
||||
GC-4: WOO_BASE_URL is compose-only HTTPS; Settings GET returns it read-only;
|
||||
PUT rejects write attempts with 422.
|
||||
GC-5: FastAPI refuses to start when WOO_BASE_URL is missing or http://.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestWooBaseUrlValidation:
|
||||
"""GC-5: FastAPI refuses invalid WOO_BASE_URL at startup."""
|
||||
|
||||
def test_refuses_http_url(self) -> None:
|
||||
"""GC-5: Settings.validate() raises ValueError for http:// URL."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="http://insecure.example.com",
|
||||
)
|
||||
with pytest.raises(ValueError, match="(?i)https"):
|
||||
settings.validate()
|
||||
|
||||
def test_refuses_missing_url(self) -> None:
|
||||
"""GC-5: Settings.validate() raises ValueError when woo_base_url is None."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
settings.validate()
|
||||
|
||||
def test_accepts_https_url(self) -> None:
|
||||
"""GC-5: Settings.validate() accepts HTTPS URL."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://secure.example.com",
|
||||
)
|
||||
settings.validate() # Should not raise
|
||||
|
||||
|
||||
class TestWooBaseUrlReadOnly:
|
||||
"""GC-4: Settings endpoints handle woo_base_url correctly."""
|
||||
|
||||
def test_get_settings_returns_woo_base_url(self) -> None:
|
||||
"""GC-4: GET /api/v1/settings returns woo_base_url from compose env."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["woo_base_url"] == "https://store.example.com"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_rejects_woo_base_url(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings rejects woo_base_url field with 422."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"woo_base_url": "https://hacked.example.com"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_allows_other_fields(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings still accepts other settings fields."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"woo_create_status": "publish"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["woo_create_status"] == "publish"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_get_settings_never_returns_app_password(self) -> None:
|
||||
"""GC-4: GET /api/v1/settings never returns woo_app_password."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "woo_app_password" not in data
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_rejects_consumer_keys(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings rejects consumer key fields with 422."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"woo_consumer_key": "ck_test",
|
||||
"woo_consumer_secret": "cs_test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,52 +0,0 @@
|
||||
"""TDD red tests for S1 crypto (QUALITY-125, SETTINGS-20/25)."""
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import crypto
|
||||
|
||||
|
||||
def _valid_key_b64() -> str:
|
||||
# 32 raw bytes base64
|
||||
raw = b"0123456789abcdef0123456789abcdef" # exactly 32
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
def test_crypto_roundtrip_encrypt_decrypt(monkeypatch):
|
||||
"""Given: valid SLAB_AES_KEY; When: encrypt then decrypt; Then: original value (QUALITY-125)."""
|
||||
key_b64 = _valid_key_b64()
|
||||
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
|
||||
plaintext = "my-secret-app-password-123"
|
||||
ct_b64 = crypto.encrypt(plaintext)
|
||||
assert isinstance(ct_b64, str)
|
||||
recovered = crypto.decrypt(ct_b64)
|
||||
assert recovered == plaintext
|
||||
|
||||
|
||||
def test_crypto_decrypt_tampered_raises_clean_error(monkeypatch):
|
||||
"""Given: valid key; When: tamper the blob (flip a byte); Then: raises detectable error, no crash, no raw leak."""
|
||||
key_b64 = _valid_key_b64()
|
||||
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
|
||||
plaintext = "secret"
|
||||
ct_b64 = crypto.encrypt(plaintext)
|
||||
# tamper: flip last byte
|
||||
blob = bytearray(base64.b64decode(ct_b64))
|
||||
blob[-1] ^= 0xFF
|
||||
tampered_b64 = base64.b64encode(blob).decode()
|
||||
with pytest.raises(Exception) as exc: # will be specific like InvalidTag or our wrapper
|
||||
crypto.decrypt(tampered_b64)
|
||||
# must not contain the raw blob or secret
|
||||
msg = str(exc.value).lower()
|
||||
assert "secret" not in msg
|
||||
assert plaintext not in msg
|
||||
# detectable, e.g. contains "decrypt" or "tag" or "corrupt"
|
||||
assert any(k in msg for k in ("decrypt", "tag", "invalid", "corrupt", "fail"))
|
||||
|
||||
|
||||
def test_crypto_missing_key_raises(monkeypatch):
|
||||
"""Given: no SLAB_AES_KEY; When: encrypt; Then: clear runtime error."""
|
||||
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
|
||||
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
|
||||
crypto.encrypt("x")
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Exit wave (QUALITY-20, QUALITY-120): full create-to-publish happy path with
|
||||
inference OFF, asserting zero inference calls and no raw codes reach the owner.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="exit_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "e.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
|
||||
db_mod._reset_for_tests()
|
||||
from backend.crypto import encrypt
|
||||
|
||||
conn = db_mod.init_db()
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("pw"),))
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','false',NULL)")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','false',NULL)")
|
||||
from datetime import datetime, timezone
|
||||
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
|
||||
(datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
|
||||
conn.commit()
|
||||
from backend.app import app
|
||||
|
||||
yield TestClient(app), conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _mock(monkeypatch, sku_exists=0, product_id=777):
|
||||
from backend.woo import WooClient
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, json.dumps([{"id": 1}] * sku_exists).encode()
|
||||
if url.endswith("/wp-json/wp/v2/media"):
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products"):
|
||||
return 201, {}, json.dumps({"id": product_id}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
orig = WooClient.__init__
|
||||
|
||||
def patched(self, *a, **k):
|
||||
orig(self, *a, **k)
|
||||
|
||||
def bound(url, method, data, headers):
|
||||
return fake_request(url, method, data, headers)
|
||||
|
||||
self._request = bound
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched)
|
||||
|
||||
|
||||
def test_full_happy_path_inference_off(env, monkeypatch):
|
||||
"""Create slab -> attach photo -> review -> publish, inference OFF.
|
||||
Zero inference_log rows; all responses carry plain messages (QUALITY-120)."""
|
||||
client, conn = env
|
||||
_mock(monkeypatch)
|
||||
# 1. create slab
|
||||
r = client.post("/api/v1/slabs", json={
|
||||
"id": "s1", "sku": "SLAB-UAT-7", "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
|
||||
"bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
|
||||
"species_id": 1, "wood_category_ids": [2], "edge_type_term_id": 1, "figure_term_ids": [],
|
||||
"grade_term_id": 1, "thickness_term_id": 1, "moisture_term_id": 1,
|
||||
"fig_tag_ids": [], "feat_tag_ids": [], "price": 184.0, "price_source": "recommendation",
|
||||
"title": "Black Walnut Live Edge Slab", "short_description": "slab, 9ft 6in",
|
||||
"description": "A fine slab.", "content_source": "manual",
|
||||
"species_confidence": None, "figure_confidence": None, "grade_confidence": None,
|
||||
"inference_status": None, "calibration_photo_id": None,
|
||||
})
|
||||
assert r.status_code in (201, 200), r.text
|
||||
# 2. attach photo
|
||||
r = client.post("/api/v1/slabs/s1/photos", data={"photo_id": "p1"},
|
||||
files={"original": ("o.png", b"\x89PNG", "image/png")})
|
||||
assert r.status_code == 201, r.text
|
||||
# 3. review (deterministic copy)
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
assert r.status_code == 200
|
||||
# 4. publish
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["woo_product_id"] == 777 and body["draft"] is True
|
||||
# QUALITY-20: zero inference calls in off mode
|
||||
assert conn.execute("SELECT COUNT(*) c FROM inference_log").fetchone()["c"] == 0
|
||||
# QUALITY-120: response has no raw status codes / stack traces in owner-facing message
|
||||
assert "Traceback" not in json.dumps(body)
|
||||
# success log
|
||||
assert conn.execute("SELECT status FROM sync_log").fetchone()["status"] == "success"
|
||||
|
||||
|
||||
def test_not_ready_publish_422_plain_message(env, monkeypatch):
|
||||
"""Failure path returns a plain field-level message, no raw code (QUALITY-120)."""
|
||||
client, conn = env
|
||||
_mock(monkeypatch)
|
||||
client.post("/api/v1/slabs", json={
|
||||
"id": "s1", "sku": "SLAB-01", "length_in": 100, "thickness_in": 2, "sqft": 20, "bdft": 8,
|
||||
"width_min_in": 10, "width_max_in": 12, "width_avg_in": 11, "species_id": 1,
|
||||
"wood_category_ids": [2], "edge_type_term_id": 1, "figure_term_ids": [], "grade_term_id": 1,
|
||||
"thickness_term_id": 1, "moisture_term_id": 1, "fig_tag_ids": [], "feat_tag_ids": [],
|
||||
"price": None, "price_source": None, "title": "T", "short_description": "S",
|
||||
"description": "D", "content_source": "manual",
|
||||
"species_confidence": None, "figure_confidence": None, "grade_confidence": None,
|
||||
"inference_status": None, "calibration_photo_id": None,
|
||||
})
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 422
|
||||
body = r.json()
|
||||
assert "missing" in body["error"]["detail"]
|
||||
assert "Traceback" not in json.dumps(body)
|
||||
assert "price" in body["error"]["detail"]["missing"]
|
||||
@@ -1,73 +0,0 @@
|
||||
"""TDD red tests for S0 health endpoint and startup guards (APP-30, APP-45, SETTINGS-10, SETTINGS-25)."""
|
||||
import importlib
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
def _reload_app():
|
||||
import backend.app as app_mod
|
||||
return importlib.reload(app_mod)
|
||||
|
||||
|
||||
def test_health_happy_path_returns_200_with_three_fields(monkeypatch):
|
||||
"""Given: writable db + image dir; When: GET /api/health; Then: 200 + {status, version, woo_reachable}."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = os.path.join(tmp, "test.db")
|
||||
img_dir = os.path.join(tmp, "images")
|
||||
os.mkdir(img_dir)
|
||||
monkeypatch.setenv("SLAB_DB_PATH", db_path)
|
||||
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert set(body.keys()) == {"status", "version", "woo_reachable"}
|
||||
assert body["status"] == "ok"
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_health_fails_non_200_when_image_dir_not_writable(monkeypatch):
|
||||
"""Given: image dir not writable; When: GET /api/health; Then: non-200 (probes health file per DATA-MODEL §1)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = os.path.join(tmp, "test.db")
|
||||
img_dir = os.path.join(tmp, "images")
|
||||
os.mkdir(img_dir)
|
||||
monkeypatch.setenv("SLAB_DB_PATH", db_path)
|
||||
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
|
||||
# force probe fail without relying on chmod (win32 owner can still write)
|
||||
with patch("backend.app.probe_images_writable", return_value=False):
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code != 200
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_startup_guard_refuses_on_missing_or_non_https_woo_base_url(monkeypatch):
|
||||
"""Given: WOO_BASE_URL missing or not https; When: import/create app; Then: raises with clear message (SETTINGS-10)."""
|
||||
monkeypatch.delenv("WOO_BASE_URL", raising=False)
|
||||
with pytest.raises(RuntimeError, match="WOO_BASE_URL"):
|
||||
_reload_app()
|
||||
monkeypatch.setenv("WOO_BASE_URL", "http://insecure.example.com")
|
||||
with pytest.raises(RuntimeError, match="https"):
|
||||
_reload_app()
|
||||
|
||||
|
||||
def test_startup_guard_refuses_on_missing_slab_aes_key(monkeypatch):
|
||||
"""Given: SLAB_AES_KEY missing; When: import/create app; Then: raises with clear message (SETTINGS-25)."""
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
|
||||
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
|
||||
_reload_app()
|
||||
@@ -1,641 +0,0 @@
|
||||
"""S7 inference tests (METADATA-105/106, 45, 55, 70, SETTINGS-45)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="inf_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://x.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
db_mod._reset_for_tests()
|
||||
from backend.crypto import encrypt
|
||||
|
||||
conn = db_mod.init_db()
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_base_url',NULL,?)", (encrypt("https://infer.example.com"),))
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_api_key',NULL,?)", (encrypt("sk-test"),))
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_model','gpt-test',NULL)")
|
||||
conn.commit()
|
||||
from backend.app import app
|
||||
|
||||
yield TestClient(app), conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_prompt_seed_and_revision(env):
|
||||
client, conn = env
|
||||
r = client.get("/api/v1/prompts/call1")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["active_content"] is not None
|
||||
assert len(body["revisions"]) == 1 # seed only
|
||||
|
||||
r = client.post("/api/v1/prompts/call1/revisions", json={"content": "revised prompt v2"})
|
||||
assert r.status_code == 201
|
||||
new_rev = r.json()["id"]
|
||||
# new revision becomes active
|
||||
r = client.get("/api/v1/prompts/call1")
|
||||
assert r.json()["active_revision_id"] == new_rev
|
||||
assert r.json()["active_content"] == "revised prompt v2"
|
||||
assert len(r.json()["revisions"]) == 2
|
||||
|
||||
# activate back to seed
|
||||
seed_rev = 1
|
||||
r = client.post("/api/v1/prompts/call1/activate", json={"revision_id": seed_rev})
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None
|
||||
|
||||
|
||||
def test_call1_seed_is_locked_expert_prompt(env):
|
||||
"""#86: shipped Call 1 seed is the locked expert prompt (no free-text bags, no grade)."""
|
||||
client, _conn = env
|
||||
from backend.inference import CALL1_SEED
|
||||
|
||||
body = client.get("/api/v1/prompts/call1").json()
|
||||
assert body["active_content"] == CALL1_SEED
|
||||
assert "wood identification expert" in CALL1_SEED
|
||||
assert "feature_tag_ids" in CALL1_SEED
|
||||
assert "Decide wood categories before edge type" in CALL1_SEED
|
||||
assert "highest-confidence" in CALL1_SEED
|
||||
assert "Wild edge" in CALL1_SEED
|
||||
assert "cookies and burl" in CALL1_SEED.lower() or "cookies and burl" in CALL1_SEED
|
||||
assert "grade_term_id" not in CALL1_SEED
|
||||
assert '"character"' not in CALL1_SEED
|
||||
assert '"inclusions"' not in CALL1_SEED
|
||||
assert "character\":" not in CALL1_SEED.replace(" ", "")
|
||||
|
||||
|
||||
def test_call2_seed_has_short_long_structure(env):
|
||||
"""Call 2 seed: plain-text short+long, woodworker tone, JSON dual output (not brand voice)."""
|
||||
client, _conn = env
|
||||
from backend.inference import CALL2_SEED
|
||||
|
||||
body = client.get("/api/v1/prompts/call2").json()
|
||||
assert body["active_content"] == CALL2_SEED
|
||||
assert "SHORT DESCRIPTION" in CALL2_SEED
|
||||
assert "LONG DESCRIPTION" in CALL2_SEED
|
||||
assert "PLAIN TEXT ONLY" in CALL2_SEED
|
||||
assert "plain text only" in CALL2_SEED.lower()
|
||||
assert "No HTML tags" in CALL2_SEED
|
||||
assert "woodworkers choosing stock" in CALL2_SEED
|
||||
assert "elevate" in CALL2_SEED # banned-phrase guidance
|
||||
assert "stunning" in CALL2_SEED
|
||||
assert "Lead with a practical outcome" in CALL2_SEED
|
||||
assert "Hook" in CALL2_SEED
|
||||
assert "Brand voice and Geography sections when provided" in CALL2_SEED
|
||||
assert "Whidbey" not in CALL2_SEED
|
||||
assert "Pacific Northwest" not in CALL2_SEED
|
||||
assert '"short_description"' in CALL2_SEED
|
||||
assert '"description"' in CALL2_SEED
|
||||
assert "<HTML long description>" not in CALL2_SEED
|
||||
assert "plain text long description" in CALL2_SEED
|
||||
# No Full-specs / attribute metadata dump in long desc (specs live on Woo fields).
|
||||
assert "Hook + Benefits only" in CALL2_SEED
|
||||
assert "Do NOT restate DECLARED" in CALL2_SEED
|
||||
assert 'no "Full specs"' in CALL2_SEED or "Do NOT add a Full specs" in CALL2_SEED
|
||||
assert "Category:/Figure:/Edge:" in CALL2_SEED
|
||||
assert "1–2 key traits" in CALL2_SEED
|
||||
assert "labeled catalog" in CALL2_SEED or "labeled metadata list" in CALL2_SEED
|
||||
# Must not instruct a Full-specs numbered part (old 4-part structure).
|
||||
assert "3. Full specs" not in CALL2_SEED
|
||||
assert "Four parts, in order" not in CALL2_SEED
|
||||
|
||||
|
||||
def test_taxonomy_lists_call1_closed_shape():
|
||||
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
|
||||
import tempfile, os, shutil
|
||||
tmp = tempfile.mkdtemp(prefix="tax_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ.setdefault("WOO_BASE_URL", "https://x.com")
|
||||
os.environ.setdefault(
|
||||
"SLAB_AES_KEY",
|
||||
base64.b64encode(b"0123456789abcdef0123456789abcdef").decode(),
|
||||
)
|
||||
db_mod._reset_for_tests()
|
||||
conn = db_mod.init_db()
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (10,'Species','species',NULL,10)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Walnut','walnut',10,11)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (20,'Wcat','wcat',NULL,20)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (21,'Live edge wood','liveedge',20,21)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (6,'Edge type','pa_etype',6)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Figure','pa_fig',8)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Grade','pa_gr',9)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Thickness','pa_thk',10)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (7,'Moisture','pa_mst',7)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (321,6,'Square edge','sqe',321)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (328,8,'Plain','pl',328)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (342,9,'Fancy','fnc',342)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (350,10,'1\"- 1 1/2\"','4-6qtr',350)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,7,'Kiln dried','kd',326)")
|
||||
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)")
|
||||
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (2,'fig-curl','fig-curl',2)")
|
||||
conn.commit()
|
||||
from backend.inference import _taxonomy_lists
|
||||
|
||||
tax = _taxonomy_lists(conn)
|
||||
assert set(tax.keys()) == {"species", "wood_categories", "edge_terms", "figure_terms", "feature_tags"}
|
||||
assert tax["species"] == [{"id": 11, "name": "Walnut"}]
|
||||
assert tax["wood_categories"] == [{"id": 21, "name": "Live edge wood"}]
|
||||
assert tax["edge_terms"] == [{"id": 321, "name": "Square edge"}]
|
||||
assert tax["figure_terms"] == [{"id": 328, "name": "Plain"}]
|
||||
# feat- prefix stripped for the model; fig- tags are not feature_tags
|
||||
assert tax["feature_tags"] == [{"id": 1, "name": "knot"}]
|
||||
assert "grade_terms" not in tax
|
||||
assert "thickness_terms" not in tax
|
||||
assert "moisture_terms" not in tax
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_gate_feature_tag_ids_not_free_text():
|
||||
"""#86: gate keeps feature_tag_ids at threshold; drops free-text bags and grade."""
|
||||
from backend.inference import _gate
|
||||
|
||||
parsed = {
|
||||
"species_id": 11, "species_confidence": 0.9,
|
||||
"wood_category_ids": [21], "wood_category_confidence": 0.8,
|
||||
"edge_type_term_id": 321, "edge_confidence": 0.85,
|
||||
"figure_term_ids": [328, 335], "figure_confidence": 0.75,
|
||||
"feature_tag_ids": [1], "feature_tag_confidence": 0.8,
|
||||
"grade_term_id": 342, "grade_confidence": 0.99,
|
||||
"character": ["rustic"], "inclusions": ["bark"], "voids": [], "checks": ["end"],
|
||||
}
|
||||
out = _gate(parsed)
|
||||
assert out["species_id"] == 11
|
||||
assert out["wood_category_ids"] == [21]
|
||||
assert out["edge_type_term_id"] == 321
|
||||
assert out["figure_term_ids"] == [328, 335]
|
||||
assert out["feature_tag_ids"] == [1]
|
||||
assert "grade_term_id" not in out
|
||||
assert "character" not in out
|
||||
assert "inclusions" not in out
|
||||
assert "voids" not in out
|
||||
assert "checks" not in out
|
||||
# below threshold feature tags dropped
|
||||
low = {**parsed, "feature_tag_confidence": 0.2}
|
||||
assert "feature_tag_ids" not in _gate(low)
|
||||
|
||||
|
||||
def test_call1_hard_disabled_410(env, monkeypatch):
|
||||
"""overnight-whole-app: Call 1 OUT — POST always 410, never chats the model."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
called["n"] += 1
|
||||
return "{}"
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 410, r.text
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "call1_disabled"
|
||||
assert called["n"] == 0
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_stores_short_and_description(env, monkeypatch):
|
||||
"""Call 2 persists both fields as plain text (HTML from model is stripped)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','old short','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
import json as _json
|
||||
|
||||
payload = _json.dumps({
|
||||
"short_description": "Kiln-dried walnut live-edge stock for a dining table — clear midspan, usable width. Ready to finish.",
|
||||
"description": "<p><strong>Dining table stock.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>",
|
||||
})
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: payload)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "Dining table stock" in body["description"]
|
||||
assert "kiln dried" in body["description"]
|
||||
assert "<p>" not in body["description"]
|
||||
assert "<strong>" not in body["description"]
|
||||
assert body["short_description"].startswith("Kiln-dried walnut")
|
||||
row = conn.execute(
|
||||
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
|
||||
).fetchone()
|
||||
assert row["short_description"].startswith("Kiln-dried walnut")
|
||||
assert "<p>" not in row["description"]
|
||||
assert "- Stable:" in row["description"] or "Stable:" in row["description"]
|
||||
assert row["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
|
||||
"""Non-JSON Call 2 body becomes plain-text description; prior short is left alone."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','keep me','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>A fine walnut slab.</p>")
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["description"] == "A fine walnut slab."
|
||||
assert "short_description" not in r.json()
|
||||
row = conn.execute(
|
||||
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
|
||||
).fetchone()
|
||||
assert row["short_description"] == "keep me"
|
||||
assert row["description"] == "A fine walnut slab."
|
||||
assert row["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_parse_call2_response_json_and_fenced():
|
||||
from backend.inference import parse_call2_response, _html_to_plain
|
||||
import json as _json
|
||||
|
||||
short, desc = parse_call2_response(
|
||||
_json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "})
|
||||
)
|
||||
assert short == "Benefit first."
|
||||
assert desc == "Long"
|
||||
fenced = "```json\n" + _json.dumps({"short_description": "S", "description": "<p>D</p>"}) + "\n```"
|
||||
short2, desc2 = parse_call2_response(fenced)
|
||||
assert short2 == "S"
|
||||
assert desc2 == "D"
|
||||
short3, desc3 = parse_call2_response("<p>only long</p>")
|
||||
assert short3 is None
|
||||
assert desc3 == "only long"
|
||||
|
||||
# Defensive strip: lists become "- " bullets; entities unescaped; no tags left.
|
||||
plain = _html_to_plain(
|
||||
"<p>Hook.</p><ul><li><strong>Stable:</strong> kiln dried</li>"
|
||||
"<li>Live edge</li></ul><p>Trust & ship.</p>"
|
||||
)
|
||||
assert "<" not in plain
|
||||
assert "Hook." in plain
|
||||
assert "- Stable: kiln dried" in plain
|
||||
assert "- Live edge" in plain
|
||||
assert "Trust & ship." in plain
|
||||
# Already-plain text is unchanged aside from trim.
|
||||
assert _html_to_plain(" Kiln-dried walnut for a bar top. ") == "Kiln-dried walnut for a bar top."
|
||||
|
||||
|
||||
def test_strip_attribute_dump_trailing_and_header():
|
||||
"""Defensive: drop labeled Category:/Length: dumps from long description."""
|
||||
from backend.inference import _strip_attribute_dump, parse_call2_response
|
||||
import json as _json
|
||||
|
||||
hook_benefits = (
|
||||
"Kiln-dried maple suited to a compact bar top.\n"
|
||||
"- Stable stock: kiln dried, ready to mill.\n"
|
||||
"- Live edge on one side for a natural apron.\n"
|
||||
"- Character grade with usable burl figure."
|
||||
)
|
||||
dump = (
|
||||
"Maple Slab\n"
|
||||
"- Category: Live edge wood\n"
|
||||
"- Figure: Burl, Curl, Figured\n"
|
||||
"- Edge: Live Edge 1 side\n"
|
||||
"- Grade: Character\n"
|
||||
"- Moisture: Kiln dried\n"
|
||||
"- Length: 45.0in\n"
|
||||
"- Board Feet: 12.4"
|
||||
)
|
||||
trust = "Ships from the mill; ask about returns in Brand voice."
|
||||
full = f"{hook_benefits}\n\n{dump}\n\n{trust}"
|
||||
cleaned = _strip_attribute_dump(full)
|
||||
assert "Category:" not in cleaned
|
||||
assert "Board Feet:" not in cleaned
|
||||
assert "Maple Slab" not in cleaned
|
||||
assert "Kiln-dried maple" in cleaned
|
||||
assert "Live edge on one side" in cleaned
|
||||
assert "Ships from the mill" in cleaned
|
||||
|
||||
# Mid-body Full specs header also stripped; benefits bullets without Label: kept.
|
||||
mid = (
|
||||
"Hook line.\n"
|
||||
"- Good for a bench seat.\n"
|
||||
"\n"
|
||||
"Full specs\n"
|
||||
"- Species: Maple\n"
|
||||
"- Length: 45.0in\n"
|
||||
"- Thickness: 2.0in\n"
|
||||
"\n"
|
||||
"Neutral trust close."
|
||||
)
|
||||
mid_clean = _strip_attribute_dump(mid)
|
||||
assert "Full specs" not in mid_clean
|
||||
assert "Species:" not in mid_clean
|
||||
assert "Good for a bench seat" in mid_clean
|
||||
assert "Neutral trust close" in mid_clean
|
||||
|
||||
# Fewer than 3 attr lines: leave alone (not a dump).
|
||||
short_list = "Hook.\n- Length: 45.0in\n- Thickness: 2in\nTrust."
|
||||
assert _strip_attribute_dump(short_list) == short_list.strip()
|
||||
|
||||
# parse_call2_response applies strip on description (not short).
|
||||
payload = _json.dumps({
|
||||
"short_description": "Short with - Length: 1in - Width: 2in - Thickness: 3in ok",
|
||||
"description": full,
|
||||
})
|
||||
short, desc = parse_call2_response(payload)
|
||||
assert short and "Length: 1in" in short # short untouched by attr-dump strip
|
||||
assert "Category:" not in desc
|
||||
assert "Board Feet:" not in desc
|
||||
assert "Kiln-dried maple" in desc
|
||||
|
||||
|
||||
def test_call1_disabled_even_when_inference_off(env):
|
||||
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01','calibrated',1,1,'t','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
|
||||
|
||||
def test_server_description_inference_off(env):
|
||||
from backend.inference import server_description
|
||||
|
||||
d = server_description({"title": "Figured Walnut Slab", "length_in": 114.0, "width_avg_in": 24.0, "thickness_in": 1.75, "bdft": 18.4})
|
||||
assert "114.0in" in d and "18.4" in d
|
||||
|
||||
|
||||
def test_test_inference_unconfigured(env):
|
||||
client, conn = env
|
||||
conn.execute("UPDATE settings SET value_enc=NULL WHERE key='inference_api_key'")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/admin/test-inference")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is False and r.json()["mode"] == "fail"
|
||||
|
||||
|
||||
def _rgba_png(w: int, h: int) -> bytes:
|
||||
"""RGBA PNG: opaque body + transparent top-left block (capture-like alpha)."""
|
||||
from PIL import Image
|
||||
import io
|
||||
im = Image.new("RGBA", (w, h), (10, 20, 30, 255))
|
||||
for y in range(min(80, h)):
|
||||
for x in range(min(80, w)):
|
||||
im.putpixel((x, y), (0, 0, 0, 0))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_fit_long_edge_png_downscales_over_1024():
|
||||
"""METADATA-35: Call 1 images long edge <= 1024; never upscale; keep alpha."""
|
||||
from backend.inference import fit_long_edge_png
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
src = _rgba_png(800, 1067) # inventory 3:4, long edge 1067
|
||||
out = fit_long_edge_png(src, max_long_edge=1024)
|
||||
im = Image.open(io.BytesIO(out)).convert("RGBA")
|
||||
assert max(im.size) == 1024
|
||||
assert min(im.size) < 1024
|
||||
w, h = im.size
|
||||
assert abs((w / h) - (800 / 1067)) < 0.02
|
||||
# Transparent block still transparent near origin after downscale
|
||||
assert im.getpixel((0, 0))[3] == 0
|
||||
assert im.getpixel((5, 5))[3] == 0
|
||||
|
||||
|
||||
def test_fit_long_edge_png_leaves_small_unchanged_dims():
|
||||
from backend.inference import fit_long_edge_png
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
src = _rgba_png(400, 300)
|
||||
out = fit_long_edge_png(src, max_long_edge=1024)
|
||||
im = Image.open(io.BytesIO(out))
|
||||
assert im.size == (400, 300)
|
||||
|
||||
|
||||
def test_redact_request_for_log_strips_data_urls_keeps_text():
|
||||
"""#87: log sanitizer replaces data-URL images with photo refs; keeps text."""
|
||||
from backend.inference import _redact_request_for_log
|
||||
|
||||
b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64 * 20}"}},
|
||||
{"type": "text", "text": "LISTS:\n{\"species\":[]}\n\nSKU=X length_in=1 thickness_in=2"},
|
||||
],
|
||||
}]
|
||||
meta = [
|
||||
{"photo_id": "p1", "seq": 1, "bytes": 100, "infer_long_edge_px": 64},
|
||||
{"photo_id": "p2", "seq": 2, "bytes": 200, "infer_long_edge_px": 128},
|
||||
]
|
||||
out = _redact_request_for_log(messages, meta)
|
||||
blob = json.dumps(out)
|
||||
assert "data:image" not in blob
|
||||
assert "base64" not in blob.lower()
|
||||
assert "LISTS:" in blob
|
||||
assert "SKU=X" in blob
|
||||
parts = out[0]["content"]
|
||||
assert parts[0]["type"] == "image_ref"
|
||||
assert parts[0]["photo_id"] == "p1"
|
||||
assert parts[0]["seq"] == 1
|
||||
assert parts[1]["photo_id"] == "p2"
|
||||
assert parts[2]["type"] == "text"
|
||||
assert "LISTS:" in parts[2]["text"]
|
||||
|
||||
|
||||
def test_call1_log_skipped_while_disabled(env):
|
||||
"""#87 Call 1 log path dormant while Call 1 is hard-disabled (overnight-whole-app)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||
)
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_log_redacts_images(env, monkeypatch):
|
||||
"""#87: Call 2 log also redacts image data-URLs."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut','ready',1,1,'t','t')"
|
||||
)
|
||||
png = _rgba_png(200, 200)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p2','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>ok</p>")
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = conn.execute(
|
||||
"SELECT request_payload FROM inference_log WHERE call=2 ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()["request_payload"]
|
||||
assert "data:image" not in payload
|
||||
assert "image_ref" in payload
|
||||
assert "p2" in payload
|
||||
assert "DIMENSION FACT BLOCK" in payload
|
||||
|
||||
|
||||
def test_call1_sends_long_edge_dormant_while_disabled(env):
|
||||
"""METADATA-35 Call 1 sizing path dormant while Call 1 is hard-disabled."""
|
||||
client, _conn = env
|
||||
r = client.post("/api/v1/slabs/missing/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
|
||||
|
||||
def test_assemble_call2_includes_declared_attrs_and_dims(env):
|
||||
"""Call 2 text = seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; shared assembler."""
|
||||
client, conn = env
|
||||
conn.execute(
|
||||
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES "
|
||||
"(10,'Species','species',NULL,10),"
|
||||
"(11,'Black Walnut','black-walnut',10,11),"
|
||||
"(20,'Wcat','wcat',NULL,20),"
|
||||
"(21,'Slabs & Lumber','snl',20,21),"
|
||||
"(22,'Live edge wood','liveedge',21,22)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES "
|
||||
"(6,'Edge type','pa_etype',6),(8,'Figure','pa_fig',8),"
|
||||
"(9,'Grade','pa_gr',9),(7,'Moisture','pa_mst',7)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES "
|
||||
"(321,6,'Live Edge 1 side','le1',321),"
|
||||
"(328,8,'Curl','curl',328),"
|
||||
"(342,9,'Fancy','fnc',342),"
|
||||
"(326,7,'Kiln dried','kd',326)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
|
||||
"('brand_voice','Warm and precise.',NULL),"
|
||||
"('geo_context','Pacific Northwest mill.',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,moisture_term_id,"
|
||||
"feat_tag_ids,title,short_description,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',114.0,1.75,24.3,18.0,24.3,18.4,11,?,321,?,342,326,?,"
|
||||
"'Figured Black Walnut Live Edge Slab','short line','ready',1,1,'t','t')",
|
||||
(json.dumps([22]), json.dumps([328]), json.dumps([1])),
|
||||
)
|
||||
png = _rgba_png(200, 200)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p9','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import assemble_call2, assemble_call2_text, CALL2_SEED
|
||||
|
||||
assembled = assemble_call2(conn, "s1")
|
||||
assert assembled is not None
|
||||
text = assembled["text"]
|
||||
assert "DECLARED ATTRIBUTES" in text
|
||||
assert "species: Black Walnut" in text
|
||||
assert "primary wood category: Live edge wood" in text
|
||||
assert "edge: Live Edge 1 side" in text
|
||||
assert "figure: Curl" in text
|
||||
assert "grade: Fancy" in text
|
||||
assert "moisture: Kiln dried" in text
|
||||
assert "features: knot" in text
|
||||
assert "DIMENSION FACT BLOCK" in text
|
||||
assert "length=114.0in" in text
|
||||
assert "Brand voice: Warm and precise." in text
|
||||
assert "Geo: Pacific Northwest mill." in text
|
||||
assert CALL2_SEED.split("\n")[0] in text
|
||||
assert "SHORT DESCRIPTION" in text
|
||||
assert "LONG DESCRIPTION" in text
|
||||
assert "short_description" in CALL2_SEED
|
||||
assert '"description"' in CALL2_SEED or "\"description\"" in CALL2_SEED
|
||||
# Prior short must not be fed back into the prompt (Call 2 writes it).
|
||||
assert "short_description: short line" not in text
|
||||
assert "Write BOTH" in CALL2_SEED or "BOTH the short" in CALL2_SEED
|
||||
# No raw base64 in View-prompt shape
|
||||
assert "photos" in assembled
|
||||
assert assembled["photos"][0]["id"] == "p9"
|
||||
assert assembled["photos"][0]["seq"] == 1
|
||||
assert assembled["photos"][0]["bytes"] > 0
|
||||
assert "base64" not in json.dumps(assembled["photos"])
|
||||
# View prompt GET mirrors assembler (no model)
|
||||
r = client.get("/api/v1/slabs/s1/inference/call2/prompt")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["text"] == text
|
||||
assert body["photos"][0]["id"] == "p9"
|
||||
assert "data:image" not in json.dumps(body)
|
||||
|
||||
|
||||
def test_call2_post_uses_same_assembler_text(env, monkeypatch):
|
||||
"""POST call2 sends the same assembled text View prompt returns."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
|
||||
"('brand_voice','voice',NULL),('geo_context','geo',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||
"title,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,22,18,'Walnut Slab','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
|
||||
(_rgba_png(64, 64),),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient, assemble_call2
|
||||
|
||||
expected = assemble_call2(conn, "s1")["text"]
|
||||
captured = {}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
captured["text"] = messages[0]["content"][-1]["text"]
|
||||
return "<p>ok</p>"
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
assert captured["text"] == expected
|
||||
assert "DECLARED ATTRIBUTES" in captured["text"]
|
||||
assert "DIMENSION FACT BLOCK" in captured["text"]
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Integration (QUALITY-35): Woo mock covering success, error, duplicate SKU,
|
||||
stale field, and taxonomy anchor.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
def _mock(monkeypatch, *, product_ok=True, sku_count=0, cat_subset="A", media_ok=True):
|
||||
from backend.woo import WooClient, WooError
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
# taxonomy endpoints
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, json.dumps([{"id": i} for i in range(sku_count)]).encode()
|
||||
if url.endswith("/wp-json/wp/v2/media") and media_ok:
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
if product_ok:
|
||||
return 201, {}, json.dumps({"id": 555}).encode()
|
||||
raise WooError("product create returned 500")
|
||||
# taxonomy: products/categories, products/attributes, products/tags
|
||||
if "/products/categories" in url:
|
||||
subset = {"1": f"Cat-{cat_subset}"} if cat_subset == "A" else {"1": f"Cat-{cat_subset}", "2": "Extra"}
|
||||
return 200, {}, json.dumps([{"id": 1, "name": f"Cat-{cat_subset}", "slug": f"c-{cat_subset}", "parent": 0}]).encode()
|
||||
if "/products/attributes" in url:
|
||||
return 200, {}, json.dumps([{"id": 8, "name": "Edge Type", "slug": "edge-type"}]).encode()
|
||||
if "/products/attributes/8/terms" in url:
|
||||
return 200, {}, json.dumps([{"id": 1, "name": "Flat", "slug": "flat"}]).encode()
|
||||
if "/products/tags" in url:
|
||||
return 200, {}, json.dumps([{"id": 1, "name": "Figured", "slug": "fig-f"}, {"id": 2, "name": "Knot", "slug": "feat-knot"}]).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
orig = WooClient.__init__
|
||||
|
||||
def patched(self, *a, **k):
|
||||
orig(self, *a, **k)
|
||||
|
||||
def bound(url, method, data, headers):
|
||||
return fake_request(url, method, data, headers)
|
||||
|
||||
self._request = bound
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="integ_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
|
||||
db_mod._reset_for_tests()
|
||||
from backend.crypto import encrypt
|
||||
|
||||
conn = db_mod.init_db()
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("pw"),))
|
||||
from datetime import datetime, timezone
|
||||
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
|
||||
(datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
|
||||
conn.commit()
|
||||
from backend.app import app
|
||||
|
||||
yield TestClient(app), conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_taxomony_anchor_bumps_on_change(env, monkeypatch):
|
||||
from backend.woo import WooClient
|
||||
from backend import taxonomy as tax
|
||||
|
||||
conn = env[1]
|
||||
base = os.environ["WOO_BASE_URL"]
|
||||
user = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0]
|
||||
enc = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
|
||||
|
||||
_mock(monkeypatch, cat_subset="A")
|
||||
client1 = WooClient.from_settings(base, user, enc)
|
||||
r1 = tax.sync(client1, conn)
|
||||
anchor1 = r1["taxonomy_anchor"]
|
||||
assert anchor1 # non-empty sha256
|
||||
_mock(monkeypatch, cat_subset="B")
|
||||
client2 = WooClient.from_settings(base, user, enc)
|
||||
r2 = tax.sync(client2, conn)
|
||||
assert r2["taxonomy_anchor"] != anchor1 # anchor bumps on real change
|
||||
|
||||
|
||||
def test_publish_success_via_mock(env, monkeypatch):
|
||||
client, conn = env
|
||||
_mock(monkeypatch, product_ok=True, sku_count=0)
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-UAT-9',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["woo_product_id"] == 555
|
||||
|
||||
|
||||
def test_publish_error_via_mock(env, monkeypatch):
|
||||
client, conn = env
|
||||
_mock(monkeypatch, product_ok=False)
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-E',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 502
|
||||
assert r.json()["error"]["detail"]["recoverable"] is True
|
||||
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
|
||||
|
||||
|
||||
def test_duplicate_sku_via_mock(env, monkeypatch):
|
||||
client, conn = env
|
||||
_mock(monkeypatch, sku_count=1)
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-DUP',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["error"]["code"] == "duplicate_sku"
|
||||
|
||||
|
||||
def test_stale_value_field_level_error(env, monkeypatch):
|
||||
"""A picked category id that no longer exists in the store is a field-level error
|
||||
(PUBLISH-20), never a raw code."""
|
||||
client, conn = env
|
||||
_mock(monkeypatch, product_ok=True)
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
|
||||
# wood_category_ids references category 99 which does not exist in the store -> stale
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-STALE',114,1.75,30,18.4,18,24.3,24.3,1,'[99]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
# publish proceeds (stale category id resolved to nothing) or errors; never a raw code/traceback
|
||||
assert r.status_code in (200, 409, 422, 502)
|
||||
assert "Traceback" not in r.text
|
||||
@@ -1,178 +0,0 @@
|
||||
"""Prototype: X-Woo-* headers required on HTTP Woo routes; never clobber Settings."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
import backend.db as db_mod
|
||||
from backend.crypto import encrypt, decrypt
|
||||
from backend.woo import (
|
||||
WooClient,
|
||||
WooDeviceCredsRequired,
|
||||
client_for_request,
|
||||
request_woo_override,
|
||||
settings_woo_creds,
|
||||
HEADER_WOO_USERNAME,
|
||||
HEADER_WOO_APP_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_dir():
|
||||
tmp = tempfile.mkdtemp(prefix="per_user_woo_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
os.environ.pop("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", None)
|
||||
db_mod._reset_for_tests()
|
||||
conn = db_mod.init_db()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_wp_username','shop-default',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_app_password',NULL,?)",
|
||||
(encrypt("shop-secret"),),
|
||||
)
|
||||
conn.commit()
|
||||
yield conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _auth_pair(client: WooClient) -> tuple[str, str]:
|
||||
raw = base64.b64decode(client._auth).decode("utf-8")
|
||||
user, password = raw.split(":", 1)
|
||||
return user, password
|
||||
|
||||
|
||||
def test_override_headers_produce_distinct_auth_without_clobbering_settings(db_dir):
|
||||
conn = db_dir
|
||||
before = settings_woo_creds(conn)
|
||||
assert before[1] == "shop-default"
|
||||
assert decrypt(before[2]) == "shop-secret"
|
||||
|
||||
h_a = Headers({HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "alice-app-pw"})
|
||||
h_b = Headers({HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "bob-app-pw"})
|
||||
|
||||
client_a = client_for_request(conn, h_a)
|
||||
client_b = client_for_request(conn, h_b)
|
||||
|
||||
assert _auth_pair(client_a) == ("alice", "alice-app-pw")
|
||||
assert _auth_pair(client_b) == ("bob", "bob-app-pw")
|
||||
assert client_a._auth != client_b._auth
|
||||
|
||||
after = settings_woo_creds(conn)
|
||||
assert after == before
|
||||
assert decrypt(after[2]) == "shop-secret"
|
||||
row_u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0]
|
||||
row_p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
|
||||
assert row_u == "shop-default"
|
||||
assert decrypt(row_p) == "shop-secret"
|
||||
|
||||
|
||||
def test_missing_headers_raise_device_creds_required(db_dir):
|
||||
conn = db_dir
|
||||
with pytest.raises(WooDeviceCredsRequired):
|
||||
client_for_request(conn, Headers({}))
|
||||
with pytest.raises(WooDeviceCredsRequired):
|
||||
client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"}))
|
||||
assert request_woo_override(Headers({HEADER_WOO_USERNAME: "alice"})) is None
|
||||
|
||||
|
||||
def test_env_escape_hatch_allows_settings_fallback(db_dir, monkeypatch):
|
||||
conn = db_dir
|
||||
monkeypatch.setenv("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "1")
|
||||
client = client_for_request(conn, Headers({}))
|
||||
assert _auth_pair(client) == ("shop-default", "shop-secret")
|
||||
|
||||
|
||||
def test_test_woo_endpoint_requires_headers(db_dir, monkeypatch):
|
||||
"""API: POST /settings/test-woo with X-Woo-* builds client; missing → 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as app_mod
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
def fake_sync(client, conn):
|
||||
seen.append(_auth_pair(client)[0])
|
||||
return {"taxonomy_anchor": "x", "taxonomy_last_sync_at": "2026-01-01T00:00:00"}
|
||||
|
||||
monkeypatch.setattr(app_mod.taxonomy_mod, "sync", fake_sync)
|
||||
tc = TestClient(app_mod.app)
|
||||
r1 = tc.post(
|
||||
"/api/v1/settings/test-woo",
|
||||
headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw-a"},
|
||||
)
|
||||
r2 = tc.post(
|
||||
"/api/v1/settings/test-woo",
|
||||
headers={HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "pw-b"},
|
||||
)
|
||||
r3 = tc.post("/api/v1/settings/test-woo")
|
||||
assert r1.status_code == 200 and r1.json()["ok"] is True
|
||||
assert r2.status_code == 200 and r2.json()["ok"] is True
|
||||
assert r3.status_code == 401
|
||||
assert r3.json()["error"]["code"] == "woo_device_creds_required"
|
||||
assert seen == ["alice", "bob"]
|
||||
assert settings_woo_creds(db_dir)[1] == "shop-default"
|
||||
|
||||
|
||||
def test_taxonomy_sync_requires_headers(db_dir, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as app_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
app_mod.taxonomy_mod,
|
||||
"sync",
|
||||
lambda client, conn: {
|
||||
"taxonomy_anchor": "x",
|
||||
"taxonomy_last_sync_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
)
|
||||
tc = TestClient(app_mod.app)
|
||||
missing = tc.post("/api/v1/admin/taxonomy/sync")
|
||||
assert missing.status_code == 401
|
||||
assert missing.json()["error"]["code"] == "woo_device_creds_required"
|
||||
ok = tc.post(
|
||||
"/api/v1/admin/taxonomy/sync",
|
||||
headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw"},
|
||||
)
|
||||
assert ok.status_code == 200 and ok.json()["ok"] is True
|
||||
|
||||
|
||||
def test_publish_requires_headers(db_dir):
|
||||
"""POST publish without X-Woo-* → 401 woo_device_creds_required."""
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as app_mod
|
||||
import json
|
||||
|
||||
conn = db_dir
|
||||
conn.execute(
|
||||
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)"
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,
|
||||
species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,
|
||||
moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,
|
||||
content_source,status,server_rev,client_rev,created_at,updated_at) VALUES
|
||||
('s1','SLAB-01',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[1]',1,1,1,'[]','[]',184,
|
||||
'recommendation','T','S','D','manual','ready',1,1,'t','t')"""
|
||||
)
|
||||
conn.commit()
|
||||
tc = TestClient(app_mod.app)
|
||||
r = tc.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 401, r.text
|
||||
assert r.json()["error"]["code"] == "woo_device_creds_required"
|
||||
@@ -1,135 +0,0 @@
|
||||
"""S3 photos BLOB storage tests: multipart upload, original/processed GET, 410 after purge."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def photos_env():
|
||||
tmp = tempfile.mkdtemp(prefix="photos_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
db_mod._reset_for_tests()
|
||||
from backend.app import app
|
||||
|
||||
conn = db_mod.init_db()
|
||||
# seed a slab
|
||||
conn.execute("INSERT INTO slabs(id, sku, status, server_rev, created_at, updated_at) VALUES ('s1','SLAB-01','calibrated',1,'t','t')")
|
||||
conn.commit()
|
||||
yield TestClient(app), conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
PNG_ORIG = b"\x89PNG-orig-bytes"
|
||||
PNG_PROC = b"\x89PNG-proc-bytes"
|
||||
|
||||
|
||||
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
|
||||
files = {"original": ("orig.png", original, "image/png")}
|
||||
if processed is not None:
|
||||
files["processed"] = ("proc.png", processed, "image/png")
|
||||
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
|
||||
if seq is not None:
|
||||
data["seq"] = str(seq)
|
||||
return client.post(
|
||||
f"/api/v1/slabs/{slab_id}/photos",
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
|
||||
def test_upload_and_get_original_processed(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "s1", "p1")
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["original_retained"] is True and body["processed_retained"] is True
|
||||
|
||||
r = client.get("/api/v1/slabs/s1/photos/p1/original")
|
||||
assert r.status_code == 200
|
||||
assert r.content == PNG_ORIG
|
||||
assert r.headers["content-type"] == "image/png"
|
||||
|
||||
r = client.get("/api/v1/slabs/s1/photos/p1/processed")
|
||||
assert r.status_code == 200
|
||||
assert r.content == PNG_PROC
|
||||
|
||||
|
||||
def test_processed_410_when_purged(photos_env):
|
||||
client, conn = photos_env
|
||||
_multipart(client, "s1", "p2")
|
||||
# simulate purge (publish clears blobs)
|
||||
conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE id='p2'")
|
||||
conn.commit()
|
||||
assert client.get("/api/v1/slabs/s1/photos/p2/original").status_code == 410
|
||||
assert client.get("/api/v1/slabs/s1/photos/p2/processed").status_code == 410
|
||||
|
||||
|
||||
def test_upload_requires_slab(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "nope", "p1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "p3", original=PNG_ORIG, processed=PNG_PROC)
|
||||
r = _multipart(client, "s1", "p3", original=b"NEW-ORIG", processed=None)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["processed_retained"] is False
|
||||
assert client.get("/api/v1/slabs/s1/photos/p3/original").content == b"NEW-ORIG"
|
||||
assert client.get("/api/v1/slabs/s1/photos/p3/processed").status_code == 410
|
||||
|
||||
|
||||
def test_list_photos(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
|
||||
_multipart(client, "s1", "pb", role="overlay")
|
||||
r = client.get("/api/v1/slabs/s1/photos")
|
||||
assert r.status_code == 200
|
||||
ids = [row["id"] for row in r.json()]
|
||||
assert "pa" in ids and "pb" in ids
|
||||
|
||||
|
||||
def test_upload_stores_and_returns_role(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "s1", "r1", role="overlay")
|
||||
assert r.status_code == 201
|
||||
assert r.json()["role"] == "overlay"
|
||||
r = client.get("/api/v1/slabs/s1/photos")
|
||||
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
|
||||
assert row["role"] == "overlay"
|
||||
|
||||
|
||||
def test_upload_rejects_bad_role(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "s1", "r2", role="not-a-role")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"]["code"] == "validation"
|
||||
|
||||
|
||||
def test_delete_photo(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "d1")
|
||||
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
|
||||
r = client.delete("/api/v1/slabs/s1/photos/d1")
|
||||
assert r.status_code == 204
|
||||
# Gone from the list and blob no longer served.
|
||||
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
|
||||
assert "d1" not in ids
|
||||
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
|
||||
|
||||
|
||||
def test_delete_photo_not_found(photos_env):
|
||||
client, _ = photos_env
|
||||
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
|
||||
@@ -1,63 +0,0 @@
|
||||
"""S5 pricing rules tests (SETTINGS-35, REVIEW-25)."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env():
|
||||
tmp = tempfile.mkdtemp(prefix="pricing_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
db_mod._reset_for_tests()
|
||||
from backend.app import app
|
||||
|
||||
conn = db_mod.init_db()
|
||||
for cid in (1, 7):
|
||||
conn.execute("INSERT OR IGNORE INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (?,?,?,NULL,?)", (cid, f"Sp{cid}", f"sp{cid}", cid))
|
||||
conn.commit()
|
||||
yield TestClient(app)
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_crud_cycle(env):
|
||||
r = env.post("/api/v1/admin/pricing", json={"species_id": 1, "price_per_bdft": 12.5})
|
||||
assert r.status_code == 201, r.text
|
||||
rule = r.json()
|
||||
rid = rule["id"]
|
||||
assert rule["price_per_bdft"] == 12.5
|
||||
|
||||
r = env.get("/api/v1/admin/pricing")
|
||||
assert r.status_code == 200
|
||||
assert [x["species_id"] for x in r.json()] == [1]
|
||||
|
||||
r = env.put(f"/api/v1/admin/pricing/{rid}", json={"price_per_bdft": 15.0})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["price_per_bdft"] == 15.0
|
||||
|
||||
r = env.delete(f"/api/v1/admin/pricing/{rid}")
|
||||
assert r.status_code == 204
|
||||
assert env.get("/api/v1/admin/pricing").json() == []
|
||||
|
||||
|
||||
def test_duplicate_species(env):
|
||||
env.post("/api/v1/admin/pricing", json={"species_id": 7, "price_per_bdft": 1.0})
|
||||
r = env.post("/api/v1/admin/pricing", json={"species_id": 7, "price_per_bdft": 2.0})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_validation(env):
|
||||
r = env.post("/api/v1/admin/pricing", json={"species_id": 1})
|
||||
assert r.status_code == 400
|
||||
r = env.post("/api/v1/admin/pricing", json={"species_id": 1, "price_per_bdft": -5})
|
||||
assert r.status_code == 400
|
||||
@@ -1,363 +0,0 @@
|
||||
"""S6 publish sequence tests (PUBLISH-5/10/15/25/30/31). Woo transport is mocked."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
def _full_slab(sku="SLAB-01", **over):
|
||||
base = {
|
||||
"id": "s1", "sku": sku, "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
|
||||
"bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
|
||||
"species_id": 1, "wood_category_ids": json.dumps([2]), "edge_type_term_id": 1,
|
||||
"figure_term_ids": json.dumps([1]), "grade_term_id": 1, "thickness_term_id": 1,
|
||||
"moisture_term_id": 1, "fig_tag_ids": json.dumps([]), "feat_tag_ids": json.dumps([]),
|
||||
"price": 184.0, "price_source": "recommendation",
|
||||
"title": "Figured Black Walnut Live Edge Slab", "short_description": "slab, 9ft 6in",
|
||||
"description": "A fine slab.", "content_source": "manual", "status": "ready",
|
||||
"client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t",
|
||||
"woo_product_id": None,
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="pub_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
|
||||
db_mod._reset_for_tests()
|
||||
from backend.crypto import encrypt
|
||||
|
||||
conn = db_mod.init_db()
|
||||
# taxonomy
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
|
||||
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
|
||||
for tid, name, slug in [
|
||||
(347, 'Under 1/2"', 'xthin'), (348, '1/2" - 3/4"', '2-3qtr'),
|
||||
(349, '3/4" - 1"', '3-4qtr'), (350, '1"- 1 1/2"', '4-6qtr'),
|
||||
(351, '1 1/2" - 2"', '6-8qtr'), (352, '2" - 3"', '8-12qtr'),
|
||||
(353, 'Over 3"', '12qtr'),
|
||||
]:
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
|
||||
(tid, name, slug, tid),
|
||||
)
|
||||
# Moisture keeps its own attribute so the five locked attributes stay distinct.
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
|
||||
# creds
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
|
||||
# fresh sync stamp so we skip re-sync
|
||||
from datetime import datetime, timezone
|
||||
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
|
||||
(datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
|
||||
conn.commit()
|
||||
from backend.app import app
|
||||
|
||||
yield TestClient(app), conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _insert_slab(conn, s):
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,"
|
||||
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,"
|
||||
"moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,"
|
||||
"content_source,status,server_rev,client_rev,created_at,updated_at) VALUES "
|
||||
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(s["id"], s["sku"], s["length_in"], s["thickness_in"], s["sqft"], s["bdft"], s["width_min_in"],
|
||||
s["width_max_in"], s["width_avg_in"], s["species_id"], s["wood_category_ids"], s["edge_type_term_id"],
|
||||
s["figure_term_ids"], s["grade_term_id"], s["thickness_term_id"], s["moisture_term_id"],
|
||||
s["fig_tag_ids"], s["feat_tag_ids"], s["price"], s["price_source"], s["title"], s["short_description"],
|
||||
s["description"], s["content_source"], s["status"], 1, 1, "t", "t"),
|
||||
)
|
||||
|
||||
|
||||
def _install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,)):
|
||||
from backend.woo import WooClient
|
||||
|
||||
state = {"media_n": 0}
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
if "/products?sku=" in url:
|
||||
body = json.dumps([{"id": 1}] * sku_exists).encode()
|
||||
return 200, {}, body
|
||||
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
||||
mid = media_ids[min(state["media_n"], len(media_ids) - 1)]
|
||||
state["media_n"] += 1
|
||||
return 201, {}, json.dumps({"id": mid}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": product_id}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
|
||||
|
||||
def test_publish_success(env, monkeypatch):
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
_install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,))
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["woo_product_id"] == 999 and body["draft"] is False
|
||||
row = conn.execute("SELECT status, woo_product_id FROM slabs WHERE id='s1'").fetchone()
|
||||
assert row["status"] == "published" and row["woo_product_id"] == 999
|
||||
# photos purged
|
||||
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
|
||||
|
||||
|
||||
def test_publish_sends_derived_thickness_band(env, monkeypatch):
|
||||
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
|
||||
|
||||
The owner has no thickness-band control, so nothing client-side carries it: the
|
||||
server derives it from the measurement on create/update, and publish must send the
|
||||
live synced term name on the product payload. This pins that chain end to end.
|
||||
"""
|
||||
client, conn = env
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75,
|
||||
"sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
|
||||
})
|
||||
assert create.status_code == 201, create.text
|
||||
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
|
||||
assert create.json()["thickness_term_id"] == 351
|
||||
|
||||
put = client.put("/api/v1/slabs/s1", json={
|
||||
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
|
||||
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
|
||||
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
|
||||
"description": "D", "content_source": "manual",
|
||||
})
|
||||
assert put.status_code == 200, put.text
|
||||
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
|
||||
products = []
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
products.append(json.loads(data))
|
||||
return 201, {}, json.dumps({"id": 999}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
from backend.woo import WooClient
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(products) == 1, "expected exactly one product create"
|
||||
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
|
||||
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
|
||||
assert attrs[9]["options"] == ['1 1/2" - 2"']
|
||||
|
||||
|
||||
def test_publish_woo_image_order_by_role(env, monkeypatch):
|
||||
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
|
||||
# role, not by stored seq (the Review strip stores the owner's keep order).
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
|
||||
# overlay(3). The publish query must reorder to cal, overlay, batch.
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
|
||||
conn.commit()
|
||||
media_calls = []
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
||||
media_calls.append(data)
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": 999}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
from backend.woo import WooClient
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(media_calls) == 3
|
||||
# The photo id is embedded in the multipart filename (slab.py publish
|
||||
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
|
||||
# batch (PUBLISH-45), not stored seq.
|
||||
def photo_id(body: bytes) -> str:
|
||||
import re
|
||||
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
|
||||
return m.group(1).decode() if m else ""
|
||||
|
||||
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
|
||||
|
||||
|
||||
def test_uat_draft(env, monkeypatch):
|
||||
client, conn = env
|
||||
s = _full_slab(sku="SLAB-UAT-1")
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
_install_mock(monkeypatch)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["draft"] is True
|
||||
|
||||
|
||||
def test_not_ready_422(env, monkeypatch):
|
||||
client, conn = env
|
||||
s = _full_slab(price=None, price_source=None)
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
_install_mock(monkeypatch)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 422
|
||||
assert "price" in r.json()["error"]["detail"]["missing"]
|
||||
|
||||
|
||||
def test_duplicate_sku_409(env, monkeypatch):
|
||||
client, conn = env
|
||||
s = _full_slab(sku="SLAB-DUP")
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
_install_mock(monkeypatch, sku_exists=1)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["error"]["code"] == "duplicate_sku"
|
||||
|
||||
|
||||
def test_partial_media_failure_502(env, monkeypatch):
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.woo import WooClient, WooError
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wp/v2/media"):
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products"):
|
||||
raise WooError("product create returned 500")
|
||||
return 200, {}, b"[]"
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 502
|
||||
assert r.json()["error"]["detail"]["partial_media"] is True
|
||||
# sync_log written, status failed
|
||||
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
|
||||
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
|
||||
|
||||
|
||||
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
|
||||
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
seen = []
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
from backend.woo import WooClient, _USER_AGENT
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
|
||||
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
|
||||
assert sku_hops and create_hops
|
||||
for hop in sku_hops + create_hops:
|
||||
assert hop["headers"].get("User-Agent") == _USER_AGENT
|
||||
|
||||
|
||||
def test_publish_403_is_unrecoverable(env, monkeypatch):
|
||||
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
from backend.woo import WooClient, WooForbiddenError
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
raise WooForbiddenError("request forbidden (403)")
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 502
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "woo_forbidden"
|
||||
assert body["error"]["detail"]["recoverable"] is False
|
||||
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
|
||||
@@ -1,246 +0,0 @@
|
||||
"""S5 deterministic copy + ready derivation tests (METADATA-80/85/90, REVIEW-5/25)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
from backend import review as review_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env():
|
||||
tmp = tempfile.mkdtemp(prefix="review_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "r.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
db_mod._reset_for_tests()
|
||||
conn = db_mod.init_db()
|
||||
# taxonomy: species + a live-edge-wood leaf + figure term
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (1,'Figure','figure',200)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,1,'Figured','figured',300)")
|
||||
conn.commit()
|
||||
from backend.app import app
|
||||
|
||||
yield TestClient(app), conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _slab(sku="SLAB-01", **over):
|
||||
base = {
|
||||
"id": "s1", "sku": sku, "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
|
||||
"bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
|
||||
"species_id": 1, "wood_category_ids": json.dumps([2]), "edge_type_term_id": None,
|
||||
"figure_term_ids": json.dumps([1]), "grade_term_id": None, "thickness_term_id": None,
|
||||
"moisture_term_id": None, "fig_tag_ids": "[]", "feat_tag_ids": "[]",
|
||||
"price": None, "price_source": None, "title": None, "short_description": None,
|
||||
"description": "A fine slab.", "content_source": "manual", "status": "calibrated",
|
||||
"client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_title_live_edge_no_dims(env):
|
||||
_, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
# #89: the review always returns the deterministic title/short_description
|
||||
# built from the current metadata (the Create button's source of truth).
|
||||
r = env[0].post("/api/v1/slabs/s1/review", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["title"] == "Figured Black Walnut Live Edge Slab"
|
||||
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in")
|
||||
assert body["title_built"] is True
|
||||
assert body["title_build_missing"] == []
|
||||
assert "18.4 bdft" in body["short_description"]
|
||||
|
||||
|
||||
def test_ready_requires_full_set(env):
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["ready"] is False
|
||||
# price is empty (no rate) and title/short_description generated -> missing includes price, price_source, grade/thickness/moisture terms
|
||||
assert "price" in body["missing"]
|
||||
|
||||
|
||||
def test_price_suggestion_from_rate(env):
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,10.0,'t','t')")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["price_suggestion"] == 184.0 # 18.4 bdft * $10
|
||||
assert body["price"] == 184.0
|
||||
|
||||
|
||||
def test_price_suggestion_tv8_quality_90(env):
|
||||
"""QUALITY-90 / TV-8: 0.96 bdft × $12.50 → 12.00."""
|
||||
client, conn = env
|
||||
conn.execute(
|
||||
"INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,12.50,'t','t')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"s1",
|
||||
"SLAB-01",
|
||||
114.0,
|
||||
1.75,
|
||||
30.0,
|
||||
0.96,
|
||||
18.0,
|
||||
24.3,
|
||||
24.3,
|
||||
1,
|
||||
json.dumps([2]),
|
||||
json.dumps([1]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"A slab",
|
||||
"manual",
|
||||
"calibrated",
|
||||
1,
|
||||
1,
|
||||
"t",
|
||||
"t",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["price_suggestion"] == 12.00
|
||||
assert body["price"] == 12.00
|
||||
|
||||
|
||||
def test_knife_blank_carries_dims(env):
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (3,'Knife blanks','knife-blanks',NULL,102)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
# #89: the deterministic build runs on every review.
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
|
||||
|
||||
|
||||
def test_review_always_reports_deterministic_title(env):
|
||||
"""#89: the review always returns the deterministic title/short_description
|
||||
built from the current metadata. The client's Create button writes these into
|
||||
the fields; the server does not persist them from the review payload."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["title"] == "Figured Black Walnut Live Edge Slab"
|
||||
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
|
||||
# The review does NOT persist title/short_description from the payload.
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["title"] is None
|
||||
assert got["short_description"] is None
|
||||
|
||||
|
||||
def test_review_no_species_reports_stored_title(env):
|
||||
"""With no species, there is no deterministic pattern; the review reports the
|
||||
stored title (None here) and lists title as missing."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["title"] is None
|
||||
assert "title" in body["missing"]
|
||||
|
||||
|
||||
def test_review_persists_owner_overrides(env):
|
||||
"""#80: regular review persists price/sku overrides from the payload to the DB,
|
||||
so the client doesn't need a separate PUT for each. (title/short_description
|
||||
are NOT persisted here — they come from the deterministic build, not the payload.)"""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
# Review with owner overrides for price + sku.
|
||||
r = client.post("/api/v1/slabs/s1/review", json={
|
||||
"price": 250.0,
|
||||
"sku": "SLAB-99",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["price"] == 250.0
|
||||
# The overrides are persisted: GET reflects them.
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["price"] == 250.0
|
||||
assert got["sku"] == "SLAB-99"
|
||||
# server_rev was incremented (the UPDATE fired).
|
||||
assert got["server_rev"] == 2
|
||||
|
||||
|
||||
def test_ready_requires_figure_term_ids(env):
|
||||
"""UX requires ≥1 figure — figure_term_ids is in _REQUIRED."""
|
||||
from backend.review import missing_required, compute_ready
|
||||
|
||||
slab = _slab(figure_term_ids=[])
|
||||
miss = missing_required(slab)
|
||||
assert "figure_term_ids" in miss
|
||||
ready, missing = compute_ready({**slab, "price": 1.0, "price_source": "manual",
|
||||
"title": "T", "short_description": "S",
|
||||
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||
"thickness_term_id": 1, "moisture_term_id": 1})
|
||||
assert ready is False
|
||||
assert "figure_term_ids" in missing
|
||||
|
||||
ready2, missing2 = compute_ready({
|
||||
**slab,
|
||||
"figure_term_ids": [1],
|
||||
"price": 1.0, "price_source": "manual",
|
||||
"title": "T", "short_description": "S",
|
||||
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||
"thickness_term_id": 1, "moisture_term_id": 1,
|
||||
})
|
||||
# May still miss other fields depending on fixture; figure must not be listed
|
||||
assert "figure_term_ids" not in missing2
|
||||
|
||||
|
||||
def test_review_title_built_false_without_species_or_primary(env):
|
||||
"""Create-title silent-fail fix: report title_built=false when taxonomy cannot resolve."""
|
||||
client, conn = env
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,"
|
||||
"species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,"
|
||||
"content_source,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1", None, 114.0, 1.75, 30.0, 18.4, 18.0, 24.3, 24.3, None, "[]", "[]", None, None,
|
||||
"Typed Title", None, None, "manual", "calibrated", 1, 1, "t", "t"),
|
||||
)
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["title_built"] is False
|
||||
assert "species" in body["title_build_missing"]
|
||||
assert "primary_wood_category" in body["title_build_missing"]
|
||||
assert body["title"] == "Typed Title"
|
||||
assert body["short_description"] is None
|
||||
@@ -1,46 +0,0 @@
|
||||
"""TDD red test for S0 schema load (DATA-MODEL.md contract)."""
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "..", "db", "schema.sql")
|
||||
|
||||
|
||||
def test_schema_loads_cleanly_and_all_tables_exist():
|
||||
"""Given: schema.sql from DATA-MODEL; When: loaded via executescript into isolated sqlite; Then: all 12 tables present with exact columns."""
|
||||
expected_tables = {
|
||||
"slabs",
|
||||
"slab_photos",
|
||||
"pricing_rules",
|
||||
"woo_categories",
|
||||
"woo_attributes",
|
||||
"woo_attribute_terms",
|
||||
"woo_tags",
|
||||
"settings",
|
||||
"prompts",
|
||||
"prompt_revisions",
|
||||
"sync_log",
|
||||
"inference_log",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = os.path.join(tmp, "isolated.db")
|
||||
assert os.path.exists(SCHEMA_PATH), "schema.sql must exist for test"
|
||||
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
|
||||
schema_sql = f.read()
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.executescript(schema_sql)
|
||||
conn.commit()
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
actual = {r[0] for r in rows}
|
||||
assert expected_tables.issubset(actual), f"Missing tables: {expected_tables - actual}"
|
||||
# spot check one column from slabs per DATA-MODEL
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(slabs)").fetchall()]
|
||||
assert "id" in cols and "status" in cols and "client_rev" in cols
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,62 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def drift_db_dir():
|
||||
# Persistent temp dir; on Windows the global WAL connection can outlive the
|
||||
# test frame, so we avoid TemporaryDirectory's immediate rmtree race and clean
|
||||
# up best-effort after the handle is released.
|
||||
tmp = tempfile.mkdtemp(prefix="slab_drift_")
|
||||
yield tmp
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
def _setup_temp_db_with_m2_schema(tmp_dir: str) -> str:
|
||||
db_path = os.path.join(tmp_dir, "test.db")
|
||||
# M2-era settings shape: NO `value` column (this is the drift signature).
|
||||
# Use a raw connection (DELETE journal) exactly as an old M2 db would be on disk.
|
||||
conn = sqlite3.connect(db_path)
|
||||
# M2-era settings: NO `value` column (drift signature).
|
||||
conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value_enc BLOB, updated_at TEXT)")
|
||||
conn.execute("INSERT INTO settings (key, value_enc, updated_at) VALUES ('woo_wp_username', X'00', '2026-01-01')")
|
||||
# M2-era slabs: NO deleted_at / server_rev (drift signature).
|
||||
conn.execute("CREATE TABLE slabs (id TEXT PRIMARY KEY, sku TEXT, status TEXT)")
|
||||
conn.execute("INSERT INTO slabs (id, sku, status) VALUES ('old', 'OLD-1', 'calibrated')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Force any journal sidecars to merge before the WAL-mode factory opens the file.
|
||||
for suffix in ("-journal", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(db_path + suffix)
|
||||
except OSError:
|
||||
pass
|
||||
return db_path
|
||||
|
||||
|
||||
def test_drift_detection_and_rebuild(monkeypatch, drift_db_dir):
|
||||
db_path = _setup_temp_db_with_m2_schema(drift_db_dir)
|
||||
monkeypatch.setenv("SLAB_DB_PATH", db_path)
|
||||
# init_db must detect pre-M3 drift and rebuild to the M3 settings shape
|
||||
conn = db_mod.init_db()
|
||||
cur = conn.execute("SELECT version FROM schema_version").fetchone()
|
||||
assert cur is not None
|
||||
assert cur[0] == 1
|
||||
info = conn.execute("PRAGMA table_info(settings)").fetchall()
|
||||
cols = {row[1] for row in info}
|
||||
assert "value" in cols
|
||||
assert "value_enc" in cols
|
||||
# slabs must also be rebuilt to the M3 shape (deleted_at present)
|
||||
slab_cols = {row[1] for row in conn.execute("PRAGMA table_info(slabs)").fetchall()}
|
||||
assert "deleted_at" in slab_cols and "server_rev" in slab_cols
|
||||
# M2 rows are disposable (M3 supersedes M2 on this dev volume)
|
||||
assert conn.execute("SELECT key FROM settings").fetchall() == []
|
||||
assert conn.execute("SELECT id FROM slabs").fetchall() == []
|
||||
@@ -1,140 +0,0 @@
|
||||
"""TDD red tests for S1 Settings API (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6)."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
def _valid_key_b64() -> str:
|
||||
raw = b"0123456789abcdef0123456789abcdef"
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
def _setup_env(monkeypatch, tmp):
|
||||
db_path = os.path.join(tmp, "test.db")
|
||||
img_dir = os.path.join(tmp, "images")
|
||||
os.mkdir(img_dir)
|
||||
monkeypatch.setenv("SLAB_DB_PATH", db_path)
|
||||
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.setenv("SLAB_AES_KEY", _valid_key_b64())
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_get_masks_secrets_and_shows_woo_base_url(monkeypatch):
|
||||
"""Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
# seed some data via direct DB (will be replaced by service later)
|
||||
conn = db_mod.get_connection()
|
||||
db_mod.init_db()
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')") # will be decrypt fail path
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')")
|
||||
conn.commit()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["woo_base_url"] == "https://example.com"
|
||||
assert body["woo_wp_username"] == "wpuser"
|
||||
assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured
|
||||
assert body["inference_model"] == "gpt-4o"
|
||||
assert body["inference_enabled"] is True
|
||||
assert "inference_api_key" in body and body["inference_api_key"] == {"configured": False}
|
||||
# never leak
|
||||
assert "value" not in str(body)
|
||||
assert "badbase64" not in str(body)
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_put_rejects_woo_base_url(monkeypatch):
|
||||
"""Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 validation (SETTINGS-85)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"})
|
||||
assert resp.status_code == 400
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == "validation"
|
||||
assert "woo_base_url" in err["message"].lower()
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_put_stores_encrypted_secrets(monkeypatch):
|
||||
"""Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not plaintext), plain fields direct (SETTINGS-20)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
payload = {
|
||||
"woo_app_password": "super-secret-pass",
|
||||
"inference_base_url": "https://api.openai.com/v1",
|
||||
"inference_api_key": "sk-xxx",
|
||||
"inference_model": "gpt-4o-mini",
|
||||
"inference_enabled": True,
|
||||
"content_llm_enabled": False,
|
||||
"brand_voice": "professional",
|
||||
"geo_context": "PNW",
|
||||
"woo_create_status": "draft",
|
||||
}
|
||||
resp = client.put("/api/v1/settings", json=payload)
|
||||
assert resp.status_code == 200
|
||||
# check DB
|
||||
conn = db_mod.get_connection()
|
||||
rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()}
|
||||
assert rows["woo_app_password"][0] is None
|
||||
assert rows["woo_app_password"][1] is not None
|
||||
# value_enc must be valid base64 and >12 bytes when decoded (nonce+ct)
|
||||
enc = rows["woo_app_password"][1]
|
||||
blob = base64.b64decode(enc)
|
||||
assert len(blob) > 12
|
||||
assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None
|
||||
assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None
|
||||
assert rows["inference_enabled"][0] == "true"
|
||||
assert rows.get("content_llm_enabled", (None, None))[0] == "false"
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_flags_default_false_when_absent(monkeypatch):
|
||||
"""Given: no flag rows; When: GET; Then: inference_enabled/content_llm_enabled == false (SETTINGS-90)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["inference_enabled"] is False
|
||||
assert body["content_llm_enabled"] is False
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
||||
"""Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500 (SETTINGS-20/15 spirit)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
conn = db_mod.get_connection()
|
||||
# insert corrupt (not even base64 or short)
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')")
|
||||
conn.commit()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200 # not 500
|
||||
body = resp.json()
|
||||
assert body["inference_api_key"] == {"configured": False}
|
||||
db_mod._reset_for_tests()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user