ai-product-extractor
Messy listings become structured rows. The interesting part is not the extraction — it is the row that extracts cleanly and is still wrong, and what the pipeline does about it.
Public · synthetic demo · offline"It extracted a price" is not "it extracted the right price."
A rule-based parser reads a listing, finds a currency symbol followed by digits, and returns a number. It looks like a success. On a listing that says "was $119 now $79.95" it returns 119 — confidently, silently, and wrong by $39.05. Nothing in the output says anything is amiss. That single row is the whole reason this project exists.
Ten deliberately messy listings
L001 Anker PowerCore 10000 Portable Charger — $25.99. 10000mAh. In stock. L004 Instant Pot Duo 7-in-1 pressure cooker, 6 Quart. was $119 now $79.95! L006 handmade oak cutting board, ~30x20cm, no price listed, contact for availability L007 샤오미 미밴드 39,900원 재고있음 L009 USB-C cable 2m — $8 Mixed currencies (USD/KRW), HTML noise, sale prices, missing prices, Korean stock wording. Ten rows, one target schema.
Actual output of pipeline.py on the committed listings
| Row | What the gate saw | Decision |
|---|---|---|
L004 — "was $119 now $79.95" | two distinct prices in the source; a rule-based pick is a coin flip | price_ambiguous → review |
L006 — "no price listed" | no price to extract at all | price_missing → review |
| the other 8 rows | one unambiguous price, currency and availability resolved | auto-pass |
Checked against the committed ground truth, the 8 auto-passed rows contain zero errors in price, currency, or availability. The one row a naive pipeline would have shipped wrong is exactly the one that got flagged. Nothing else was flagged — the gate does not cry on rows it can actually judge.
Same extractor, same data, different honesty
| No gate | With the gate | |
|---|---|---|
| Rows shipped as final | 10 | 8 |
| Wrong values shipped silently | 1 (L004: $119 instead of $79.95) | 0 |
| Rows a human must look at | unknown — nothing says which | 2, named, with the reason |
| False alarms | — | 0 |
What this backend fills, and what it openly does not
| Field | Filled | Coverage |
|---|---|---|
| name | 10 / 10 | 100% |
| availability | 10 / 10 | 100% |
| price | 9 / 10 | 90% |
| currency | 9 / 10 | 90% |
| brand | 0 / 10 | 0% — the offline backend does not do this |
| category | 0 / 10 | 0% — the offline backend does not do this |
This is the distinction the project is built on. A wrong value is a per-row alarm and goes to a human. A field the backend structurally cannot fill is a capability gap — it is printed as 0% in every run rather than flagged on all ten rows, because a checker that fires on everything is as useless as one that never fires. Brand and category are what the LLM backend is for; the offline path is the deterministic baseline that runs anywhere, with no key.
Runs offline, twice, with the same result.
Every listing is validated against a Pydantic schema before it reaches SQLite, and rows upsert on a stable business key. Running the pipeline twice leaves the products table at 10 rows with 2 still marked needs_review — no duplication, no drift. The unit suite covers currency parsing, HTML stripping, the missing-price path, the was/now ambiguity path, and — deliberately — that an unambiguous row is not flagged: 6 tests, all passing. No API key is required for any of it.
- Ten synthetic listings are a demonstration of the method, not a benchmark. Real accuracy depends entirely on the client's source text.
- The offline backend extracts no brand and no category. Seven of the eight auto-passed rows do have a brand stated in the source that this path leaves empty — the coverage report says so on every run rather than letting it pass unnoticed.
- The gate catches ambiguity and absence, not plausibility. A single, clean, wrong number in the source will still pass.
- The LLM backend is implemented and documented but is not measured here, because running it requires an API key. No numbers are claimed for it.