← All work
Case studyAI pipeline · Auction cataloguing

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 catalogue
Availability feedevery 30 minutesDealer storefronttwice a dayWholesale spreadsheetson deliveryEligible watchesstill listed in the feednot excluded by an operatorretired, never deletedall-or-nothing importsLLM gatewayAI copywriterstructured output · taxonomyskips unchanged watchesvalidate → retry → or failnever stores a partial listingCataloguereserve · estimatescondition reportupload-ready CSVphotosImage pipelineformat read from the bytesCloudflare R2 + CDN3,669 photos

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. 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. 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. 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. 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
Media
Format detection · Imagick · Cloudflare R2 · CDN
Quality
PHPUnit with the model, network and storage faked
PHPLaravelLLM integrationPrompt engineeringStructured outputWeb scrapingData normalisationQueues & schedulingImage processingCDN hostingAutomated testing

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.

Next

Need AI that has to get the facts right?

Get in touch →
© 2026 MD Morsalinbuilt with care · OSS-first · sustainable by design
lat: 23.81°Nlon: 90.41°Etz: UTC+6status: 200 OK