fc5df28b8c
Two taxonomy stamps, validation/module + 412 hash mismatch, SKU charset vs UTF-8 titles, 409 open_existing required, draft = phone form + failed row.
223 lines
12 KiB
Markdown
223 lines
12 KiB
Markdown
# Content Generation & WooCommerce Integration
|
||
|
||
Status: SPEC · 2026-08-24 · SlabUploader
|
||
Covers: FR16 (deterministic content templates + optional LLM refinement), FR17
|
||
(brand voice / GEO), FR18 (editable, never auto-published), FR25–28 (taxonomy sync,
|
||
product create, sync log), and the exact WooCommerce REST v3 payload.
|
||
|
||
## 1. Content generation (FR16–18)
|
||
|
||
### 1.1 Deterministic templates (always on)
|
||
Templates are configured in Settings (`content_templates`, editable per FR32).
|
||
Defaults below. Placeholders use `{field}`.
|
||
|
||
**title** (default):
|
||
```
|
||
{species} {length_ft}ft {length_in_remainder}in × {width_avg_in}in Wood Slab — {character}
|
||
```
|
||
Example: `Black Walnut 16ft 0in × 18.2in Wood Slab — Cathedral`
|
||
- `length_ft` = floor(length_in / 12), `length_in_remainder` = length_in mod 12 (1 dp).
|
||
|
||
**short_title** (default):
|
||
```
|
||
{species} Slab — {character}
|
||
```
|
||
Example: `Black Walnut Slab — Cathedral`
|
||
|
||
**description** (default, multi-paragraph):
|
||
```
|
||
A one-of-a-one {character} {species} slab, {length_ft}ft {length_in_remainder}in long with an
|
||
average width of {width_avg_in}in and {thickness_in}in thickness ({bdft} bdft).
|
||
|
||
Each slab is naturally shaped — width varies along the length at {width_min_in}in to
|
||
{width_max_in}in. Sold by the slab; dimensions as measured. {geo_sentence}
|
||
```
|
||
`geo_sentence` = GEO context sentence from Settings (FR17), e.g.
|
||
`"Locally milled and shipped from the Pacific Northwest."` (default if unset: none).
|
||
|
||
All template math fields come from the deterministic pipeline (never from the LLM).
|
||
|
||
### 1.2 Call 2 content generation (toggleable)
|
||
- Triggered only if `content_llm_enabled` is true AND the user taps "Generate text"
|
||
on the review screen (never automatic).
|
||
- **LLM writes prose only**: the prompt sends the slab's deterministic measurements
|
||
as facts, and asks the model to write compelling product description prose
|
||
(character, color, warmth, grain, use cases). Dimensions are never generated
|
||
by the LLM — they are injected by deterministic templates.
|
||
- **Template assembly**: after the LLM returns, the server assembles the final
|
||
title, short description, and full description from deterministic templates
|
||
with the LLM prose injected into the description.
|
||
- No numeric guardrail needed — dimensions never pass through the LLM.
|
||
- Output is always editable (FR18); `content_source` = `llm` when used.
|
||
- If the inference endpoint is text-only or unreachable → the button is disabled,
|
||
templates still work. No failure mode blocks publish.
|
||
|
||
### 1.3 Audit
|
||
Every content generation (template or LLM) is logged with inputs (template version,
|
||
slab facts), the LLM request/response payload when used (inference calls logged for audit),
|
||
and the final stored content.
|
||
|
||
## 2. WooCommerce integration (FR24–28)
|
||
|
||
### 2.1 Auth & client
|
||
- REST v3 surface unchanged: `GET/POST/PATCH/DELETE {WOO_BASE_URL}/wp-json/wc/v3/…`
|
||
- Auth is **WordPress Application Passwords**, not WooCommerce consumer keys
|
||
(`ck_`/`cs_`) and not the user's login password.
|
||
- Create a **dedicated low-privilege WordPress user** (shop manager, or a custom
|
||
role with product create/edit + media upload). Do not reuse an admin account.
|
||
- Generate the application password under **Users → Profile → Application
|
||
Passwords**. WordPress requires **HTTPS** for that UI; the store must already
|
||
be on TLS (Caddy enforces HTTPS on the app side too).
|
||
- Basic auth shape: `woo_wp_username:woo_app_password` (username + application
|
||
password). Spaces in the generated password are ignored by WordPress; store
|
||
the value as entered.
|
||
- FastAPI is the sole Woo caller. Credentials live encrypted at rest in SQLite
|
||
(AES-GCM, `SLAB_AES_KEY` env). The browser never holds them.
|
||
- Rotation: revoke the application password in the WP user profile, enter a new
|
||
one in Settings UI, then `POST /api/v1/settings/test-woo`.
|
||
- Client: `httpx` with timeouts (connect 10s, read 60s for image-heavy create).
|
||
- Store URL is **`WOO_BASE_URL`** from Docker Compose / host `.env` (HTTPS only).
|
||
FastAPI refuses `http://` at startup. Not a Settings field; UI shows it read-only.
|
||
|
||
### 2.2 Taxonomy sync (FR25)
|
||
`POST /api/v1/admin/taxonomy/sync` pulls, in order:
|
||
1. `GET /products/categories?per_page=100` (paginate) → species + wood-category leaves
|
||
2. `GET /products/tags?per_page=100` → `fig-*` / `feat-*` only (other tags ignored)
|
||
3. `GET /products/attributes` (+ terms) → five locked attributes
|
||
|
||
Rebuilds cache tables in one transaction. Failure → `502 woo_error`; previous
|
||
cache retained (never delete-then-fail).
|
||
|
||
**Manufactured stamps.** Woo REST has **no** usable last-modified on categories,
|
||
attributes, or tags. FastAPI persists `taxonomy_last_sync_at` (every successful
|
||
pull) and `taxonomy_anchor` (bump only when stored-subset hash changes, including
|
||
deletions). Client re-pulls the full taxonomy iff the anchor advanced. Do not
|
||
invent a Woo-side taxonomy timestamp. Do not sync per navigation.
|
||
|
||
**Triggers:** (1) successful `test-woo` **always** resyncs; (2) publish if
|
||
`now - taxonomy_last_sync_at > 1 hour` then reconcile (bump only on real change);
|
||
(3) optional app launch / Settings refresh. Stale selected IDs after a bump are
|
||
#18 422, not a silent remap.
|
||
|
||
**Species exclusivity.** UI pickers, pricing seeds, and Call 1 species lists are
|
||
**exactly** the synced species-category leaves. No hardcoded, invented, or fallback
|
||
species set. A species new in Woo is selectable only after the next sync stores it.
|
||
|
||
### 2.3 Taxonomy assignment (FR26)
|
||
|
||
**Primary source when inference is on and confidence ≥ threshold (default 0.7):**
|
||
Call 1 vision results pre-fill the review screen for species, wood categories,
|
||
edge type, figure, grade, and mapped `fig-*` / `feat-*` tags (character /
|
||
inclusions / voids / checks → existing `feat-*`). All pre-filled values are
|
||
mutable. The user confirms or overrides; manual entry always wins.
|
||
|
||
**Below threshold:** those fields stay empty. The review screen gates continue
|
||
until the user manually selects **exactly one species, ≥1 wood category, and ≥1
|
||
figure**. Inline nudges: `docs/UX.md`. Do not auto-fill below-threshold
|
||
guesses. Full functional rules: AGENTS §6.
|
||
|
||
**Deterministic name-matching** resolves Call 1 names (or manual picks) to synced
|
||
Woo IDs case-insensitive, trimmed. No match → empty; user picks in review. Woo
|
||
required-field validation still surfaces gaps at publish.
|
||
|
||
Field rules:
|
||
- **Categories**: species leaf (1 required) plus wood-category leaf ids (1+).
|
||
- **Tags**: `fig-*` (1+ required) and `feat-*` (0+). Do not invent generic
|
||
character or species product tags. Woo is authoritative for tag definitions.
|
||
- **Attributes**: the five locked Woo attributes only — Edge Type, Figure, Grade,
|
||
Thickness, Moisture — using term ids from the cache. Do not publish species,
|
||
character, length, or free-text thickness as product attributes. Missing
|
||
attributes are skipped, not created (v1: no attribute creation).
|
||
|
||
Call 1 is assist-only and never gates publish. Inference OFF → full manual path.
|
||
|
||
### 2.4 Product create payload (FR27)
|
||
`POST /products` — body (only non-null fields sent):
|
||
```jsonc
|
||
{
|
||
"name": "{title}",
|
||
"type": "simple",
|
||
"status": "{woo_create_status}", // draft | publish; default draft; SLAB-UAT-* forced draft
|
||
"sku": "{sku}",
|
||
"regular_price": "{price}", // 2 dp string, e.g. "2730.00"
|
||
"description": "{description}",
|
||
"short_description": "{short_title}",
|
||
"categories": [ { "id": <species category id> } ],
|
||
"tags": [ { "id": <fig-* tag id> }, { "id": <feat-* tag id> }, … ],
|
||
"images": [
|
||
{ "src": "<data URL or pre-uploaded attachment URL>", "alt": "{species} {character} slab" }
|
||
],
|
||
"attributes": [ … if present … ],
|
||
"virtual": false,
|
||
"downloadable": false,
|
||
"catalog_visibility": "visible"
|
||
}
|
||
```
|
||
**Image upload strategy** (Woo v3): `images[].src` accepts a URL or a data URL.
|
||
For reliability the server:
|
||
1. For each customer-facing inventory photo in `seq` order (processed transparent PNGs
|
||
from the client TECH-SPEC path): `POST /media` with the **PNG bytes** → get attachment
|
||
`source_url`. Woo accepts `image/*`; keep alpha. Do not re-encode to JPEG and drop alpha.
|
||
2. Reference those `source_url` values in `images` (≤5 inventory photos).
|
||
3. Only `kind=inventory` photos. Calibration photos do not exist in this design.
|
||
|
||
**Status** comes from Settings key **`woo_create_status`** (`draft` | `publish`, default
|
||
**`draft`**). Woo **safety policy** (UAT draft default, `SLAB-UAT-*` force draft even when
|
||
Settings say publish, no edit-same-SKU, purge originals + processed images after successful
|
||
publish) is owned by **AGENTS.md §5**. This file owns payload shape and publish sequence
|
||
only. Do not diverge from AGENTS on force-draft. Do not conflate Woo create status with
|
||
slab lifecycle status `published` (means Woo create succeeded locally).
|
||
|
||
### 2.5 Dedupe & idempotency (FR24a)
|
||
Before create: `GET /products?sku={sku}` (any Woo status).
|
||
- Found → `409 duplicate_sku` (v1 has no update path).
|
||
- Not found → proceed. (Single-owner, slab-by-slab; check still required.)
|
||
|
||
### 2.6 Post-submission recovery (409 / 422) and logging (FR28)
|
||
|
||
Business-logic failures after the user taps publish map to **field-level recovery**
|
||
on the review screen. No raw HTTP codes or stack traces in the UI. The draft stays
|
||
on the phone across error-and-retry.
|
||
|
||
| Class | When | UI recovery |
|
||
|---|---|---|
|
||
| **409 Conflict** | SKU already exists in Woo, any status | Inline on the **SKU** field. Copy names the conflict. Two paths: **edit SKU** and retry, or **open the existing listing** (store admin URL / product id). Never "update in place" of the other product in MVP. |
|
||
| **422 Validation** | Stale or rejected value (category/attribute deleted since sync, price rule, etc.) | Inline on the **affected field**. Copy: value is no longer valid; show **current options** from the synced taxonomy cache (or re-pull if anchor advanced). |
|
||
|
||
Server still writes `sync_log` and sets slab `failed` with machine codes for pollers.
|
||
Success path unchanged: set `woo_product_id`, local `published`, purge images.
|
||
|
||
- **Partial-failure note** (risk, see IMPL-PLAN): if media uploads succeed but the
|
||
final product POST fails, orphaned Woo attachments may exist. v1: log it; surface
|
||
a manual cleanup note. Not auto-deleted.
|
||
|
||
## 3. End-to-end publish sequence (server side)
|
||
```
|
||
1. validate via shared validation module (hash check on submit; see ARCHITECTURE §8)
|
||
2. dedupe by SKU (GET /products?sku=, any status) → 409 duplicate_sku if exists
|
||
3. (re)sync taxonomy if last_sync >1h; bump taxonomy_anchor only on stored-subset hash change
|
||
4. resolve category/tags/attributes (Call 1 / manual + name-match; synced set only)
|
||
5. upload processed PNG inventory images → media → source_urls
|
||
6. POST /products (status per AGENTS §5 + woo_create_status) → woo_product_id
|
||
7. write sync_log(success), set slab published, purge originals + processed images
|
||
on failure: sync_log(failed), slab=failed, images retained; map 409/422 to fields (§2.6)
|
||
```
|
||
|
||
## 4. What is deterministic vs scoped-inference here
|
||
- Deterministic: templates, all dimension text, category/tag/attribute resolution
|
||
against the Woo-synced cache only, payload assembly, dedupe, logging, title assembly.
|
||
- Scoped inference: optional LLM prose generation for description only. The LLM
|
||
never touches dimensions and may only choose species/attributes present in the
|
||
synced taxonomy lists.
|
||
|
||
## 4. Field-level Woo errors (presentation in `docs/UX.md`)
|
||
|
||
Wire shape: `{ field, code, message, actions[] }`. The phone never displays HTTP codes.
|
||
|
||
- **409 duplicate SKU**: actions `edit_sku` | `open_existing`. Include Woo id + admin URL when GET-by-sku works; if lookup fails, keep `open_existing` (SKU + “open this SKU in Woo admin”). No in-place update of the other product.
|
||
- **422 stale field**: current cache options after publish-time sync if #14 fired.
|
||
- Draft: phone keeps the form; server slab → `failed` (retryable). No IndexedDB.
|
||
- Module-stale hash is **412**, not 409.
|
||
|
||
The shared validation module is `GET /api/v1/validation/module`. Hash check on submit only.
|