Lab 03 — Product-Listing Generator Service
商品文案生成服務 · Hands-on track: AI Infrastructure for Real Business Problems
Code:
labs/03/· Uses:data/products.csv,data/docs/brand_voice.md
Business Problem — 商業問題
Kettle & Kiln adds 10–20 SKUs a season. Every new product needs, in English and Traditional Chinese (Taiwan):
- a title of 70 characters or fewer
- a 60–120-word description
- 3–5 bullets
- an SEO meta description
- tags
- image alt text
The owner writes these at night. A freelancer once wrote "microwave safe" on a gold-rimmed mug, and that led to returns and a chargeback.
We want a service, not a chat window: it takes a SKU, returns validated copy, batch-generates a whole collection, and exports a Shopify-importable CSV with everything unpublished until a human approves it. It must never invent specs (a capacity, a dimension, "lead-free") and must never make the wrong care claim.
Architecture — 系統架構
flowchart LR
U[Staff / script] -->|POST /v1/listings sku| API[FastAPI listing-api :8003]
U -->|POST /v1/batches| API
API --> CAT[catalog.py<br/>data/products.csv]
API --> GEN[generator.py]
GEN -->|chat_json + validator| LLM[llmkit → Ollama / cloud]
GEN --> RULES[rules.py<br/>hard + soft business rules]
RULES -->|problems → repair turn| LLM
API --> JOBS[jobs.py thread pool]
JOBS --> CSV[out/*.csv Shopify format<br/>Published=FALSE]
Validation runs in two layers:
- Schema (Pydantic
Listing): lengths and counts, e.g. title 10–70 chars, 3–5 bullets. - Business rules (
rules.py):- Hard rules, never publishable:
- banned hype words
- claims not in the spec ("lead-free", "certified", …)
- any number + unit that isn't in the spec
- "microwave safe" when the care text says otherwise
- Simplified characters in zh-TW copy
- Soft rules, style only:
- title includes the product name
- description 60–120 words
- no ALL-CAPS
- bullets of 15 words or fewer
- Hard rules, never publishable:
Rule problems are sent back to the model as a repair turn (LISTING_MAX_REPAIRS, default 2). If only soft rules still fail after the repairs, the draft is returned with needs_review: true. If a hard rule still fails, the API returns 422, and nothing unsafe gets through.
Prerequisites — 先決條件
- Labs 01–02. A chat model that handles JSON, e.g.
qwen3:8bor larger, or a cloud model. - Docker + Compose v2 for the container path (optional; the lab runs fine with
uvicorn).
Step-by-Step Setup — 逐步設定
cd hands-on/labs/03
uv venv --python 3.12 && source .venv/bin/activate
uv pip install -e ../common -r requirements.txt
cp .env.example .env
uvicorn app.main:app --port 8003 # http://localhost:8003/docs
Single listing:
curl -s localhost:8003/v1/listings -H 'content-type: application/json' \
-d '{"sku":"KK-MUG-001","lang":"en"}' | python -m json.tool
curl -s localhost:8003/v1/listings -H 'content-type: application/json' \
-d '{"sku":"KK-MUG-003","lang":"zh-TW"}' | python -m json.tool # the gold-rim mug
Batch + Shopify CSV:
curl -s localhost:8003/v1/batches -H 'content-type: application/json' \
-d '{"skus":["KK-MUG-001","KK-TEA-001"],"lang":"en"}' # → {"job_id": "…", "status": "queued", "total": 2, ...}
curl -s localhost:8003/v1/batches/<job_id>
curl -s -o listings.csv localhost:8003/v1/batches/<job_id>/shopify.csv
# or without the server (cron-friendly):
python batch_cli.py --skus KK-MUG-001 KK-TEA-001 --out out/listings.csv
The CSV has a leading Needs Review column. Filter on it, then delete the column before importing in Shopify. Published is always FALSE.
Container:
docker compose up --build # Ollama on the host is reached via host.docker.internal
Code Walkthrough — 程式碼導覽
app/schemas.py:ProductSpec(from CSV),Listing(output contract), request/response models.app/rules.py:hard_problems()/soft_problems().- The number check extracts every
number + unit(ml, cm, g, hours, pieces …) from the copy and requires the number to appear in the spec. "Set of 4" is fine only if 4 is in the spec.
app/generator.py:- Builds the prompt from the spec plus
brand_voice.md. - Calls
chat_json(..., validator=check_listing). - Falls back to
needs_reviewfor soft-only failures. - Adds a warning for
final_saleitems.
- Builds the prompt from the spec plus
app/jobs.py: a small thread pool (LISTING_MAX_WORKERS, default 2 so a single local GPU isn't oversubscribed) and the Shopify CSV writer.
How to Test — 測試方法
LLM_PROVIDER=mock pytest -q # 8 tests
The tests cover:
- health
- listing from SKU
- unknown SKU → 404
- the rules catching hallucinated numbers and banned words
- the gold-rim microwave rule
- repair loop → 422 when the model keeps breaking hard rules
- batch job + Shopify CSV
- soft-only failures →
needs_review
What was run on the build box — 實測紀錄
- ✅
pytest: 8 passed. - ✅ Live, Ollama
qwen2.5:1.5bon CPU (single run):- A batch of 4 SKUs produced 4/4 drafts with 0 hard-rule failures.
- All 4 were flagged
needs_reviewfor soft (style) rules. - The API path worked end to end.
- This is the design working as intended: a small model's copy is usable as a draft, and the system says so instead of pretending it's final.
- ⚠️ Not run:
docker compose up --build(no Docker daemon on the build box;docker compose configwas validated), and a real Shopify import.
Cost & Performance Notes — 成本與效能
- Each listing is 1 call plus up to
LISTING_MAX_REPAIRSrepair calls. The response includestokensandattempts. Log them for a batch and multiply:skus × avg tokens × price(cloud) gives your estimate. Repairs are where cost hides. If most listings need 2 repairs, fix the prompt or the model rather than paying for 3× tokens. - Translation doubles calls. Generating zh-TW from the spec directly (as here) usually reads more natural than translating the English; check this with a native speaker.
- Throughput on a single local GPU is limited by the model server. Raising
LISTING_MAX_WORKERSabove what the server can run in parallel only adds queueing (Ollama: seeOLLAMA_NUM_PARALLEL).
Common Failures & Fixes — 常見錯誤與修正
| Symptom | Cause | Fix |
|---|---|---|
| 422 with "quantity '350 ml' is not in the spec" | model invented or reformatted a number | good: the rule worked. Add the real value to products.csv if it's true |
Every listing needs_review |
model too small, or word-count rules too strict for the language | larger model; for zh-TW word count is skipped (no spaces) |
| False positive on "Set of 4" | number rule too broad | rule compares numbers only, so make sure the spec mentions the count |
Container: Connection refused |
LLM_BASE_URL points to localhost inside container |
compose sets host.docker.internal; on Linux keep extra_hosts: host-gateway |
| Cloud provider in Docker ignores your key/URL | compose's LLM_BASE_URL default overrides .env |
set LLM_BASE_URL explicitly in .env when using a cloud provider |
| Shopify import rejects file | the extra Needs Review column / missing columns for your store |
delete the column; compare headers with a fresh Shopify export |
Stretch Exercise — 延伸練習
- Add a hard rule: if
spec.final_saleis true, the description must contain "final sale" (en) or "恕不退換" (zh-TW). Write the test first. - Add
POST /v1/listings/{sku}/approvethat records the approver and flips the row toPublished=TRUEin the next export.
Deliverable Checklist — 交付清單
- [ ] API running;
/docsreachable - [ ] Listings for 4+ SKUs in both languages, with the
attempts/tokensnoted - [ ] Shopify CSV from a batch with
Published=FALSE - [ ] Proof that KK-MUG-003 is never described as microwave safe
- [ ]
pytest -qpasses