Spec — Comp Analysis page (NEW)
"Five comparables + ARV + 70%-rule offer, in ~3 seconds." The PropStream
replacement. Prototype: panel-comps in reference/closer/public/v2/index.html.
UX flow
- User enters a subject address → clicks Run comps →.
- Status line streams:
Geocoding address…→Fetching candidates…→AI selecting comps…→ done. - Results render:
- ARV bar: low/high band (e.g.
$385K–$442K), 5 comp dots plotted by sale price, a 70%-rule max-offer marker ($268K), confidence4/5bars, footnoten=5 · 0.4mi · 97d avg age. - AI "Deal read" prose summary (streamed,
--accentstyling). - Selected comparables grid (5 of N candidates): each card → address, beds/baths, sqft, distance (mi), sold price, sold age (
23d ago), and an adjustment delta (+$3.4K/−$11K, bull/bear colored).
- ARV bar: low/high band (e.g.
- Right sidebar: Subject property stats, This month usage (comps run, avg time
2.8s, avg API cost$0.11, saved-to-leads), Recent searches.
Backend pipeline (Cloudflare Worker)
geocode → fetch sold comps (ATTOM / external) → AI selects best 5 + writes deal-read (Anthropic) → compute ARV band + 70%/80% offer → persist + cache.
Graceful degrade to mock data if an external call fails or times out. Track
api_cost_cents and generation_time_ms per query.
ARV math (server): median of adjusted comp $/sqft × subject sqft → band =
[median×~0.93, median×~1.04]; offer_70pct = arv_median × 0.70.
D1 schema (new migration)
comp_queries(id, user_id, input_address, geocoded_lat, geocoded_lng, status[pending|geocoded|candidates_fetched|ai_selected|complete|failed], error_message, total_candidates, selected_count, api_cost_cents, generation_time_ms, created_at)— idx(user_id, created_at DESC).comparables(id, subject_lead_id?, comp_query_id, comp_address, comp_city, comp_state, comp_zip, comp_property_type, beds, baths, sqft, year_built, lot_size_sqft, sold_price_cents, sold_date, days_since_sold, distance_miles, adjustment_cents, data_source, external_mls_id, created_at)— idx(comp_query_id),(subject_lead_id, sold_date DESC).arv_estimates(id, subject_lead_id?, comp_query_id, arv_low_cents, arv_high_cents, arv_median_cents, confidence_score[0-100], confidence_breakdown(JSON), offer_70pct_cents, offer_80pct_cents, rehab_estimate_cents, market_summary, comps_used_count, radius_miles, created_at, expires_at)— TTL ~30d.leadsaugment:comps_run_count INT DEFAULT 0,last_arv_estimate_id TEXT,last_comp_query_date TEXT.
API (/api/v1)
POST /comps/run—{address, radius_miles?=0.5, comp_count?=5, min_quality_score?}→{query_id, subject{…}, comps[…], arv{low,high,median}, offer_70pct_cents, confidence, market_summary, api_cost_cents, generation_time_ms}. Auth required.GET /comps/:queryId— re-fetch a completed analysis (cache hit, no re-query).GET /leads/:leadId/comps— history for a lead (paginated).GET /user/comps/history?limit=10— recent searches (sidebar).GET /user/comps/stats— month/YTD usage (sidebar), 1h cache.POST /comps/:queryId/save—{lead_id?, notes?}→ links to lead, bumpscomps_run_count.
Secrets / env
ATTOM_API_KEY (or chosen comps source), ANTHROPIC_API_KEY — into .dev.vars
locally. Prototype referenced an external https://biglead.velli.cc/api/comps;
the real impl should run inside the soldi worker so cost/latency are tracked.
Build notes
- Slice 1 can ship against a deterministic mock provider (seeded comps) so
the full UI + persistence is verifiable offline; swap in the live ATTOM+AI
provider behind the same
POST /comps/runcontract in a follow-up slice. - Stream the AI deal-read via chunked response or poll
GET /comps/:queryId.