Skip to main content

Demo. These are not real sales. Every stop is synthetic data, built to show the map and an AI ingest pipeline. How the ingest works

Ingest pipeline

How the ingest works

The ingest script reads a community sale list, sends the plain text to Claude with a structured prompt, and checks every record it gets back against a schema before anything is written. This page shows one sample going through it.

What you are looking at

  • The input is a synthetic three-row sample, written by hand. It is not from a real sale list.
  • The output came from running the script in mock mode (MOCK_INGEST=1). Mock mode skips Claude and returns a fixed, hand-written result for this sample. It shows the shape a real model call has to return. It is not a model’s output.
  • The prompt and the validation rules below are the real ones from the code. The checks on this page run against the real schema at build time.
  • The 30 stops on the demo map were not produced by this pipeline. They were written by hand.

1.Input: a sale list

A sale day usually lives in one place, like a blog post. This is the sample HTML the script reads (scripts/__fixtures__/ws-2026-sample.html).

Sample input (HTML, synthetic)
<div class="entry-content">
  <h2>Sample sale list (synthetic demo data)</h2>
  <p>Saturday, May 9, 2026. Most sales run 9am to 3pm.</p>

  <ol>
    <li>
      <strong>4500 block of California Ave SW</strong>. 9am–3pm.
      Categories: furniture, kitchen.
      Items: midcentury teak credenza, round oak dining table, KitchenAid mixer,
      stack of Pyrex bowls. Notes: cash only. Host: Junction household.
    </li>
    <li>
      <strong>5600 block of California Ave SW</strong>. 8am–2pm.
      Categories: books, art supplies.
      Items: paperbacks (mystery + sci-fi), acrylic paints, brushes, framed
      botanical prints, sketchbooks. Notes: rain or shine. Host: California Ave household.
    </li>
    <li>
      <strong>3800 block of Beach Drive SW</strong>. 9am–4pm.
      Categories: sports, kids.
      Items: used Trek road bike, inflatable paddleboard, kids life jackets,
      Patagonia rain shells, beach toys. Host: anonymous.
    </li>
  </ol>
</div>

The script strips tags, scripts, comments and extra whitespace to keep the prompt short. This is the plain text that would go to Claude:

Plain text sent to the model
Sample sale list (synthetic demo data) Saturday, May 9, 2026. Most sales run 9am to 3pm. 4500 block of California Ave SW . 9am–3pm. Categories: furniture, kitchen. Items: midcentury teak credenza, round oak dining table, KitchenAid mixer, stack of Pyrex bowls. Notes: cash only. Host: Junction household. 5600 block of California Ave SW . 8am–2pm. Categories: books, art supplies. Items: paperbacks (mystery + sci-fi), acrylic paints, brushes, framed botanical prints, sketchbooks. Notes: rain or shine. Host: California Ave household. 3800 block of Beach Drive SW . 9am–4pm. Categories: sports, kids. Items: used Trek road bike, inflatable paddleboard, kids life jackets, Patagonia rain shells, beach toys. Host: anonymous.

2.The prompt

The script asks for JSON only, one record per stop, with fixed field rules. It is set to use claude-sonnet-4-6 with a 32,000-token output cap, and it stops with a clear error on a refusal or a truncated reply instead of parsing half a JSON object.

Show the full prompt
You are extracting structured garage-sale listing data from a community
garage-sale list (typically a blog post or PDF). Return ONLY a JSON object
matching this shape: {"listings":[{...}, ...]}.

Per-listing fields:
- id: stable per event, format ws-2026-001 (zero-padded 3-digit suffix)
- eventSlug: always "garage-sale-day-2026"
- address: full street address (include city/state suffix when known)
- lat / lng: always 0 (geocoding is a separate step)
- hours: free-text time window (e.g. '9am-3pm')
- categories: 1-4 from the union (furniture, clothes, kids-baby, tools,
  books-media, kitchen, electronics, sports-outdoors, garden-plants,
  art-craft, collectibles, free-pile, estate, other)
