AI Listing Pipeline for a Luxury-Watch Auction House
Two dealers supply the watches — one through an online store and a live availability feed, one through spreadsheets that change shape between deliveries. This pipeline brings both together, hosts every photo on a CDN, has an LLM write a constrained auction listing for each watch, and exports an upload-ready catalogue.
A small codebase with no web interface — operators run it from the command line and a scheduler keeps it current. The interesting work is not the size: it is making unreliable inputs and a probabilistic writer produce output an auction house can publish.
Client
Serenity Gallerie
Role
Design, engineering & coding
Domain
Watch inventory · auction cataloguing · AI copy
Shape
Laravel app · command line + scheduler
Status
In use since May 2026
The problem
Two watch dealers supply inventory in incompatible shapes — a scraped storefront, a live availability feed and spreadsheets that change between deliveries.
What was built
Morsalin designed and built a Laravel pipeline that merges the sources, hosts every photo on a CDN and has an LLM write each listing under strict, validated rules.
The result
In use since May 2026: 1,027 AI-written listings and 27 upload-ready catalogue exports. Cataloguing moved from writing to reviewing.
1,027AI-written listings
3,669photos hosted
27catalogue exports
663lots in the largest
636taxonomy IDs
30 minavailability refresh
~2.7klines of PHP
~660lines of tests · 25 tests
01The problem
Two dealers’ data, none of it shaped for an auction.
01
The storefront has the words, the feed has the truth.
The dealer’s store carries descriptions, specs and photos, but its stock status lags. A separate feed is authoritative for what is still for sale — and a lot for a watch already sold is a problem at the auction.
02
The wholesaler sends spreadsheets, and they drift.
Between two deliveries the file lost five columns, gained three, renamed four and moved its images into differently named columns.
03
A catalogue needs more than a product page.
Every lot needs a fixed-format title, a structured description with specifications, a condition report, a reserve, estimates, and IDs from the platform’s taxonomy.
04
An auction listing is a statement.
Copy that calls a dial “sunray” or a part “original” when the data does not say so is a liability, not a style choice.
Merge sources it does not control, track availability close to real time, write hundreds of listings without inventing facts — and never pay to rewrite a listing that has not changed.
02What it does
From raw inventory to an upload-ready catalogue.
Sources
Scrapes the dealer’s storefront twice a day, polls its availability feed every 30 minutes, applies negotiated price overrides, and imports wholesale spreadsheets on delivery.
Listings
An LLM writes the title, a four-paragraph description, a specifications list and taxonomy selections for every eligible watch.
Images
Downloads every photo, normalises its format and hosts it on a CDN.
Catalogue
Exports upload-ready lots with reserve, estimates and an approved condition report.
Monitoring
Emails a list of watches that have left the feed, with photos and prices.
Review
Compares two catalogues so a new export can be reviewed as a set of changes.
03Architecture
Three inputs, one catalogue.
fig.02 — pipelinethree inputs · one cataloguescroll →
The feed decides availability
The store is scraped for its content, but a watch only reaches the catalogue while the feed still lists it. The store lags; the next export after a sale simply does not contain that lot.
One listing, two sources
Listings started out tied to the storefront. When the wholesaler arrived, a listing became something either source can own — migrated in place, with every existing listing carried over.
Retire, never delete
A watch missing from a scrape or delivery is retired, not deleted. One that comes back keeps its lot reference and listing, and the history of what sold stays visible.
04Drifting spreadsheets
Rules for what a malformed file may change.
01
Columns bind by name, not position
Headers are normalised before matching, so a reordered file, a renamed column or a stray space in a header does not break an import. Irrelevant columns are ignored explicitly, so a warning about an unrecognised one still means something.
02
A retail price never becomes a reserve
The importer accepts other names for the wholesale price — but never a retail or MSRP column, which would silently become the auction reserve. Using one has to be an explicit choice.
03
Nothing is written unless everything is valid
Every row is validated before the first write; one bad row rejects the delivery as a whole, with the errors listed. A dry run shows what would be created, updated and retired.
04
Reconcile only against a complete snapshot
Watches missing from a delivery are retired — but never after a partial import, which would retire real stock.
“A partial import is not a valid inventory snapshot, so never reconcile against one.”
05AI only on change
The model is called when a watch changes — not before.
Each watch carries a fingerprint of the facts that shape its listing. When it has not changed, the model is not called. Prices are deliberately left out, so a price change updates the catalogue without paying for new copy.
Adding a field is the trap: it changes every fingerprint and would regenerate every listing. New fields therefore count only when a watch actually has a value for them.
215listings not rewritten
when a delivery introduced new fields — the fingerprint rule kept every existing listing as it was.
06Constraining the model
Validate the model, don’t trust it.
The model returns structured output against a schema — title, description and taxonomy selections, all required. The constraints come in three layers.
01
The prompt
Fixes the title format and length, the description structure and a required specifications list; translates the dealer’s shorthand; keeps reference numbers exactly as given; bans unsupported descriptors; and embeds the platform’s 636-ID taxonomy with an instruction to never guess a brand.
02
The input
Prices and internal identifiers are removed before prompting. The model cannot mention a price it was never given.
03
The check
Every response is validated before it is saved: the specifications list must be present and complete, and the listing must carry the watch’s reference. A failure is retried with a note naming what was missing — then the job fails rather than store an incomplete listing.
From the system prompt Do not describe dial finishes, provenance, or rarity with words such as “sunray”, “factory”, “original”, “rare”, “scarce”, or “collectible” unless that exact term appears in the raw data.
Separate tooling finds and regenerates any listing written before a rule existed, and every listing records which model wrote it — so a later quality question can be traced to the model that caused it.
07Images
Trust the file, not the label.
One supplier served WebP images under .jpg addresses while declaring them JPEG. Neither the address nor the declared type could be trusted, so the format is read from the file itself.
Convert only what must be
JPEG and PNG are stored untouched, since re-encoding a JPEG only loses quality. Other formats become JPEG, flattened onto white so transparency does not turn black.
Never lose a good image
A failed re-download keeps what was already hosted, and a missing image is left visibly missing so the next run retries it.
Cheap when nothing changed
Watches whose photos are all hosted are skipped entirely.
08Testing
Tests where a silent failure would publish.
The model, network, storage and queue are all faked — so the suite runs offline, costs nothing, and can script a model that returns exactly the broken output a test needs.
AI copywriterComplete listings are saved; missing specifications or references trigger a retry; incomplete listings are never saved
ImagesA failed download keeps good images; formats are converted only when needed
ImportsOld and new spreadsheet formats both import; columns map correctly; fingerprints stay stable when fields are added
ExportEvery watch gets the right condition report
09Problems solved
Investigation before code.
1
A spreadsheet that changed shape
A delivery arrived with a fifth of its columns removed, added or renamed. One new column looked like a merge of two old ones; the data said otherwise — in 44 of 45 rows holding both, the values were identical, and the one difference was the newer value being more specific. It was a rename, and treating it as one avoided duplicated data. The delivery imported 627 watches and produced 461 new listings in one pass.
2
Listings that stopped short
Some generated descriptions ended before their specifications. The fix was layered: a prompt rule, validation with retry, a hard failure instead of a stored partial listing, and a repair pass over the listings already affected.
3
Images that lied about their type
Files served with the wrong extension and type were stored wrongly until detection moved to the file contents.
4
Wording the auction house would not accept
Repeated phrases, unworn watches receiving the pre-owned condition report, and flourishes the data did not support were each closed with a rule and, where possible, a test.
10Key decisions
Every non-obvious choice, with its reason.
The feed decides availability
An auction lot for a sold watch is worse than a missing one.
Bind spreadsheet columns by name
Suppliers reorder and rename columns; position is the least stable thing in the file.
Never treat retail as wholesale
A retail figure would silently become the reserve.
Validate every row before writing any
A half-imported delivery is harder to reason about than a rejected one.
Never reconcile after a partial import
Retiring against a partial file would retire real stock.
Prices outside the fingerprint
A price change should update the catalogue, not pay for new copy.
Remove prices from the prompt
The model cannot state what it was never given.
Validate model output and fail hard
An incomplete listing saved is worse than a job that visibly failed.
Read image formats from the file
The supplier’s addresses and declared types were wrong.
Retire rather than delete
A returning watch keeps its lot reference and history.
11Outcome
Cataloguing moved from writing to reviewing.
Inventory
Three inputs from two dealers merged into one list of what is for sale, refreshed every 30 minutes.
Content
1,027 AI-written listings with specifications and taxonomy selections.
Media
3,669 photos normalised and hosted on a CDN.
Catalogue
27 upload-ready exports of up to 663 lots, with pricing, estimates and condition reports.
✓Cataloguing moved from writing to reviewing — the operator runs a handful of commands and reviews what changed since the last catalogue.
✓Sold watches leave the catalogue on their own, and removals arrive by email within half an hour.
✓Supplier changes became routine — a delivery that changed a fifth of its columns was absorbed in one pass.
✓AI cost follows change — unchanged watches are skipped, price changes are free, and adding fields did not trigger a mass rewrite.
My role
I designed, built and coded the pipeline: how two unrelated sources map onto one listing, the rules for what a malformed spreadsheet may change, the image pipeline, the prompt and the checks between the model and a published listing, and the export the auction platform consumes. I did the coding myself, working with AI-assisted tooling. When a delivery behaved unexpectedly, I traced each anomaly to a root cause with the numbers to back it, and wrote it up for the client.
Stack
App
PHP · Laravel · queued jobs · scheduler
AI
Laravel AI SDK · structured output · OpenRouter
Sources
Storefront scraping · XML feed · spreadsheet imports
Built at Serenity Gallerie, an online auction house for luxury jewellery and watches, where I have been Tech Lead since Q4 2025. Dealer, supplier, platform and domain identifiers are deliberately omitted.