I Built a Pipeline That Turns Messy Supplier Data Into On-Brand Product Listings — Automatically
Drop a supplier CSV. Get clean, grounded, compliance-checked Shopify drafts in under 3 minutes.
The Problem
Every retailer with more than a few hundred SKUs knows this problem.
Supplier data arrives looking like this:
tb-0092, STAINLESS STEEL WATER BOTTLE 750ML, kitchen, Silver,
stainless steel, 750ml, $24.95, , BPA free
TB0094, Linen Throw Cushion, Home, Charcoal, 100% linen,
45cm x 45cm, 34.5, 12, Cover only no insert
TB-0098, Wool Throw Blanket, HOME, Grey Marle, 80% wool 20% acrylic,
130cm x 170cm, 89.0, 22, "Made in Australia, supplier states ""hypoallergenic"""Inconsistent SKU formats. Random capitalisation. Prices with and without dollar signs. Missing quantities. Supplier claims in quotes that you absolutely cannot repeat without verification.
Before any of this becomes a product listing, someone has to read it, normalise it, decide what's safe to publish, write copy in the brand voice, and fill out the SEO fields. That's the expensive part. Not the writing — the judgment.
What It Does
Drop a supplier CSV into an S3 bucket. Within three minutes, clean draft listings appear in Shopify — grounded only in verified product data, written in a consistent brand voice, with unverified claims filtered out before anything goes live.
- Inconsistent formatting and casing normalisation
- Missing prices, missing stock quantities, duplicate SKUs
- Supplier claims that can't be published ("hypoallergenic", "100% natural fragrance")
- Products that aren't ready to list — flagged and held for review
- On-brand copy that follows your voice guide and learns from gold standard examples
Everything lands as a draft. A human approves before publishing. That's intentional.
Architecture
Supplier CSV lands in incoming/ prefix
Normalises each row: fixes SKUs, strips currency, flags duplicates and unverified claims
Skips: out_of_stock · price_missing · possible_duplicate
Fetches relevant brand voice rules + gold standard examples
Writes title, description, bullets, SEO metadata grounded in verified data only
Output-only check — blocks health claims and invented product attributes
Creates product as draft — human approves before publishing
How the Prompts Work
Extraction — structured output with XML tags and JSON fencing
The extraction prompt uses a pattern that consistently produces valid, parseable JSON. XML tags separate the instructions from the data, and a fenced example makes the expected output format unambiguous.
EXTRACTION_PROMPT = """
You are a product data normalisation engine.
<instructions>
- sku: uppercase with hyphen (e.g. TB-0091)
- product_name: Title Case, preserve scent/variant names
- dimensions: if product name contains volume (350ml, 750ml),
extract it here when dimensions is otherwise empty
- flags: array of out_of_stock, price_missing, qty_missing,
possible_duplicate, unverified_claim
</instructions>
<example>
Input: SKU: tb-0092, Item Name: STAINLESS STEEL WATER BOTTLE 750ML...
Output:
```json
{ "sku": "TB-0092", "product_name": "Stainless Steel Water Bottle",
"dimensions": "750 ml", "price": 24.95, "flags": ["qty_missing"] }
```
</example>
<raw_row>{row_text}</raw_row>
Respond with ```json ... ``` only.
"""Generation — RAG-grounded retrieval
Before generating each listing, the pipeline queries the Knowledge Base. The retrieved chunks — brand voice rules and example listings — become the brand context in the generation prompt, so the model has both the product data and the style guide before it writes a single word.
def retrieve_kb_context(query):
resp = agent_runtime.retrieve(
knowledgeBaseId=KB_ID,
retrievalQuery={'text': query},
retrievalConfiguration={
'managedSearchConfiguration': {'numberOfResults': 3}
}
)
return '\n\n---\n\n'.join(
r['content']['text'] for r in resp['retrievalResults']
)The Guardrail — and Why It Runs Output-Only
This was the most interesting part to get right.
The obvious approach is to attach the guardrail to the invoke_model call. The problem: our generation prompt includes the brand voice guide, which explicitly mentions claims you must neverpublish. The guardrail's topic classifier reads those phrases and fires — blocking the request before the model has written a word.
The fix: decouple generation from validation.
def generate_listing_guarded(product):
# Step 1: generate without guardrail
listing_text = generate(product)
# Step 2: validate only the output
guard = bedrock.apply_guardrail(
guardrailIdentifier = GUARDRAIL_ID,
guardrailVersion = GUARDRAIL_VERSION,
source = 'OUTPUT', # <- key
content = [{'text': {'text': listing_text}}]
)
if guard['action'] == 'GUARDRAIL_INTERVENED':
return {'sku': product['sku'], 'blocked': True}
return json.loads(listing_text)Results
Test run: 20 products from a messy supplier CSV
Published to Shopify
As drafts, ready for review
Skipped
Out of stock (3), missing price (1), duplicate (1)
Blocked by guardrail
Unverified health claim
Runtime
End-to-end
Evaluation: 10-product scored set
| SKU | Product | Case | Score |
|---|---|---|---|
| TB-0091 | Ceramic Mug | Normal | 9/9 |
| TB-0096 | Cast Iron Skillet, 26 cm | Spec-heavy | 9/9 |
| TB-0093 | Bamboo Cutting Board | Limited data | 8/9 |
| TB-0099 | Recycled Glass Vase | Eco material | 9/9 |
| TB-0101 | Organic Cotton Tea Towels | Eco material | 8/9 |
| TB-0098 | Wool Throw Blanket | Unverified claim | 9/9 |
| TB-0106 | Soy Wax Candle – Sandalwood | Unverified claim | 9/9 |
| TB-0092 | Stainless Steel Water Bottle | Health claim (BPA) | 7/9 |
| TB-0110 | Bamboo Cotton Bath Towel | Missing qty | 8/9 |
| TB-0103 | Acacia Wood Serving Board | Variable size | 9/9 |
| Average | 8.5 / 9 | ||
Scoring axes: factual grounding (no invented claims) · brand tone (warm, direct, no superlatives) · completeness (all fields present and informative).
What Improved v1 → v2
The first eval run averaged 7.7/9. Four issues, four fixes, taking it to 8.5.
- 01
Capacity missing from titles
"Ceramic Mug 350ml" was extracted as "Ceramic Mug" with dimensions: null.
Fix: Updated the extraction prompt to parse volume and capacity from product names.
- 02
Spec-sheet bullet format
Sparse products defaulted to "Material: Bamboo" / "Category: Kitchen" labels.
Fix: Added explicit WRONG/RIGHT examples to the generation prompt and gold standard docs.
- 03
Scent names dropped
"Soy Wax Candle - Sandalwood" became "Soy Wax Candle" during extraction.
Fix: Updated the extraction prompt to preserve scent and variant names.
- 04
Minor hallucination
"Gets softer with every wash" appeared in a listing — not in the supplier data.
Fix: Added a "Do not add general category knowledge" rule to the generation instructions.
Open Questions
BPA-free (TB-0092)
Blocked by the guardrail in one run and passed in another. The topic classifier is non-deterministic here — "BPA free" sits between a health claim and a factual material descriptor. A policy decision, not a technical one.
TB-0110 Bamboo Cotton Bath Towel
Scored 8/9 due to thin bullets. The supplier provided minimal data. The model did the right thing by not inventing care instructions — the real fix is upstream: ask suppliers for more data.
What's Left for Production
This is a personal reference implementation — not a production system. The gap is about 3.5 days of work:
- Lambda + S3 trigger — replace the notebook with an event-driven function
- Idempotency — check if a SKU exists in Shopify before creating
- AWS Secrets Manager — move credentials out of .env
- IAM role — replace long-lived access keys with an execution role
- Guardrail versioning — promote from DRAFT to a numbered version
- Run-level audit log — write a JSON summary per run to S3
- Error alerting — CloudWatch + SNS on failure or block-rate spike