← back

Production system · Python + FastAPI + LLM extraction · deployed for a real estate agency

Real Estate Listing Automation

A small real estate agency was hand-typing every listing into their CRM's web form, and re-typing client submissions that arrived as scattered emails and photos. This replaces both: a feed the CRM pulls on its own schedule, and an email inbox an LLM turns into a draft — that a human still has to open, check, and approve before anything publishes.

1 agency, in production 3 intake paths into one feed 4 schema errors found via the real validator 0 listings auto-published without review
01

What was there before

Every listing went in by hand through the CRM's own web form — a dozen fields, one at a time, per property. Client submissions arrived as email: a paragraph of facts and a batch of phone photos, in no particular order, sometimes with a document or two mixed in among the photos by accident. Turning that into a clean, correctly-tagged listing was manual work every time, and it didn't scale past whoever had the patience to do the retyping.

02

Three ways in, one feed out

Web formFastAPI + SQLite, drag-to-reorder photos
Client emailtext + photos, unstructured
LLM field extractionstructured JSON, or a flag: "not a listing"
Draft — unpublisheda person opens it, corrects it, approves it
One XML feedrebuilt on every save, polled by the CRM on its own schedule

Every listing — whether typed into the form or drafted by the model — carries the same stable identifier from creation onward. The CRM matches on that identifier, so re-publishing the feed updates the existing listing instead of creating a second one. That single invariant is what makes "regenerate the whole feed on every save" safe to do without thinking about it.

03

What actually broke in production

Not a staged list — these are the five things that went wrong while getting a real listing live and the email intake running, in the order they happened.

01

The published schema and the validated schema didn't match

The vendor's own field documentation renders client-side and returns nothing to a plain fetch. The first feed was built from secondary sources and failed their validator on four counts — a renamed ID field, a renamed room-count field, a whole contact-phone block that didn't exist yet, and three fields that turned out to need a wrapper element around them. The fix was to stop treating secondary sources as ground truth and let the validator's own error messages drive the schema.

Id → ExternalId Rooms → FlatRoomsCount + Phones/PhoneSchema/{CountryCode,Number} + Building wrapper around {FloorsCount,BuildingType,BuiltYear}
02

The CDN's SSL mode assumed a certificate the origin didn't have

The domain sits behind a CDN in "Full" SSL mode, which encrypts CDN→origin as well as visitor→CDN — the origin server was only listening on port 80. Every request came back 521 until the origin got a certificate. It doesn't need to be publicly trusted for "Full" (only "Full strict" checks that), so a self-signed one on port 443 was enough.

03

Reading an email marked it read, before it was processed

IMAP's standard whole-message fetch has a side effect it doesn't advertise in its name: retrieving a message flips it to \Seen regardless of what the caller does next. A failed run on the very first batch of test emails silently marked all of them read — they'd have been invisible to the next "fetch unread mail" pass forever, unprocessed and unflagged as failed.

FETCH ... RFC822 → marks \Seen as a side effect of reading FETCH ... BODY.PEEK[] → reads without marking; \Seen is set explicitly, only after the draft is actually created
04

The extraction model happily hallucinated a listing from a security alert

The inbox that receives client emails also receives whatever else lands in a mailbox — in this case, a new account's own welcome and security-alert emails from its provider. With nothing telling it otherwise, the model dutifully invented a plausible-looking address and price from account-security boilerplate. The fix wasn't a keyword filter bolted on afterward — it was making "is this actually a listing?" part of the extraction schema itself, so the model has to commit to that judgment before it's allowed to fill in anything else.

{"is_listing": false, "label": null, "price": null, ...} — every other field is forced null in the same response once is_listing is false, instead of a separate filtering pass
05

Not every photo a client sends is a photo of the property

Among the first real batch of client photos, two files were scans of an ownership certificate and a cadastral passport — the owner's full name and national insurance number, readable, saved into the same folder as the kitchen and bedroom shots. Nothing flagged them automatically; they were caught by looking at every photo before anything went live, which is the actual policy now: photo review is a manual step in the workflow, not something the pipeline is trusted to get right on its own. See below for why that's a deliberate line, not a gap to close later.

04

Where the automation deliberately stops

Every draft the model produces — from the web form or from an email — lands in an unpublished state. Nothing reaches the CRM until a person opens it, fixes what's wrong (an address the model couldn't find, a phone number that wasn't in the text, a photo that shouldn't be there), and explicitly approves it. That's not a missing feature; findings 04 and 05 above are exactly why it's the design: a model that will confidently invent an address from a security email, or forward whatever's in a photo folder without looking at it, is a drafting tool, not a publisher. The review step is what makes the rest of the automation safe to trust.

05

Stack

·FastAPI + SQLite — listing records, admin form, drag-to-reorder photo grid
·DeepSeek API, schema-constrained JSON, thinking mode off — single-shot field extraction, not a chat
·IMAP polling — a systemd timer, not a long-running process
·OpenStreetMap/Nominatim — address → coordinates, no paid geocoder needed at this volume
·nginx + a CDN in front — basic-auth on the admin UI, the feed itself stays public for the CRM to poll
·systemd services/timers — no orchestration layer this scale doesn't need
06

Where this is now

Live for one agency, low volume by design — this replaces an afternoon of retyping per listing, not a high-throughput pipeline. The reusable part isn't specific to real estate: a structured feed sync with a stable identifier per record, and an LLM-drafted intake path that a human approves before anything goes out, is the same shape regardless of what's being listed.