- items: 1-8 short item descriptions (free text)
- notes: host-provided notes if present (cash only, no early birds, etc.)
- host: a neutral label from the source (e.g. 'multi-family', 'estate
  sale', 'Junction household') OR the literal string 'anonymous'. Never
  copy a personal name, even when the source gives one.
- hasFreePile: true only when a free-pile is explicitly mentioned
- source: always "ai-ingest"
- sourceLineRef: a stable reference to where in the source this listing
  appeared (e.g. 'item-3' or 'paragraph-7')
- confidence: 0.0-1.0 self-rated extraction confidence
- rawSourceText: the verbatim slice of source text for this listing

Rules:
- Do NOT invent listings that aren't in the source.
- Skip headers, table-of-contents entries, and prose that isn't a listing.
- Normalize categories aggressively into the union; map 'antiques' to
  'collectibles', 'baby' to 'kids-baby', etc.
- For listings with no explicit host label, use 'anonymous'.
- If the source text is too short or contains no listings, return
  {"listings": []}.

Source text:
---
<plain text of the sale list goes here>
---
Return only the JSON object. No prose, no markdown, no code fence.

3.Output: one structured record

Each stop comes back as a record like this. The first one, from the mock-mode run:

Example record (mock mode, hand-written fixture)
{
  "id": "ws-2026-mock-001",
  "eventSlug": "garage-sale-day-2026",
  "address": "4500 block of California Ave SW, Seattle, WA",
  "lat": 0,
  "lng": 0,
  "hours": "9am-3pm",
  "categories": [
    "furniture",
    "kitchen"
  ],
  "items": [
    "midcentury teak credenza",
    "round oak dining table",
    "KitchenAid mixer",
    "stack of Pyrex bowls"
  ],
  "notes": "cash only",
  "host": "Junction household",
  "source": "ai-ingest",
  "sourceLineRef": "mock-fixture:line-1 (see scripts/__fixtures__/ws-2026-sample.html)",
  "rawSourceText": "4500 block of California Ave SW. 9am-3pm. Categories: furniture, kitchen. Items: midcentury teak credenza, round oak dining table, KitchenAid mixer, stack of Pyrex bowls. Notes: cash only. Host: Junction household",
  "confidence": 0.95
}

The script then splits each record in two. The map data goes to garage-sale-day-2026.ingest.json. The model’s confidence and the verbatim source slice go to a debug sidecar, garage-sale-day-2026.ingest-debug.json, so a person can check what the model did with what the source said. Coordinates start at 0 and are filled in by a separate geocoding step.

4.Validation rules

Every reply is parsed with a Zod schema (lib/listing-schema.ts). If any record fails, the whole run fails and nothing is written.

  • id is letters, digits, - or _ only, so it is safe in a URL and an HTML attribute.
  • address is at least 5 characters; hours is not empty.
  • categories has 1 to 4 entries, each from a fixed list of 14 (furniture, clothes, kids-baby, tools, and so on).
  • items has 1 to 8 entries, none empty.
  • source must be "ai-ingest", and sourceLineRef is required, so every record points back to where it came from.
  • confidence, when present, is between 0 and 1.
  • The reply must hold at least one listing, and the prompt tells the model never to invent a listing or copy a personal name.

Mock output: passes

3 records checked against the schema at build time.

Bad record: rejected

A hand-written example with 5 problems.

Rejected example (hand-written to show the checks)
{
  "id": "ws 2026 004",
  "eventSlug": "garage-sale-day-2026",
  "address": "Alki",
  "hours": "9am-3pm",
  "categories": [
    "antiques",
    "furniture"
  ],
  "items": [],
  "source": "ai-ingest"
}

What the schema reports for it:

  • id: Invalid string: must match pattern /^[a-zA-Z0-9_-]+$/
  • address: Too small: expected string to have >=5 characters
  • categories.0: Invalid option: expected one of "furniture"|"clothes"|"kids-baby"|"tools"|"books-media"|"kitchen"|"electronics"|"sports-outdoors"|"garden-plants"|"art-craft"|"collectibles"|"free-pile"|"estate"|"other"
  • items: Too small: expected array to have >=1 items
  • sourceLineRef: Required field is missing

5.After the ingest

A second script geocodes new addresses with OpenStreetMap Nominatim at one request per second and caches the results in the repo, so a build makes no network calls. The plan is for a person to review the ingest output against the debug sidecar before it goes on a map.

Back to the demo map