A reusable pipeline to seed a new AppFolio customer's memory graph from their work-order history — entities → observations → beliefs. Mirrors the LAPM chat backfill (scripts/backfill-from-chat.mjs), adapted for structured WO data. First run: Green Oak, Jan-1 window (282 WOs). Built to re-run per customer with --workspace + --vhost.
LAPM's graph was backfilled from iMessage (backfill-from-chat.mjs), 4 phases. Each is LLM-heavy because chat is freeform — the model has to find the entities and events inside the text.
| Phase | What it did | Cost |
|---|---|---|
| 1 · sessionize | Walk chat.db, group messages into conversational sessions (chat_sessions/chat_messages) | mini LLM (judge) |
| entities | LLM reads each session, extracts named vendors/properties/owners → resolveEntity (find-or-create) + owner cascade | full LLM / session |
| 2 · observations | LLM extracts past-tense events ("Kori fixed closet at 17 Ozone; Jose overrode Guox") → memory.addObservation | full LLM / session |
| beliefs | runBeliefFormer(ws, obs_id) consolidates observations → durable beliefs | full LLM / obs |
Key properties we keep: entities before observations (so obs reference real entity ids), idempotency (watermark / extracted_at / belief-evidence checks), cost gates (--confirm-cost, --dry-run, --limit).
vendor · property · unit · tenant · category · description as structured fields — so entity seeding needs no LLM (the chat version's biggest cost). resolveEntity runs straight off the crawl. The freeform layer that does want an LLM is the work-order notes (the activity log) — that's where the real events live ("David reset the router + fixed the entry keypad").| LAPM (chat) | AppFolio (WOs) | |
|---|---|---|
| Unit | chat session | one work order (+ its notes) |
| Entities | LLM-extracted from text | structured columns — deterministic resolveEntity |
| Observation source | Jose's replies | WO description (base) + each note (rich) |
| Beliefs | belief-former | same belief-former + your confirmed facts seeded directly |
WO list via the JSON:API (scripts/appfolio-crawl.mjs) — 1,273 WOs in appfolio/crawl/. Still to add: the per-WO notes (activity log) — crack the notes API the same way ("Download Notes" implies one), so observations are rich, not just the thin description.
For each distinct vendor and property in the window: resolveEntity({workspace_id, kind, name}). Vendors match the loaded vendors table → real ref_id. Properties cascade to owners via owner_properties. Tenants are not graph entities (kinds are vendor/property/owner only — same as the chat seeder, which skips tenants).
owner_properties aren't loaded yet, so property entities would be informal (no legacy backing, no owner cascade). Load properties + owners first (small — 25 props in window) so property entities are real and owners cascade in. Cheap, and unblocks the cascade.Base obs (free): straight from the structured WO — "{vendor} did {description} at {property} ({status}, {date})", entities = [vendor +1, property +1], tags from category. Note obs (LLM): the activity-log notes are freeform events worth extracting (who came, what was actually done, tenant feedback, reschedules) — run an extractor like the chat one. Both go through memory.addObservation(ws, {title, summary, raw_text, entities, tags, salience, source_message_id}).
Seed your confirmed facts directly (no LLM): the 9 vendor-trade overrides + 2 in-house staff (Yesika = cleaner, José = handyman, Bondy = laundry, Roberto = handyman-leaning-leaks…) → high-confidence beliefs with the WOs as evidence. Then run runBeliefFormer over the observations to consolidate the rest (property→preferred-vendor patterns, recurring treatments). This is the only expensive step — gate + batch it.
| Window | WOs | Vendors | Properties | Tenants |
|---|---|---|---|---|
| All time | 1,273 | 84 | 28 | 117 |
| From Jan 1 (#1234) | 282 | 29 | 25 | 69 |
Graph = Jan-1 only (282 obs, not a 1,273 flood — your call). Full history stays in the flat vendor roster / cheat sheet. The trade overrides aren't all from the Jan-1 window, so only seed the ones whose vendor appears in it. Initial batch before the full run: --limit=20 → eyeball the entities + observations → then the rest. Mirror the chat seeder's flags: --phase=entities|observations|beliefs, --limit, --dry-run, --confirm-cost.
node scripts/appfolio-seed.mjs --workspace=greenoak --vhost=greenoakpropertymanagement.appfolio.com \ --since=2026-01-01 --phase=entities --dry-run # preview the entity set node scripts/appfolio-seed.mjs --workspace=greenoak ... --phase=entities # seed entities (cheap) node scripts/appfolio-seed.mjs --workspace=greenoak ... --phase=observations --limit=20 --confirm-cost # batch node scripts/appfolio-seed.mjs --workspace=greenoak ... --phase=beliefs --limit=20 --confirm-cost
Every step is parameterized by --workspace + --vhost; nothing is LAPM/Green-Oak-specific. Onboarding customer #3 (AppFolio) = crawl → load vendors/properties → appfolio-seed --phase=entities → observations → beliefs. The crawler + this seeder are the reusable spine; the only per-customer human work is confirming the handful of untagged vendor trades.
owner_properties before entity seed so properties are real + owners cascade. Otherwise properties seed informal and we relink later.--limit=20, verify belief quality against the eval doc, then widen.