Files
Bike-Tinder/docs/DESIGN.md

145 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DESIGN — Bike-Tinder
Architecture for v1 (single source, single user, tailnet-only PWA). Written 2026-11-10 by Alex; engineering input from the Torres consultation (same date). Items marked 【decided】 are locked for v1.
## Components
| piece | what | where |
|---|---|---|
| Crawler | existing `motorcycle-hunt-scan.py` (**unchanged**) — sweeps Kijiji NS+NB nightly at 08:00 | Alex's pipeline (`~/.hermes/scripts/`) |
| Importer | reads the crawler's state JSONs, gates them, upserts into the app DB, scores, dedupes, queues images | `app/importer.py` |
| API | FastAPI + uvicorn — deck, swipes, list, stats, images | `app/api.py` |
| PWA | no-build single-page frontend (deck, list, matches, trends) | `app/static/` |
| DB | SQLite (WAL mode) | `data/bike-tinder.sqlite3` (stack volume) |
## Data flow
```
08:00 motorcycle-hunt crawl (existing, unchanged) → state JSONs: kijiji-ns.json, kijiji-nb.json
08:30 importer — script-only, zero LLM (Hermes cron `bike-tinder-import` → POST /api/import):
gates → upsert → dedupe → score → gone-marking → image queue
→ SQLite (WAL)
Deck phone ⇄ API ⇄ SQLite
↘ images: local mirror first, hotlink (302) fallback
```
## Import design 【decided】
- **Source of truth = the hunt's state files, not a re-crawl.** Rationale: zero duplicate load on Kijiji; inherits the crawler's retry / partial / last-known-good / absence logic instead of re-implementing it; the format is stable and self-describing (`ok`, `partial`, `fetched_at`, per-page counts).
- **Escape hatch:** the app vendors the Kijiji parser from `reference/motorcycle-hunt-scan.py` behind a `--direct` flag — if the hunt pipeline ever moves or dies, the importer can sweep Kijiji itself.
- **Gates (fail loud, keep last good):** each state file must have `ok == true`, `partial == false`, `fetched_at` within 26 h, and non-empty `items`. Any failure → abort that region, log to `import_runs`, leave the DB untouched.
- **Gone marking:** a listing absent from ≥2 consecutive *good* imports flips to `gone` (never deleted; reappearance flips it back). House discipline: a single miss — or any blocked page — never counts as "sold."
- **Dedupe:** cross-province twins (same normalized title + price) collapse to one canonical row (`dup` status), keeping the more complete row (img > km > older). Merges are logged and reversible.
- **Scoring:** rubric tables ported from `reference/motorcycle-hunt-rank.py` (family tiers + extras + veto) — the score is Glenn's accumulated taste, computed at import, stored on each row. See `RANKING.md`.
- **Idempotent:** safe to re-run any time; also exposed as `POST /api/import` for manual triggers.
## Scheduling 【updated 2026-11-10 — Glenn's call】
The nightly import is triggered by its own Hermes cron job — `bike-tinder-import`, **08:30 America/Halifax** (after the 08:00 hunt), run **script-only (zero LLM tokens)** and silent on success. The hunt's own cron is untouched. The container additionally keeps an **on-boot catch-up** (runs once if the last import is >26 h stale). Every path calls the same idempotent importer and writes an `import_runs` row; `GET /api/stats` surfaces the last run. (The cron job is created now, parked; it wakes at first deploy — Phase 4.)
## API surface (v1)
| route | purpose |
|---|---|
| `GET /api/health` | liveness + last-import summary |
| `GET /api/stats` | counts: active / gone / dup / swiped; last import time |
| `GET /api/deck?limit=` | next cards (deck query below) |
| `POST /api/swipe` `{id, action}` | record or edit like / nope / maybe (upsert) |
| `DELETE /api/swipe/{id}` | clear a call (returns the card to the deck) |
| `GET /api/list?filter=&q=&offset=` | full list, filters + search |
| `POST /api/import` | manual import trigger (also the cron hook) |
| `GET /img/{id}` | cached image file, else 302 to source CDN |
**Deck query:** `status='active'`, not noped, and either no swipe or a `maybe` older than 7 days — ordered `score DESC, first_seen DESC`. Unscored rows sort below scored ones but still appear — nothing is hidden.
## Images 【decided: hybrid mirror + hotlink】
- The importer pulls each new listing's card image once into `data/img/` (~100 KB avg; ≈64 MB the first day, then ~20–30 MB/month). Cards reference the local mirror; a missing file falls back to a 302 hotlink of the source CDN (verified token-free and size-rewritable).
- Why: ad removal eventually kills CDN URLs (unverifiable until it happens — the mirror makes it a non-issue), phone-side speed, and "gone" cards stay viewable.
- Bootstrap (frozen 2026-11-10, P2): image URLs are enriched into the hunt state by the hunt toolchain's `motorcycle-hunt-img.py` — one detail-page fetch per listing, polite pacing; rescans keep curated imgs — then the importer carries them into the DB. The app mirrors files via `app/imgfetch.py` → `data/img/{id}.webp`; `/img/{id}` serves the file, else 302 to the source CDN, else the UI placeholder. Backfill scope at freeze: all 620 Kijiji rows missing an image. A placeholder renders for any listing still without a photo; it never holds up the deck. Mirrored files for rows gone >30 days are pruned.
## Frontend
No-build SPA — vanilla JS + CSS, pointer-events drag, keyboard arrows on desktop (~300 lines). A small service worker caches the shell; the API is always live. Screens, gestures, and visual anatomy: see `UI-SPEC.md`.
## Personalization architecture (Phase 3) — frozen 2026-11-10
| piece | what | where |
|---|---|---|
| Taste engine | deterministic points + breakdown from `swipe_events` + rubric | `app/taste.py` |
| Section engine | compose/forge/read sections, returns, wildcards, modes | `app/sections.py` |
| Agent pass | the ONLY LLM step — reads strikes, writes the profile; never in a request path | `app/agentpass.py` |
**The law:** no LLM in the swipe path, ever. The deck renders and the engine scores with zero network; the agent pass runs between sections (forge) or on schedule (P4 nightly), and can fail without breaking anything — compose falls back to the last profile.
**Forge flow (on-demand):** section exhausted → `POST /api/section/forge` → agent pass (mock/fallback when unconfigured; live when the key exists) writes `taste_profile` → section engine composes the next 25. **Strict mode:** forge refuses (409) until a fresh pass exists post-exhaustion — the nightly cron provides it at P4.
**API additions:** `GET /api/section` · `POST /api/section/forge` · `GET /api/trends` · `POST /api/agent/run`. Listing payloads gain `points`, `points_breakdown`, `tank`, `slot`, `round_next` (see UI-SPEC §10 and DATA-MODEL P3).
**Cost & keys:** each pass ≈ pennies (one compact prompt; house model default via `OPENROUTER_API_KEY`). The key lives in the host/stack env only — never the repo, never logs. "Pause" stops passes with all other spend.
## Deployment (house constraints)
- **Stack dir:** `/var/mnt/DATA/podman-stacks/bike-tinder/` — `docker-compose.yml` + `Dockerfile` (`python:3.14-slim` + fastapi/uvicorn/requests), volume `./data:/data`, `restart: unless-stopped`.
- **App networking:** the app binds plain HTTP on the compose network and publishes **localhost-only** (`127.0.0.1:9453:8000`) — that's how the nightly trigger and local debugging reach it. No LAN exposure.
- **Tailscale sidecar — baked into the stack** (the repo carries **no host-tailnet coupling**; the app itself knows nothing about Tailscale):
- a `tailscale/tailscale` container joins the tailnet as its own node and serves the app at **`https://biketinder.tailf35a34.ts.net`** (TCP 443 via `tailscale serve` → `http://app:8000` over the compose network);
- auth: a deploy-time auth key minted by Glenn, stored only in the stack's local `.env` — never committed, never pasted anywhere else;
- deploy-time checks: the tailnet ACL must allow Glenn's devices to reach the `biketinder` node; first verification happens from his phone (the tailnet name can't be curled from this host).
- After adding files: `chcon -Rt container_file_t /var/mnt/DATA/podman-stacks/bike-tinder`.
- **Systemd user unit** `podman-stack-bike-tinder.service`, cloned from the machine's `podman-stack-searxng.service` pattern: `Type=oneshot`, `RemainAfterExit=yes`, **`Delegate=yes`** (keeps the stack out of the hermes-gateway cgroup so gateway restarts can't SIGKILL it); ExecStart/ExecStop = the venv `podman-compose up -d` / `down`.
- **Backups:** nightly `sqlite3 .backup` into `data/backup/` right after import (the DB is the only mutable state; house rule). Restore = stop stack, swap the file, start.
## Security
Single user; **no auth in v1** — the app is reached only through the stack's own Tailscale node (plus a localhost-only port for triggers and debugging). An optional `AUTH_TOKEN` env var is noted as a one-line later hardening. One deploy-time secret exists in the stack: the Tailscale auth key (local `.env` only).
## Risks & mitigations (from consultation)
| risk | mitigation |
|---|---|
| Kijiji markup drift | parser keys on `data-testid`; failures → gates abort, deck stays last-known-good; crawler's fail/partial tracking monitors |
| Anti-bot on deep sweeps | ship at the proven 8-page window; grow only after two clean weeks |
| Image persistence after ad removal | local mirror (built-in) |
| Cross-province dedupe false merges | logged, reversible, low blast radius; canonical keeps the more complete row |
| Hunt pipeline dependency | `fetched_at` gate fails loud; vendored `--direct` parser escape hatch |
| Dealer blast dominance in score ties | tie-break `first_seen DESC`; "hide this seller" planned as a v1.1 prefs row |
| Rubric blindness (no family match) | rows still appear, sorted below scored ones — nothing hidden |
## Repo layout (target)
```
app/ importer.py · api.py · imgfetch.py · static/{index.html (single-file app), manifest.json, sw.js, icons/}
deploy/ docker-compose.yml (app + Tailscale sidecar) · Dockerfile · podman-stack-bike-tinder.service
reference/ copies of the hunt modules the app vendors from
data/ runtime state (gitignored)
docs/ this design set
```
## Runbook (Phase 4)
**Stack home:** `/var/mnt/DATA/podman-stacks/bike-tinder/` — a checkout of the `release` branch, plus stack-local `data/` (DB + image mirror + backups), `.env` (secrets, never committed) and `tailscale-state/` (node identity). Unit: `podman-stack-bike-tinder.service` (installed from `deploy/`).
**Deploy / update a cut:**
```sh
# from the repo: merge dev -> main, then fast-forward release
git -C /var/mnt/DATA/podman-stacks/bike-tinder pull --ff-only
podman-compose -f deploy/docker-compose.yml --env-file .env build
systemctl --user restart podman-stack-bike-tinder.service
```
Docs-only cuts stop after the `pull`. The unit is `oneshot` + `RemainAfterExit` with `Delegate=yes`, so the stack survives `hermes gateway restart` and reboots. The historical dev service (`bike-tinder-dev.service`) is retired at deploy; rollback happens through the stack, not that unit.
**Rollback:** check out the previous release SHA in the stack dir, rebuild, restart. The DB is never touched by code rollbacks (migrations are guarded and idempotent); if a migration ever needs unwinding, restore the last backup (below).
**Logs:** `podman logs bike-tinder-app`, `podman logs bike-tinder-ts`, `journalctl --user -u podman-stack-bike-tinder.service`.
**Import troubleshooting:** `POST /api/import` refuses when the hunt state is older than 26 h or missing — check `~/.hermes/scripts/motorcycle-hunt.d/*.json`. The nightly chain is the Hermes cron job `bike-tinder-import` (08:30 Atlantic: import → agent read → DB backup); it is silent on success by design — inspect its last run in the cron list if a morning looks stale. The container also runs an on-boot catch-up import when the last run is >26 h old.
**Backups:** nightly `data/backup/bike-tinder-YYYYMMDD-HHMM.sqlite3`, last 14 kept. Restore drill: stop the unit → remove `data/bike-tinder.sqlite3-wal`/`-shm` → copy the chosen backup over `data/bike-tinder.sqlite3` → start the unit → check `GET /api/stats`.
**Phone checklist:** open `https://biketinder.tailf35a34.ts.net` from a tailnet device → expect the deck; Add to Home Screen (PWA installs via the manifest); swipe once and confirm the decision shows in List. If the URL fails: check the sidecar (`podman logs bike-tinder-ts`), confirm `TS_AUTHKEY` in `.env`, and re-run `podman-compose -f deploy/docker-compose.yml --env-file .env up -d`.
**First-run sidecar (one-time):** mint a Tailscale auth key (admin console → Settings → Keys), put `TS_AUTHKEY=<key>` into the stack `.env`, then `podman-compose -f deploy/docker-compose.yml --env-file .env up -d tailscale`.