179 lines
12 KiB
Markdown
179 lines
12 KiB
Markdown
# DATA-MODEL — Bike-Tinder
|
||
|
||
SQLite, WAL mode, `busy_timeout`. One file: `data/bike-tinder.sqlite3`. Schema as consulted (Torres, 2026-11-10), locked for v1.
|
||
|
||
## Tables
|
||
|
||
```sql
|
||
listings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
region TEXT NOT NULL CHECK (region IN ('ns','nb')),
|
||
src_id TEXT NOT NULL, -- source ad id (Kijiji id in v1)
|
||
title TEXT NOT NULL,
|
||
price_display TEXT, -- "$5,000"; "Please Contact"
|
||
price_num INTEGER, -- NULL when unpackable
|
||
km INTEGER, -- NULL when not stated (~2/3 of cards)
|
||
location TEXT,
|
||
url TEXT NOT NULL, -- source ad page
|
||
img_url TEXT, -- source CDN card image
|
||
desc TEXT, -- card description snippet
|
||
family TEXT, -- top rubric family label (taste signal)
|
||
score INTEGER NOT NULL DEFAULT 0, -- rubric score, recomputed each import
|
||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','gone','dup')),
|
||
dup_of INTEGER, -- canonical row id when status='dup'
|
||
first_seen TEXT, last_seen TEXT,
|
||
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT,
|
||
UNIQUE (region, src_id)
|
||
);
|
||
CREATE INDEX idx_listings_deck ON listings (status, score DESC);
|
||
CREATE INDEX idx_listings_dup ON listings (dup_of) WHERE status = 'dup';
|
||
|
||
swipes (
|
||
listing_id INTEGER PRIMARY KEY REFERENCES listings(id) ON DELETE CASCADE,
|
||
action TEXT NOT NULL CHECK (action IN ('like','nope','maybe')),
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT
|
||
);
|
||
|
||
import_runs (
|
||
id, started_at, ok, partial,
|
||
src_fetched_ns, src_fetched_nb,
|
||
total, new, gone, dup, error
|
||
); -- one observability row per nightly run
|
||
|
||
prefs (
|
||
key TEXT PRIMARY KEY, value TEXT
|
||
); -- deck_size, depth, etc. — tiny
|
||
```
|
||
|
||
Notes:
|
||
|
||
- One row per swipe; a new swipe **upserts** (a re-swiped card overwrites its action). The latest action is the truth; edits from the list view are upserts. Append-history is deliberately not kept in v1.
|
||
- `import_runs` is the dashboard's evidence trail — every run writes one row, success or failure.
|
||
|
||
## State semantics
|
||
|
||
- **new** — `active` with no `swipes` row → the deck.
|
||
- **seen** — has any swipe row.
|
||
- **gone** — was `active`, then absent from ≥2 consecutive *good* imports → `status='gone'` (kept forever, swipes preserved; reappearance flips it back to `active`). Single misses and failed/partial imports never mark gone.
|
||
- **dup** — the second province's twin of the same ad (dedupe below) → hidden from the deck, visible under its canonical row in the list.
|
||
- **nope** — never re-enters the deck unless cleared from the list (`DELETE` swipe → back to unswiped).
|
||
- **maybe** — soft skip: the deck query re-admits `maybe` rows after **7 days** (computed at query time — no cron needed). Editing a maybe to like/nope is just an upsert.
|
||
- **like** — pins to the Liked ("matches") view.
|
||
|
||
## Deck query (one statement)
|
||
|
||
```sql
|
||
SELECT * FROM listings
|
||
WHERE status = 'active'
|
||
AND id NOT IN (SELECT listing_id FROM swipes WHERE action = 'nope')
|
||
AND id NOT IN (SELECT listing_id FROM swipes WHERE action IN ('like','maybe')
|
||
AND (action = 'like' OR updated_at > datetime('now','-7 days')))
|
||
ORDER BY score DESC, first_seen DESC
|
||
LIMIT :deck_size;
|
||
```
|
||
|
||
(Implementers: a `LEFT JOIN swipes` variant is equivalent and cheaper — the intent above is the contract. Unscored rows sort below scored ones; nothing is hidden.)
|
||
|
||
## Dedupe (Kijiji cross-province)
|
||
|
||
- Group imports by `(normalized_title, price_num)`; when a group spans both provinces, keep the more-complete row canonical (img present → km present → older row) and mark the twin(s) `status='dup', dup_of=canonical`. The twin's URL is preserved on the canonical row (extra note field / desc suffix).
|
||
- Today's snapshot: 58/320 NS rows are twins of NB rows (dealers cross-post one ad to both provinces).
|
||
- False merges are possible (same title + price, different bike) but rare; every merge is logged in `import_runs` and reversible in the DB. Honest v1 tradeoff.
|
||
|
||
## Import gates (must pass or the run aborts)
|
||
|
||
1. State file `ok == true` and `partial == false`.
|
||
2. `fetched_at` within 26 hours.
|
||
3. Non-empty `items`.
|
||
4. On failure: log, leave the DB untouched (deck stays last-known-good).
|
||
|
||
## Maintenance
|
||
|
||
- **Backup:** nightly `sqlite3 .backup` to `data/backup/` immediately after import (house rule; the DB is the app's only mutable state).
|
||
- **Prune:** mirrored image files for rows `gone` >30 days; rows themselves are never auto-deleted in v1.
|
||
- **VACUUM:** optional; the DB is KV-sized (a few thousand rows).
|
||
|
||
## P2 additions — frozen 2026-11-10 (production build)
|
||
|
||
- **New columns** (guarded, idempotent migrations in `db.init_db`: `ALTER TABLE ... ADD COLUMN` when missing):
|
||
- `listings.year INTEGER` — parsed from title at import (`rubric.year_from_title`); NULL when absent.
|
||
- `listings.seller_type TEXT` — `'dealer'` | `'private'` | NULL. Filled ONLY from a real page signal (the hunt image backfill / a later enrichment pass); never inferred from titles.
|
||
- `listings.extras TEXT` — JSON array of rubric-matched extra labels (`MVI/cert, whitewalls, spokes, windshield, bags, ABS`); UI displays at most 3.
|
||
- **Importer:** fills the three new fields; all P1 behavior unchanged (gates, upsert, dedupe, gone-marking, idempotence, counts 640/560/80).
|
||
- **Deck feed + cursor pagination (production):** semantics unchanged (no swipe, or `maybe` older than 7 days; `active` only); order `score DESC, first_seen DESC, id DESC`. Cursor = opaque `"<score>|<first_seen>|<id>"` of the last returned row; next page = strictly-after in that tuple order (OR-chain on the three keys). `GET /api/deck?limit&cursor` → `{items, next_cursor, remaining}`. Cursor stays correct while swipes delete rows underneath it.
|
||
- **Badge:** `prefs['badge_seen_at']` (ISO). `new` = active rows with `first_seen` > COALESCE(badge_seen_at,'0'); `POST /api/badge/seen` stamps now.
|
||
- **Media mirror:** files at `data/img/{listing_id}.webp`; `GET /img/{id}` → file (200) → `img_url` (302) → 404 (UI placeholder). Downloads via `app/imgfetch.py` (polite: ~1 req/s, skip-existing, resumable). `img_url` enrichment happens upstream in the hunt state (`motorcycle-hunt-img.py`; atomic writes; rescans keep curated imgs) and lands via import.
|
||
- **Dedupe refinement (stretch, targeted):** for cross-province groups with >2 members, replace collapse-to-one with `min(ns,nb)` completeness pairing; surplus siblings stay active. Expected dup ≈ 77 (vs 80). Verify counts and report.
|
||
|
||
## P3 additions — frozen 2026-11-10 (personalization)
|
||
|
||
New tables (guarded, idempotent creation; fresh AND existing DBs migrate in place):
|
||
|
||
```sql
|
||
swipe_events ( -- append-only strike log; the learning source of truth
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
listing_id INTEGER NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
|
||
action TEXT NOT NULL CHECK (action IN ('like','nope','maybe')),
|
||
round INTEGER NOT NULL DEFAULT 1, -- 1 = first verdict; 2+ = second look (engine caps influence at round 2)
|
||
section_id INTEGER, -- section it happened in (NULL for list edits / legacy)
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX idx_events_listing ON swipe_events (listing_id, round);
|
||
CREATE INDEX idx_events_time ON swipe_events (created_at);
|
||
|
||
sections (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','report','closed')),
|
||
size INTEGER NOT NULL,
|
||
profile_version INTEGER, -- taste_profile.version used at compose
|
||
recipe TEXT, -- JSON summary {new, return, wildcard, caps_applied, note}
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
closed_at TEXT
|
||
);
|
||
|
||
section_items (
|
||
section_id INTEGER NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
|
||
listing_id INTEGER NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
|
||
slot TEXT NOT NULL CHECK (slot IN ('new','return','wildcard')),
|
||
position INTEGER NOT NULL,
|
||
PRIMARY KEY (section_id, listing_id)
|
||
);
|
||
|
||
taste_profile (
|
||
version INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
source TEXT NOT NULL CHECK (source IN ('agent','mock','fallback')),
|
||
payload TEXT NOT NULL -- JSON: lead, leanings, changes, chips, adjustments, wildcards
|
||
);
|
||
|
||
agent_runs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
started_at TEXT, finished_at TEXT,
|
||
ok INTEGER NOT NULL DEFAULT 0,
|
||
source TEXT, model TEXT, cost_note TEXT, error TEXT,
|
||
profile_version INTEGER
|
||
);
|
||
```
|
||
|
||
Semantics:
|
||
|
||
- **Learning = `swipe_events` only** (append-only). A cleared swipe keeps its history — clearing removes the *UI verdict*, not the record. **UI truth = `swipes`** (latest verdict / cleared), unchanged from P1–P2.
|
||
- Round = prior events for that listing + 1, assigned at write time. Rounds cap at 2 for engine influence (any later events weigh as round 2; v1 returns never produce rounds >2 by design).
|
||
- **Migration seeding:** when `swipe_events` is empty and `swipes` has rows → insert one round-1 event per swipe row (created_at copied). Only-when-empty, hence idempotent.
|
||
- **Sections:** at most ONE `open` section at a time. The deck = that section's `section_items` in `position` order, filtered to items undecided at read. Exhausted = no item undecided → `status='report'`. Forge = close current + open new. First-ever read composes a cold section (rubric-weighted, w=0).
|
||
- **Returns eligibility (v1 contract):** a verdict exists (like/maybe/nope in `swipes`); listing `active`; NOT in either of the previous two sections; no round≥2 `nope` ever (rested); not already returned once (round cap). Selection prefers maybe > like > nope; nope candidates must be "surprising" (points ≥55 or net-positive family). Max 5 returns per section, ≤2 of them nopes.
|
||
- **`points` is computed, not stored** (deterministic from events + profile; a cache is allowed only if it never changes semantics).
|
||
- **prefs:** `forge_mode` ('ondemand' default | 'strict'). Existing keys unchanged.
|
||
- **Legacy deck semantics:** `/api/deck` (cursor paging; 7-day maybe re-admit) stays as a read-only fallback for debugging; sections are the production flow.
|
||
|
||
## B1 additions — frozen 2026-11-10 (photo experience v2)
|
||
|
||
- **New columns** (guarded, idempotent migrations in `db.init_db`):
|
||
- `listings.imgs TEXT` — JSON array of gallery image URLs (page order, deduped by image id; entry 0 = the established primary). Absent/empty → single-image fallback to `img_url`.
|
||
- `swipes.last_photo_idx INTEGER` — the photo index on screen when the card was struck (set by `POST /api/swipe`'s optional `photo_idx`; NULL when unknown). Drives UI-SPEC §11.5 rotation: a re-shown listing leads at `(idx+1) % n`.
|
||
- **Importer:** carries `imgs` from hunt state (state gains `imgs[]` beside `img`; the hunt image tool fills both in the ONE page fetch it already makes — `img` = established primary, `imgs[0]` = that primary, remainder = gallery in page order).
|
||
- **Media mirror:** `data/img/{id}_{n}.webp` per `imgs[n]` (polite ~1 req/s, skip-existing, resumable — same as P2); `GET /img/{id}?i=n` → mirrored file → CDN 302 → 404. Legacy `/img/{id}` unchanged.
|
||
- **API payload additions:** deck/section items and list rows carry `n_photos` (int) and `last_photo_idx` (int|null); `POST /api/swipe` accepts optional `photo_idx` (int) and persists it.
|
||
- **Rotation is deck-only** (list thumbs + dossier hero = photo 0).
|