Merge branch 'dev'

This commit is contained in:
Torres
2026-11-11 00:34:44 -04:00
19 changed files with 4191 additions and 111 deletions

View File

@@ -2,7 +2,9 @@
A private, Tinder-style web app for browsing the motorcycle market: swipe real listings (like / nope / maybe), see everything on one editable list, and let the deck learn your taste. Built for Glenn; runs on the Rotarran, published to the tailnet only.
**Status:** Phase 2 (production build) — FastAPI backend + no-build PWA shipped on `dev` (2026-11-10): migrations, API with cursor-paginated deck, swipe persistence, real list/matches screens, image mirror, PWA shell. Deployment (podman stack) is Phase 4 — not started.
**Status:** Phase 3 (personalization) built on `dev` (2026-11-11): sections of 25 (wildcards + second looks + new), taste engine with match points, tank gauges, dossier "Why this rank", real Trends tab, agent pass (mock / live / fallback). Phase 2 remains shipped as-is; deployment (podman stack) is Phase 4 — not started.
**B1 (photo experience v2)** built on `dev`: full photo galleries mirrored locally, deck flips (tap left/right thirds, clamped), segment bars, a fullscreen viewer (swipe nav, pinch/double-tap zoom, pull-down close), and repeat rotation (a re-shown card leads at its next photo). See UI-SPEC §11 + the "B1 develop notes" section below.
## How it works
@@ -52,20 +54,83 @@ python3 -m app.importer --state-dir ~/.hermes/scripts/motorcycle-hunt.d --db dat
# serve the app (127.0.0.1:9453 by default)
.venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453
# self-check: 8 P1 spine checks + 6 P2 API checks (on a temp DB copy)
# self-check: P1 + P2 + P3 (25 checks, temp DB copies only)
.venv/bin/python -m app.selfcheck
# P3: agent pass — mock is deterministic + offline (tests, offline forge);
# --live needs OPENROUTER_API_KEY (+ optional BT_AGENT_MODEL); --dry-run
# validates the payload and writes nothing; --print-summary prints exactly
# the strike summary a live pass would be sent.
.venv/bin/python -m app.agentpass --db /tmp/bt-copy.sqlite3 --mock
.venv/bin/python -m app.agentpass --db /tmp/bt-copy.sqlite3 --print-summary --dry-run
# P3: run a scratch server against a COPY (NEVER point a dev server at
# data/bike-tinder.sqlite3 while the live service owns it — use port 9454).
cp data/bike-tinder.sqlite3 /tmp/bt-copy.sqlite3
.venv/bin/python -m app.serve --db /tmp/bt-copy.sqlite3 --img-dir data/img --port 9454
curl -s http://127.0.0.1:9454/api/section # first read composes a section
curl -s -X POST http://127.0.0.1:9454/api/section/forge \
-H 'Content-Type: application/json' -d '{"size": 8}' # mock pass + compose
# proof CLI: ranked deck + counts (read-only)
python3 -m app.deck --db data/bike-tinder.sqlite3 --top 15
```
Key routes: `/` (PWA), `/api/health`, `/api/stats`, `/api/deck?limit=&cursor=`,
`POST /api/swipe` / `DELETE /api/swipe/{id}`, `/api/list?decision=&sort=&q=&limit=&offset=`,
Key routes: `/` (PWA), `/api/health`, `/api/stats`, `/api/deck?limit=&cursor=` (read-only
legacy fallback), `POST /api/swipe` / `DELETE /api/swipe/{id}`, `/api/list?decision=&sort=&q=&limit=&offset=`,
`/api/listing/{id}`, `POST /api/import`, `POST /api/badge/seen`, `/img/{id}`
(cached file → 302 source CDN → 404 placeholder). Full contract: `docs/UI-SPEC.md` + the task brief.
(cached file → 302 source CDN → 404 placeholder).
P3 (Phase 3): `GET /api/section`, `POST /api/section/forge` `{size}`,
`GET /api/trends`, `POST /api/agent/run` `{source: mock|live}`,
`POST /api/prefs` `{key: forge_mode, value: ondemand|strict}`. Listing
payloads carry `points`, `tank {value, band}`, `slot`, `round_next`,
`points_breakdown`. Full contract: `docs/UI-SPEC.md` §10 + `docs/RANKING.md`
Stage 2 + the task brief.
`data/` and `.venv/` are gitignored — the DB, image mirror and venv are local state.
## B1 develop notes (photo experience v2)
- **Columns** (guarded in `db.init_db`): `listings.imgs TEXT` (JSON array of
gallery URLs, page order; entry 0 = the primary = `img_url`) ·
`swipes.last_photo_idx INTEGER` (photo on screen at swipe time; drives §11.5
rotation `(last+1) % n`; NULL = photo 0).
- **Importer** copies the hunt state's `imgs[]` into `listings.imgs`; `img_url`
stays the state `img` and falls back to `imgs[0]`. Re-imports are
byte-stable (selfcheck proves it on the 640-row copy).
- **Route:** `GET /img/{id}?i=n` — mirrored file → CDN 302 → 404. `i=0`
(default) keeps the legacy chain (`{id}.webp`, then `{id}_0.webp`, then
`img_url`); `i>0` serves `{id}_{n}.webp` with the CDN fallback from
`imgs[n]`. Out-of-range/unknown → 404.
- **Mirror:** `app/imgfetch.py` downloads every `imgs[n]`. The primary (n=0)
lands at the legacy `data/img/{id}.webp` (one canonical file — no duplicate
fetch); extras land at `data/img/{id}_{n}.webp`. Same politeness as P2
(~1 req/s, skip-existing, `.part`-atomic, per-file CLI progress).
- **Payload:** deck/section/list/listing items add `n_photos` (int) and
`last_photo_idx` (int|null). `POST /api/swipe` accepts optional `photo_idx`
(non-negative int; persisted; absent key preserves, explicit null clears).
- **Frontend:** tap LEFT/RIGHT third = photo flip (clamped, no wrap, commits
nothing), CENTER third = fullscreen viewer (ⓘ still wins); segment bars per
photo (Tinder-style; band chips drop to `top:22px` when segments exist);
viewer = swipe nav + pinch 1×–4× + double-tap 1×↔2.5× + ✕/pull-down close
(returns to the same card/photo); re-shown cards lead at `(last+1) % n`;
preloads the top card's next photo + next card's lead; `sw.js` cache is
`bike-tinder-v3`.
- **FE walk (evidence):** `app/fe_walk_b1.py` — deterministic, headless:
```sh
.venv/bin/python -m app.fe_walk_b1 setup # fixtures: copy DB + 701-706 synthetics + local photos
env -u OPENROUTER_API_KEY .venv/bin/python -m app.serve \
--db /var/home/admin/dev/bike-tinder-b1/test-walk.sqlite3 \
--img-dir /var/home/admin/dev/bike-tinder-b1/test-img \
--host 127.0.0.1 --port 9454 # scratch server (mock forge)
.venv/bin/python -m app.fe_walk_b1 # 390 DOM checks
```
- **Test data:** `b1/test-state/` = hunt-state sample with `imgs` (8/3-photo
galleries; refresh `fetched_at` before an import gate test) ·
`b1/test-img/` = staged `{id}_{n}.webp` photos · `b1/test-copy.sqlite3` =
pristine live-DB snapshot (selfcheck copies it; never write to it).
## Deployment (planned)
A small podman compose stack on the Rotarran under `/var/mnt/DATA/podman-stacks/bike-tinder/`, its own systemd user unit (`Delegate=yes`), SQLite database. The stack ships its own Tailscale node (sidecar container — no host-tailnet coupling) and serves the app privately at `https://biketinder.tailf35a34.ts.net`. Single user; reachable only from Glenn's own devices.

501
app/agentpass.py Normal file
View File

@@ -0,0 +1,501 @@
"""Bike-Tinder P3 agent pass — the ONLY LLM step (docs/DESIGN.md).
Reads a compact, deterministic strike summary and writes ONE taste_profile
row (payload schema {lead, leanings, changes, chips, adjustments,
wildcards}) plus ONE agent_runs observability row. Never part of any
request path; the deck never waits on it.
Sources:
mock — deterministic payload synthesis from the strike data; zero
network; used by tests and offline forge.
live — OpenRouter chat completion (requests; env OPENROUTER_API_KEY,
BT_AGENT_MODEL; timeout + one retry; malformed -> fallback).
The key is never logged or printed.
fallback — deterministic synthesis again (source-labeled 'fallback').
CLI:
.venv/bin/python -m app.agentpass --db PATH [--mock|--live] [--model M]
[--print-summary] [--dry-run]
"""
import argparse
import datetime
import json
import os
import sys
from . import db
from . import sections
from . import taste as tastemod
DEFAULT_MODEL = 'openrouter/auto'
API_URL = 'https://openrouter.ai/api/v1/chat/completions'
TIMEOUT = 45
FEATURE_DIMS = tastemod.FEATURE_DIMS
SYSTEM_PROMPT = (
"You are the taste-profile writer for a single-user motorcycle-swipe "
"app. Reply with ONLY strict JSON, no markdown, matching exactly:\n"
'{"lead": "<one line>", "leanings": ["<up to 8 bullets>"], '
'"changes": ["<since last read, may be empty>"], '
'"chips": ["<up to 6 short explanations>"], '
'"adjustments": [{"feature": "<'+ '|'.join(FEATURE_DIMS) +'>", '
'"value": "<feature value>", "delta": <number -3..3>, '
'"why": "<short reason>"}], '
'"wildcards": {"families": ["<family label>"], '
'"price_bands": ["<band>"]}}'
"Adjustments nudge listing scores; keep them small and few. "
"Never invent feature values that are not in the data."
)
def now_iso():
return datetime.datetime.now(datetime.timezone.utc).strftime(
'%Y-%m-%dT%H:%M:%SZ')
def _now_dt():
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
# --- strike summary (deterministic: a pure function of DB rows) --------------
def build_summary(conn, now=None):
"""Compact text summary of the strikes — exactly what the live pass
is sent. Deterministic for identical DB content (no wall clock)."""
now = now or _now_dt()
engine = sections.build_engine(conn, now=now)
lines = []
counts = {r['action']: r['n'] for r in conn.execute(
'SELECT action, COUNT(*) AS n FROM swipes GROUP BY action')}
decided = sum(counts.values())
lines.append('decided=%d like=%d maybe=%d nope=%d events=%d' % (
decided, counts.get('like', 0), counts.get('maybe', 0),
counts.get('nope', 0), engine.n_events))
prof = sections.latest_profile(conn)
if prof:
lines.append('profile_version=%d source=%s' % (prof['version'],
prof['source']))
# families: action counts + net weight
fam_rows = {}
for ev in engine.events:
row = engine.by_id.get(ev['listing_id'])
fam = row['family'] if row else None
if fam:
rec = fam_rows.setdefault(fam, {'like': 0, 'maybe': 0, 'nope': 0,
'net': 0.0})
rec[ev['action']] = rec.get(ev['action'], 0) + 1
rec['net'] += tastemod.event_weight(ev['action'], ev['round'],
ev['created_at'], now)
lines.append('families:')
if fam_rows:
for fam, rec in sorted(fam_rows.items(),
key=lambda kv: -abs(kv[1]['net']))[:10]:
lines.append('- %s | like=%d maybe=%d nope=%d | net=%.2f' % (
tasname(fam), rec['like'], rec['maybe'], rec['nope'],
rec['net']))
else:
lines.append('- (none matched yet)')
# feature cells (non-family) with verdicts
lines.append('signals:')
cells = {}
for ev in engine.events:
for dim, val in engine.cells.get(ev['listing_id'], []):
if dim == 'family':
continue
rec = cells.setdefault((dim, val), {'n': 0, 'w': 0.0})
rec['n'] += 1
rec['w'] += tastemod.event_weight(ev['action'], ev['round'],
ev['created_at'], now)
for (dim, val), rec in sorted(cells.items(),
key=lambda kv: -abs(kv[1]['w']))[:10]:
wtxt = '%.2f' % rec['w']
lines.append('- %s %s | n=%d | w=%s' % (dim, val, rec['n'], wtxt))
# recent strikes (chronological tail)
recent = conn.execute(
"""SELECT e.action, e.round, e.created_at, l.title, l.family,
e.section_id
FROM swipe_events e JOIN listings l ON l.id = e.listing_id
ORDER BY e.id DESC LIMIT 10""").fetchall()
lines.append('recent:')
for r in reversed(recent):
lines.append('- %s r%d %s | %s%s' % (
r['action'], min(r['round'] or 1, 2), r['created_at'],
(r['title'] or '')[:60], (' | %s' % tasname(r['family']))
if r['family'] else ''))
# liked exemplars
liked = conn.execute(
"""SELECT l.title, l.family, l.price_num, l.year
FROM swipes s JOIN listings l ON l.id = s.listing_id
WHERE s.action='like' ORDER BY s.updated_at DESC LIMIT 5"""
).fetchall()
lines.append('liked:' if liked else 'liked: (none yet)')
for r in liked:
lines.append('- %s | %s | %s | %s' % (
(r['title'] or '')[:60], tasname(r['family']), r['price_num'],
r['year']))
# returns / wildcard engagement so far
restruck = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
WHERE si.slot='return'""").fetchone()['n']
wild_hits = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
WHERE si.slot='wildcard' AND e.action IN ('like','maybe')"""
).fetchone()['n']
lines.append('second_looks_re_struck=%d wildcards_hit=%d' % (restruck,
wild_hits))
return '\n'.join(lines)
def tasname(label):
"""Short family name for the summary (trim trailing rationale)."""
return (label or '').split(' — ')[0].strip()
# --- deterministic payload synthesis (mock + fallback share this) ------------
def synthesize_payload(conn, now=None):
"""Deterministic {lead, leanings, changes, chips, adjustments,
wildcards} derived purely from the strike data. No randomness, no
network."""
now = now or _now_dt()
engine = sections.build_engine(conn, now=now)
counts = {r['action']: r['n'] for r in conn.execute(
'SELECT action, COUNT(*) AS n FROM swipes GROUP BY action')}
decided = sum(counts.values())
likes = counts.get('like', 0)
cells = {} # (dim, val) -> {n, w}
for ev in engine.events:
for dim, val in engine.cells.get(ev['listing_id'], []):
rec = cells.setdefault((dim, val), {'n': 0, 'w': 0.0})
rec['n'] += 1
rec['w'] += tastemod.event_weight(ev['action'], ev['round'],
ev['created_at'], now)
ranked = sorted(cells.items(), key=lambda kv: -abs(kv[1]['w']))
pos = [(d, v, r) for (d, v), r in ranked if r['w'] > 0][:4]
neg = [(d, v, r) for (d, v), r in ranked if r['w'] < 0][:4]
def lab(dim, val):
return tasname(val) if dim == 'family' else ('%s %s' % (dim, val))
# lead
if not engine.events:
lead = 'Cold start — rubric order until the first strikes land.'
elif likes and pos:
d, v, r = pos[0]
lead = ('You favor %s — %d of your likes cluster there.'
% (lab(d, v), r['n']))
elif pos:
d, v, r = pos[0]
lead = ('Leaning toward %s (%d strike%s).'
% (lab(d, v), r['n'], 's' if r['n'] != 1 else ''))
else:
lead = 'No strong signal yet — keep swiping.'
# leanings (<=8)
leanings = []
for d, v, r in pos:
leanings.append('%s pulls you in (+%.1f over %d strike%s)' % (
lab(d, v), r['w'], r['n'], 's' if r['n'] != 1 else ''))
for d, v, r in neg:
leanings.append('%s reads as a miss (%+.1f over %d strike%s)' % (
lab(d, v), r['w'], r['n'], 's' if r['n'] != 1 else ''))
leanings = leanings[:8]
if not leanings and engine.events:
leanings = ['No feature clusters yet — the ramp keeps the rubric '
'in charge.']
# changes (since the previous profile version)
prof = sections.latest_profile(conn)
if prof is None:
changes = ['first read — %d strikes so far' % engine.n_events]
else:
try:
prev_dt = tastemod.parse_ts(prof['created_at'], now)
except Exception: # noqa: BLE001
prev_dt = None
if prev_dt is None:
changes = ['unable to diff against the previous read']
else:
new_n = sum(1 for ev in engine.events
if (tastemod.parse_ts(ev['created_at'], now) or now)
> prev_dt)
if new_n:
deltas = []
for (d, v), r in ranked[:3]:
sub = 0.0
for ev in engine.events:
dt = tastemod.parse_ts(ev['created_at'], now)
if dt is not None and dt > prev_dt and (
d, v) in engine.cells.get(
ev['listing_id'], []):
sub += tastemod.event_weight(
ev['action'], ev['round'],
ev['created_at'], now)
if abs(sub) >= 0.05:
deltas.append('%s %+.1f' % (lab(d, v), sub))
head = '%d new strike%s since the last read' % (
new_n, 's' if new_n != 1 else '')
changes = [head + ('; ' + ', '.join(deltas)
if deltas else '')]
else:
changes = ['no new strikes since the last read']
# chips (up to 6 short explanations)
chips = [lab(d, v) for d, v, r in pos[:6]]
if not chips:
chips = ['rubric fit']
# adjustments (small deterministic nudges on the top cells)
adjustments = []
for (d, v), r in ranked[:8]:
if abs(r['w']) < 0.5:
continue
delta = max(-3.0, min(3.0, round(r['w'] / 2.0, 1)))
adjustments.append({
'feature': d, 'value': v, 'delta': delta,
'why': 'you %s %s %d time%s' % (
'favored' if r['w'] > 0 else 'passed on', lab(d, v), r['n'],
's' if r['n'] != 1 else ''),
})
# wildcard hints: families without hard nopes, off the beaten likes
liked_fams = {ev['listing_id'] for ev in engine.events
if ev['action'] == 'like'}
fam_nopes_w = {}
for ev in engine.events:
row = engine.by_id.get(ev['listing_id'])
if not row or not row['family']:
continue
rec = fam_nopes_w.setdefault(row['family'], {'nopes': 0, 'w': 0.0})
if ev['action'] == 'nope':
rec['nopes'] += 1
rec['w'] += tastemod.event_weight(ev['action'], ev['round'],
ev['created_at'], now)
fam_stretch = [f for f, r in fam_nopes_w.items()
if r['nopes'] <= 1 and r['w'] >= 0]
fam_stretch = sorted(fam_stretch, key=lambda f: -fam_nopes_w[f]['w'])[:3]
band_rows = [(tastemod.price_band(row['price_num']), ev['action'])
for ev in engine.events
if (row := engine.by_id.get(ev['listing_id'])) is not None]
band_counts = {}
for b, a in band_rows:
if b:
rec = band_counts.setdefault(b, {'nopes': 0, 'n': 0})
rec['n'] += 1
rec['nopes'] += 1 if a == 'nope' else 0
band_stretch = sorted(
(b for b, r in band_counts.items() if r['nopes'] == 0),
key=lambda b: band_counts[b]['n'])[:3]
wildcards = {'families': fam_stretch, 'price_bands': band_stretch}
return {'lead': lead, 'leanings': leanings, 'changes': changes,
'chips': chips, 'adjustments': adjustments,
'wildcards': wildcards}
# --- validation --------------------------------------------------------------
def validate_payload(payload):
"""Raise ValueError on schema violations."""
if not isinstance(payload, dict):
raise ValueError('payload must be an object')
for key in ('lead', 'leanings', 'changes', 'chips', 'adjustments',
'wildcards'):
if key not in payload:
raise ValueError('missing key: %s' % key)
if not isinstance(payload['lead'], str):
raise ValueError('lead must be a string')
for key in ('leanings', 'changes', 'chips'):
if (not isinstance(payload[key], list)
or not all(isinstance(x, str) for x in payload[key])):
raise ValueError('%s must be a list of strings' % key)
for adj in payload['adjustments']:
if not isinstance(adj, dict):
raise ValueError('adjustment must be an object')
if adj.get('feature') not in FEATURE_DIMS:
raise ValueError('bad adjustment feature: %r' % adj.get('feature'))
if not isinstance(adj.get('delta'), (int, float)):
raise ValueError('adjustment delta must be numeric')
wc = payload['wildcards']
if not isinstance(wc, dict):
raise ValueError('wildcards must be an object')
if (not isinstance(wc.get('families', []), list)
or not isinstance(wc.get('price_bands', []), list)):
raise ValueError('wildcard hints must be lists')
return payload
def _extract_json(text):
"""Pull the first JSON object out of a model reply (fence-tolerant)."""
text = (text or '').strip()
if text.startswith('```'):
lines = text.splitlines()
while lines and lines[0].startswith('```'):
lines = lines[1:]
while lines and lines[-1].startswith('```'):
lines = lines[:-1]
text = '\n'.join(lines).strip()
start = text.find('{')
end = text.rfind('}')
if start < 0 or end <= start:
raise ValueError('no JSON object in reply')
return json.loads(text[start:end + 1])
# --- the pass -----------------------------------------------------------------
def run_pass(db_path, source='mock', model=None, dry_run=False,
print_summary=False, out=sys.stdout):
"""Run one agent pass. Returns a result dict; never raises for live
failures (falls back to a deterministic profile, source='fallback')."""
started = now_iso()
conn = db.connect(db_path)
db.init_db(conn)
try:
now = _now_dt()
summary = build_summary(conn, now=now)
if print_summary:
out.write(summary + '\n')
model = model or os.environ.get('BT_AGENT_MODEL') or DEFAULT_MODEL
if source == 'mock':
payload = synthesize_payload(conn, now=now)
profile_source = 'mock'
error = None
cost_note = None
else: # live
key = os.environ.get('OPENROUTER_API_KEY')
if not key:
payload = synthesize_payload(conn, now=now)
profile_source = 'fallback'
error = 'OPENROUTER_API_KEY not configured'
cost_note = None
else:
payload, profile_source, error, cost_note = _live_call(
model, summary)
if payload is None:
payload = synthesize_payload(conn, now=now)
profile_source = 'fallback'
validate_payload(payload)
if dry_run:
return {'ok': True, 'dry_run': True, 'source': profile_source,
'model': model, 'payload': payload, 'summary': summary,
'error': error}
cur = conn.execute(
"""INSERT INTO taste_profile (created_at, source, payload)
VALUES (?, ?, ?)""",
(started, profile_source, json.dumps(payload)))
version = cur.lastrowid
finished = now_iso()
conn.execute(
"""INSERT INTO agent_runs (started_at, finished_at, ok, source,
model, cost_note, error,
profile_version)
VALUES (?,?,?,?,?,?,?,?)""",
(started, finished, 1 if error is None else 0, profile_source,
model, cost_note, (error or '')[:500], version))
conn.commit()
return {'ok': error is None, 'source': profile_source,
'profile_version': version, 'model': model,
'payload': payload, 'error': error, 'cost_note': cost_note}
finally:
conn.close()
def _live_call(model, summary):
"""OpenRouter call with one retry; returns (payload|None, source,
error, cost_note). Never leaks the key into error text or logs."""
import requests
key = os.environ['OPENROUTER_API_KEY']
body = {
'model': model,
'messages': [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': summary},
],
'temperature': 0,
}
headers = {'Authorization': 'Bearer %s' % key,
'Content-Type': 'application/json'}
last_err = None
for attempt in (1, 2):
try:
r = requests.post(API_URL, headers=headers, json=body,
timeout=TIMEOUT)
if r.status_code == 200:
data = r.json()
text = (data.get('choices') or [{}])[0].get(
'message', {}).get('content', '')
try:
payload = validate_payload(_extract_json(text))
cost_note = _cost_note(data)
return payload, 'agent', None, cost_note
except (ValueError, json.JSONDecodeError) as e:
return None, None, 'malformed reply: %s' % str(e)[:300], None
last_err = 'HTTP %d: %s' % (r.status_code, r.text[:200])
except requests.exceptions.RequestException as e:
last_err = 'network error: %s' % type(e).__name__
return None, None, (last_err or 'live call failed')[:500], None
def _cost_note(data):
usage = data.get('usage') or {}
cost = usage.get('cost')
note = 'tokens_in=%s tokens_out=%s' % (
usage.get('prompt_tokens'), usage.get('completion_tokens'))
if cost is not None:
note += ' cost=$%s' % cost
return note
def main(argv=None):
ap = argparse.ArgumentParser(description='Bike-Tinder P3 agent pass')
ap.add_argument('--db', default='data/bike-tinder.sqlite3')
src = ap.add_mutually_exclusive_group()
src.add_argument('--mock', action='store_true',
help='deterministic offline pass (default)')
src.add_argument('--live', action='store_true',
help='OpenRouter pass (needs OPENROUTER_API_KEY)')
ap.add_argument('--model', default=None,
help='model override (env BT_AGENT_MODEL, else default)')
ap.add_argument('--print-summary', action='store_true',
help='print exactly the text the live pass would send')
ap.add_argument('--dry-run', action='store_true',
help='validate + print, write nothing')
args = ap.parse_args(argv)
result = run_pass(args.db, source='live' if args.live else 'mock',
model=args.model, dry_run=args.dry_run,
print_summary=args.print_summary)
if args.dry_run:
print(json.dumps(result['payload'], indent=2))
print('dry-run ok; would write source=%s (model=%s)' % (
result['source'], result['model']))
if result.get('error'):
print('note: %s' % result['error'])
return 0
if result['ok']:
print('pass ok: source=%s version=%d model=%s%s' % (
result['source'], result['profile_version'], result['model'],
' (%s)' % result['cost_note'] if result.get('cost_note') else ''))
return 0
print('pass fell back: source=fallback version=%d error=%s' % (
result['profile_version'], result['error']))
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -12,15 +12,21 @@ on; frozen in the task brief):
url, img ("/img/{id}" if img_url or cached file exists else null), desc,
family, score, year, seller_type, extras (array of strings),
decision (null|'like'|'maybe'|'nope'), first_seen}
B1 additions (UI-SPEC §11 / DATA-MODEL "B1 additions"):
n_photos (int), last_photo_idx (int|null); GET /img/{id}?i=n serves
gallery photo n.
"""
import datetime
import json
import os
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from . import agentpass as agmod
from . import db as dbmod
from . import importer as imp
from . import sections as secmod
ACTIONS = ('like', 'nope', 'maybe')
@@ -39,7 +45,7 @@ DECK_WHERE = (
)
DECK_SELECT = (
"SELECT l.*, s.action AS decision"
"SELECT l.*, s.action AS decision, s.last_photo_idx AS last_photo_idx"
" FROM listings l LEFT JOIN swipes s ON s.listing_id = l.id"
" WHERE " + DECK_WHERE
)
@@ -73,6 +79,14 @@ def get_conn(app):
return conn
def utcnow():
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
def live_configured():
return bool(os.environ.get('OPENROUTER_API_KEY'))
def set_pref(conn, key, value):
conn.execute(
"INSERT INTO prefs (key, value) VALUES (?, ?) "
@@ -117,10 +131,21 @@ def count_deck_after(app, score, first_seen, listing_id):
def build_payload(img_dir):
def _payload(conn, row):
img = None
if row['img_url'] or os.path.exists(
os.path.join(img_dir, '%d.webp' % row['id'])):
img = '/img/%d' % row['id']
leg = os.path.join(img_dir, '%d.webp' % row['id'])
b1 = os.path.join(img_dir, '%d_0.webp' % row['id'])
has_photo = (bool(row['img_url']) or os.path.exists(leg)
or os.path.exists(b1))
img = '/img/%d' % row['id'] if has_photo else None
# B1: gallery size + swipe-time photo index (UI-SPEC §11 / DATA-MODEL B1)
imgs = []
if isinstance(row['imgs'], str) and row['imgs']:
try:
imgs = json.loads(row['imgs'])
except ValueError:
imgs = []
if not isinstance(imgs, list):
imgs = []
n_photos = len(imgs) if imgs else (1 if has_photo else 0)
extras = row['extras']
if isinstance(extras, str):
try:
@@ -137,6 +162,9 @@ def build_payload(img_dir):
'region': row['region'],
'url': row['url'],
'img': img,
'n_photos': n_photos,
'last_photo_idx': (row['last_photo_idx']
if 'last_photo_idx' in row.keys() else None),
'desc': row['desc'],
'family': row['family'],
'score': row['score'],
@@ -149,6 +177,23 @@ def build_payload(img_dir):
return _payload
# --- P3 listing payload (additive over the P2 contract) --------------------
def build_p3_payload(img_dir):
base = build_payload(img_dir)
def _payload(conn, engine, row, slot=None, include_breakdown=False):
p = base(conn, row)
p['points'] = engine.points(row['id'])
p['tank'] = engine.tank(row['id'])
p['slot'] = slot
p['round_next'] = engine.round_next(row['id'])
p['points_breakdown'] = (engine.breakdown(row['id'])
if include_breakdown else None)
return p
return _payload
# --- app factory ----------------------------------------------------------
def create_app(db_path, img_dir, state_dir=None):
@@ -166,6 +211,7 @@ def create_app(db_path, img_dir, state_dir=None):
app.state.static_dir = static_dir
app.state.icons_dir = icons_dir
payload = build_payload(app.state.img_dir)
p3_payload = build_p3_payload(app.state.img_dir)
# -- meta -----------------------------------------------------------
@@ -225,6 +271,7 @@ def create_app(db_path, img_dir, state_dir=None):
cur = parse_cursor(cursor)
conn = get_conn(app)
try:
engine = secmod.build_engine(conn, now=utcnow())
if cur is None:
rows = conn.execute(DECK_SELECT + DECK_ORDER + ' LIMIT ?',
(limit,)).fetchall()
@@ -232,7 +279,7 @@ def create_app(db_path, img_dir, state_dir=None):
rows = conn.execute(
DECK_SELECT + cursor_clause() + DECK_ORDER + ' LIMIT ?',
(cur[0], cur[0], cur[1], cur[1], cur[2], limit)).fetchall()
items = [payload(conn, r) for r in rows]
items = [p3_payload(conn, engine, r) for r in rows]
next_cursor = None
remaining = 0
if rows:
@@ -256,6 +303,19 @@ def create_app(db_path, img_dir, state_dir=None):
if not isinstance(listing_id, int) or action not in ACTIONS:
raise HTTPException(status_code=400,
detail='body must be {listing_id:int, action:like|nope|maybe}')
# optional P3: section this verdict happened in (NULL for list edits)
section_id = body.get('section_id')
if not isinstance(section_id, int):
section_id = None
# B1: optional photo index on screen at swipe time (§11.5 rotation).
has_photo_idx = 'photo_idx' in body
photo_idx = body.get('photo_idx')
if has_photo_idx and photo_idx is not None:
if (isinstance(photo_idx, bool) or not isinstance(photo_idx, int)
or photo_idx < 0):
raise HTTPException(
status_code=400,
detail='photo_idx must be a non-negative int (or null)')
conn = get_conn(app)
try:
row = conn.execute('SELECT id FROM listings WHERE id=?',
@@ -263,17 +323,36 @@ def create_app(db_path, img_dir, state_dir=None):
if not row:
raise HTTPException(status_code=404, detail='unknown listing')
now = imp.now_iso()
conn.execute(
'''INSERT INTO swipes (listing_id, action, created_at, updated_at)
VALUES (?,?,?,?)
ON CONFLICT(listing_id) DO UPDATE SET
action=excluded.action, updated_at=excluded.updated_at''',
(listing_id, action, now, now))
if has_photo_idx:
# explicit photo_idx (int or null) overwrites; an absent key
# preserves the stored index (list edits etc.)
conn.execute(
'''INSERT INTO swipes (listing_id, action, created_at,
updated_at, last_photo_idx)
VALUES (?,?,?,?,?)
ON CONFLICT(listing_id) DO UPDATE SET
action=excluded.action, updated_at=excluded.updated_at,
last_photo_idx=excluded.last_photo_idx''',
(listing_id, action, now, now, photo_idx))
else:
conn.execute(
'''INSERT INTO swipes (listing_id, action, created_at, updated_at)
VALUES (?,?,?,?)
ON CONFLICT(listing_id) DO UPDATE SET
action=excluded.action, updated_at=excluded.updated_at''',
(listing_id, action, now, now))
# P3: append the learning event (round = prior events + 1)
round_ = dbmod.append_event(conn, listing_id, action, section_id,
now)
conn.commit()
stored = conn.execute(
'SELECT listing_id, action, created_at, updated_at'
' FROM swipes WHERE listing_id=?', (listing_id,)).fetchone()
return dict(stored)
'SELECT listing_id, action, created_at, updated_at,'
' last_photo_idx FROM swipes WHERE listing_id=?',
(listing_id,)).fetchone()
result = dict(stored)
result['round'] = round_
result['section_id'] = section_id
return result
finally:
conn.close()
@@ -307,16 +386,19 @@ def create_app(db_path, img_dir, state_dir=None):
params.append('%' + escaped + '%')
conn = get_conn(app)
try:
engine = secmod.build_engine(conn, now=utcnow())
total = conn.execute(
'SELECT COUNT(*) AS n FROM listings l'
' LEFT JOIN swipes s ON s.listing_id = l.id'
' WHERE ' + where, params).fetchone()['n']
rows = conn.execute(
'SELECT l.*, s.action AS decision FROM listings l'
'SELECT l.*, s.action AS decision,'
' s.last_photo_idx AS last_photo_idx FROM listings l'
' LEFT JOIN swipes s ON s.listing_id = l.id'
' WHERE ' + where + ' ORDER BY ' + LIST_SORTS[sort] +
' LIMIT ? OFFSET ?', params + [limit, offset]).fetchall()
return {'items': [payload(conn, r) for r in rows], 'total': total}
return {'items': [p3_payload(conn, engine, r) for r in rows],
'total': total}
finally:
conn.close()
@@ -324,13 +406,15 @@ def create_app(db_path, img_dir, state_dir=None):
def listing(listing_id: int):
conn = get_conn(app)
try:
engine = secmod.build_engine(conn, now=utcnow())
row = conn.execute(
'SELECT l.*, s.action AS decision FROM listings l'
'SELECT l.*, s.action AS decision,'
' s.last_photo_idx AS last_photo_idx FROM listings l'
' LEFT JOIN swipes s ON s.listing_id = l.id'
' WHERE l.id=?', (listing_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail='unknown listing')
return payload(conn, row)
return p3_payload(conn, engine, row, include_breakdown=True)
finally:
conn.close()
@@ -357,21 +441,268 @@ def create_app(db_path, img_dir, state_dir=None):
finally:
conn.close()
# -- P3: sections ----------------------------------------------------
def section_state(conn, sec, note=None):
"""Shared body for GET /api/section and POST /api/section/forge."""
now = utcnow()
engine = secmod.build_engine(conn, now=now)
data = secmod.read_section(conn, sec, now)
sec = conn.execute('SELECT * FROM sections WHERE id=?',
(sec['id'],)).fetchone()
mode = get_pref(conn, 'forge_mode', 'ondemand')
items = [p3_payload(conn, engine, r, slot=r['slot'],
include_breakdown=True)
for r in data['items']]
data['items'] = items
data['mode'] = mode
data['can_forge'] = (mode != 'strict'
or secmod.fresh_profile_exists(conn, sec))
data['counts'] = secmod.section_counts(conn, sec)
prof = secmod.latest_profile(conn)
data['read_info'] = {
'events': engine.n_events,
'profile_version': prof['version'] if prof else None,
'profile_source': prof['source'] if prof else None,
'profile_at': prof['created_at'] if prof else None,
}
if note:
data['note'] = note
return data
@app.get('/api/section')
def get_section():
conn = get_conn(app)
try:
sec = secmod.get_current_section(conn)
if sec is None:
# first-ever read composes a cold section
prof = secmod.latest_profile(conn)
engine = secmod.build_engine(conn, prof, now=utcnow())
sec = secmod.compose_section(
conn, engine, size=25,
profile_version=prof['version'] if prof else None,
note='cold compose (first read)')
return section_state(conn, sec)
finally:
conn.close()
@app.post('/api/section/forge')
def forge(body: dict):
size = body.get('size', 25)
if not isinstance(size, int) or not 1 <= size <= 100:
raise HTTPException(status_code=400,
detail='size must be an int 1..100')
conn = get_conn(app)
try:
mode = get_pref(conn, 'forge_mode', 'ondemand')
sec = secmod.get_current_section(conn)
note = None
if mode == 'strict':
if not secmod.fresh_profile_exists(conn, sec):
raise HTTPException(
status_code=409, detail=(
'strict mode: no fresh agent pass since this '
'section — the nightly read provides it'))
note = 'strict: forged on the fresh pass'
else:
# on-demand: agent pass between sections (mock when the
# live key is not configured; live failures fall back)
src = 'live' if live_configured() else 'mock'
res = agmod.run_pass(app.state.db_path, source=src)
note = ('pass %s v%d' % (res['source'],
res['profile_version']))
if res['source'] == 'fallback':
note += ' (agent offline — last read used)'
if sec is not None:
secmod.close_section(conn, sec, utcnow())
prof = secmod.latest_profile(conn)
engine = secmod.build_engine(conn, prof, now=utcnow())
newsec = secmod.compose_section(
conn, engine, size=size,
profile_version=prof['version'] if prof else None,
note=note)
return section_state(conn, newsec, note=note)
finally:
conn.close()
# -- P3: trends ------------------------------------------------------
@app.get('/api/trends')
def trends():
conn = get_conn(app)
try:
prof = secmod.latest_profile(conn)
profile = None
if prof:
payload_prof = {}
try:
payload_prof = json.loads(prof['payload'])
except ValueError:
payload_prof = {}
maker = conn.execute(
'SELECT * FROM agent_runs WHERE profile_version=?'
' ORDER BY id DESC LIMIT 1', (prof['version'],)
).fetchone()
profile = {
'version': prof['version'],
'source': prof['source'],
'created_at': prof['created_at'],
'model': maker['model'] if maker else None,
'lead': payload_prof.get('lead', ''),
'leanings': payload_prof.get('leanings', [])[:8],
'changes': payload_prof.get('changes', []),
'chips': payload_prof.get('chips', []),
}
per = {}
for action in ('like', 'maybe', 'nope'):
per[action] = conn.execute(
'SELECT COUNT(*) AS n FROM swipes WHERE action=?',
(action,)).fetchone()['n']
decided = per['like'] + per['maybe'] + per['nope']
events = conn.execute(
'SELECT listing_id, action FROM swipe_events ORDER BY id'
).fetchall()
prev = {}
flips = 0
for ev in events:
if ev['listing_id'] in prev and prev[ev['listing_id']] != ev[
'action']:
flips += 1
prev[ev['listing_id']] = ev['action']
ret_placed = conn.execute(
"SELECT COUNT(*) AS n FROM section_items "
"WHERE slot='return'").fetchone()['n']
ret_struck = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n
FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id
AND si.listing_id = e.listing_id
WHERE si.slot='return'""").fetchone()['n']
wild_placed = conn.execute(
"SELECT COUNT(*) AS n FROM section_items "
"WHERE slot='wildcard'").fetchone()['n']
wild_hits = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n
FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id
AND si.listing_id = e.listing_id
WHERE si.slot='wildcard' AND e.action IN ('like','maybe')"""
).fetchone()['n']
last_run = None
row = conn.execute(
'SELECT * FROM agent_runs ORDER BY id DESC LIMIT 1'
).fetchone()
if row:
last_run = {
'started_at': row['started_at'],
'finished_at': row['finished_at'],
'ok': bool(row['ok']),
'source': row['source'],
'model': row['model'],
'cost_note': row['cost_note'],
'profile_version': row['profile_version'],
}
return {
'profile': profile,
'stats': {
'decided': decided,
'likes': per['like'],
'maybes': per['maybe'],
'nopes': per['nope'],
'hit_rate': (per['like'] / decided) if decided else None,
'hit_rate_plus_maybe': (
(per['like'] + per['maybe']) / decided
if decided else None),
'returns_placed': ret_placed,
'returns_re_struck': ret_struck,
'wildcards_placed': wild_placed,
'wildcards_hit': wild_hits,
'flips': flips,
},
'last_run': last_run,
'cold_start': decided < 10,
'forge_mode': get_pref(conn, 'forge_mode', 'ondemand'),
}
finally:
conn.close()
# -- P3: agent pass trigger ------------------------------------------
@app.post('/api/agent/run')
def agent_run(body: dict):
source = body.get('source', 'mock') if isinstance(body, dict) else 'mock'
if source not in ('mock', 'live'):
raise HTTPException(status_code=400,
detail='source must be mock|live')
if source == 'live' and not live_configured():
raise HTTPException(
status_code=400,
detail='live pass not configured (OPENROUTER_API_KEY unset)')
model = body.get('model') if isinstance(body, dict) else None
res = agmod.run_pass(app.state.db_path, source=source, model=model)
return res
# -- prefs (P3 forge_mode toggle persistence) -------------------------
@app.post('/api/prefs')
def set_pref_endpoint(body: dict):
key = body.get('key')
value = body.get('value')
allowed = {'forge_mode': ('ondemand', 'strict')}
if key not in allowed or value not in allowed[key]:
raise HTTPException(status_code=400,
detail='unknown or invalid pref')
conn = get_conn(app)
try:
set_pref(conn, key, value)
conn.commit()
return {'key': key, 'value': value}
finally:
conn.close()
# -- images + static -------------------------------------------------
@app.get('/img/{listing_id}')
def img(listing_id: int):
fpath = os.path.join(app.state.img_dir, '%d.webp' % listing_id)
def img(listing_id: int, i: int = Query(0, ge=0)):
"""Photo n of a gallery (B1 §11.1): mirrored file -> CDN 302 -> 404.
i=0 keeps the legacy chain ({id}.webp, with {id}_0.webp accepted as
the gallery-mirror name for the primary); i>0 serves {id}_{n}.webp
with the CDN fallback taken from imgs[n].
"""
fpath = os.path.join(
app.state.img_dir,
'%d.webp' % listing_id if i == 0 else
'%d_%d.webp' % (listing_id, i))
if i == 0 and not os.path.exists(fpath):
alt = os.path.join(app.state.img_dir, '%d_0.webp' % listing_id)
if os.path.exists(alt):
fpath = alt
if os.path.exists(fpath):
return FileResponse(fpath, media_type='image/webp')
conn = get_conn(app)
try:
row = conn.execute('SELECT img_url FROM listings WHERE id=?',
(listing_id,)).fetchone()
row = conn.execute(
'SELECT img_url, imgs FROM listings WHERE id=?',
(listing_id,)).fetchone()
finally:
conn.close()
if row and row['img_url']:
return RedirectResponse(url=row['img_url'], status_code=302)
url = None
if row:
if i == 0 and row['img_url']:
url = row['img_url']
elif row['imgs']:
try:
arr = json.loads(row['imgs'])
except ValueError:
arr = []
if isinstance(arr, list) and len(arr) > i and arr[i]:
url = arr[i]
if url:
return RedirectResponse(url=url, status_code=302)
raise HTTPException(status_code=404, detail='no image')
@app.get('/')

112
app/db.py
View File

@@ -2,12 +2,67 @@
Stdlib only. The sqlite3 CLI is not installed on this host, so everything
goes through the sqlite3 module.
P3: init_db additionally creates the personalization tables
(swipe_events, sections, section_items, taste_profile, agent_runs),
migrates pre-P3 DBs in place (CREATE TABLE IF NOT EXISTS), and seeds
swipe_events from existing swipes rows exactly once (only-when-empty,
hence idempotent — see docs/DATA-MODEL.md "P3 additions").
"""
import os
import sqlite3
SCHEMA_PATH = os.path.join(os.path.dirname(__file__), 'schema.sql')
# P3 tables — guarded/idempotent; the locked v1 schema.sql stays untouched.
P3_SCHEMA = """
CREATE TABLE IF NOT EXISTS swipe_events (
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,
section_id INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_listing ON swipe_events (listing_id, round);
CREATE INDEX IF NOT EXISTS idx_events_time ON swipe_events (created_at);
CREATE TABLE IF NOT EXISTS 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,
recipe TEXT,
created_at TEXT DEFAULT (datetime('now')),
closed_at TEXT
);
CREATE TABLE IF NOT EXISTS 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)
);
CREATE INDEX IF NOT EXISTS idx_section_items_section ON section_items (section_id);
CREATE INDEX IF NOT EXISTS idx_section_items_listing ON section_items (listing_id);
CREATE TABLE IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS 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
);
"""
def connect(db_path):
"""Open a connection with WAL mode and a busy_timeout, then ensure schema."""
@@ -23,11 +78,13 @@ def connect(db_path):
def init_db(conn):
"""Apply schema.sql (idempotent) + guarded P2 column migrations.
"""Apply schema.sql (idempotent) + guarded P2/P3 migrations.
P2 additions (docs/DATA-MODEL.md) are added with idempotent
``ALTER TABLE ... ADD COLUMN`` guarded by a PRAGMA check, so the locked
v1 schema.sql stays untouched and existing DBs migrate in place.
P3 additions create the personalization tables (idempotent) and seed
swipe_events from swipes exactly once (when the events table is empty).
"""
with open(SCHEMA_PATH, encoding='utf-8') as fh:
conn.executescript(fh.read())
@@ -38,4 +95,57 @@ def init_db(conn):
conn.execute('ALTER TABLE listings ADD COLUMN seller_type TEXT')
if 'extras' not in cols:
conn.execute('ALTER TABLE listings ADD COLUMN extras TEXT')
# B1 (photo experience v2): gallery URLs + swipe-time photo index.
if 'imgs' not in cols:
conn.execute('ALTER TABLE listings ADD COLUMN imgs TEXT')
sw_cols = {r['name'] for r in conn.execute(
"PRAGMA table_info('swipes')")}
if 'last_photo_idx' not in sw_cols:
conn.execute('ALTER TABLE swipes ADD COLUMN last_photo_idx INTEGER')
# P3 tables (idempotent on every call)
conn.executescript(P3_SCHEMA)
# P3 event seeding: one round-1 event per existing swipe, copied
# created_at. Only when swipe_events is empty — hence idempotent.
n_events = conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events').fetchone()['n']
if n_events == 0:
conn.execute(
"""INSERT INTO swipe_events
(listing_id, action, round, section_id, created_at)
SELECT listing_id, action, 1, NULL, created_at
FROM swipes""")
conn.commit()
def append_event(conn, listing_id, action, section_id=None, created_at=None):
"""Append one swipe event; round = prior events for this listing + 1.
Returns the assigned round. Every swipe write path must call this once
per verdict change (swipes stays the materialized latest verdict;
swipe_events is the append-only learning log).
"""
prior = conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events WHERE listing_id=?',
(listing_id,)).fetchone()['n']
round_ = prior + 1
if created_at is None:
created_at = None # let the column default (datetime('now')) apply
conn.execute(
"""INSERT INTO swipe_events (listing_id, action, round, section_id)
VALUES (?,?,?,?)""",
(listing_id, action, round_, section_id))
else:
conn.execute(
"""INSERT INTO swipe_events
(listing_id, action, round, section_id, created_at)
VALUES (?,?,?,?,?)""",
(listing_id, action, round_, section_id, created_at))
return round_
def next_round(conn, listing_id):
"""Round the NEXT event on this listing will get (= prior events + 1)."""
prior = conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events WHERE listing_id=?',
(listing_id,)).fetchone()['n']
return min(prior + 1, 2)

519
app/fe_walk_b1.py Normal file
View File

@@ -0,0 +1,519 @@
"""B1 FE walk — headless-chromium proof of UI-SPEC §11 on the real page.
Not part of `app.selfcheck` (needs playwright + a live server); run it after
starting the scratch server (the brief's isolation recipe):
.venv/bin/python -m app.fe_walk_b1 setup # optional — auto at start
env -u OPENROUTER_API_KEY .venv/bin/python -m app.serve \
--db /var/home/admin/dev/bike-tinder-b1/test-walk.sqlite3 \
--img-dir /var/home/admin/dev/bike-tinder-b1/test-img \
--host 127.0.0.1 --port 9454 # in another shell (mock forge)
.venv/bin/python -m app.fe_walk_b1 # walk (this file)
Setup is deterministic and idempotent: a fresh copy of the pristine B1 copy
DB (test-copy.sqlite3) each run, synthetic multi-photo listings (ids 701+)
with local image files staged into test-img from media-sample/, and one
swiped return listing (705) whose stored last_photo_idx drives §11.5
rotation. Everything walks the REAL page: real pointers, real DOM, no
stubbed APIs.
"""
import json
import os
import re
import shutil
import sqlite3
import sys
import time
import urllib.request
ARENA = '/var/home/admin/dev/bike-tinder-b1'
PRISTINE = os.path.join(ARENA, 'test-copy.sqlite3')
WALK_DB = os.path.join(ARENA, 'test-walk.sqlite3')
IMG_DIR = os.path.join(ARENA, 'test-img')
MEDIA = os.path.join(ARENA, 'media-sample')
BASE_URL = 'http://127.0.0.1:9454'
CDN = ('https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/x/%s'
'?rule=kijijica-640-webp')
# synthetic fixtures: listing_id -> (n_photos, label)
FIX = {
701: (8, 'gallery-8'),
702: (3, 'gallery-3'),
703: (2, 'gallery-2'),
704: (1, 'single-photo'),
705: (8, 'return-rotation'), # pre-swiped: last_photo_idx=0 -> lead 1
706: (0, 'zero-photo'),
}
SCORE_BASE = 2000
def stage_img_files():
"""{id}_0..{id}_{n-1}.webp (and {id}.webp for the legacy single) from
media-sample's real webp files — no network needed."""
os.makedirs(IMG_DIR, exist_ok=True)
names = sorted(m for m in os.listdir(MEDIA) if m.endswith('.webp'))
assert len(names) == 12, names
k = 0 # cycle through the 12 real files
for lid in sorted(FIX):
n = FIX[lid][0]
if n == 0:
continue
if n == 1:
shutil.copy2(os.path.join(MEDIA, names[k % len(names)]),
os.path.join(IMG_DIR, '%d.webp' % lid))
k += 1
continue
for i in range(n):
shutil.copy2(os.path.join(MEDIA, names[k % len(names)]),
os.path.join(IMG_DIR, '%d_%d.webp' % (lid, i)))
k += 1
def setup_db():
shutil.copy2(PRISTINE, WALK_DB)
from app import db as dbmod
conn = dbmod.connect(WALK_DB)
dbmod.init_db(conn) # B1 migrations
for lid in sorted(FIX):
n = FIX[lid][0]
imgs = [CDN % ('x%d-%d' % (lid, i)) for i in range(n)]
img = imgs[0] if imgs else None
score = SCORE_BASE + (750 - lid) # 701 highest -> top of the deck
conn.execute(
"DELETE FROM listings WHERE id=?", (lid,))
conn.execute(
"INSERT INTO listings (id, region, src_id, title, url, status,"
" img_url, imgs, score, first_seen, desc) VALUES"
" (?, 'ns', ?, ?, ?, 'active', ?, ?, ?,"
" '2026-11-11T00:00:00Z', ?)",
(lid, 'b1fix%d' % lid, 'B1 test %s' % FIX[lid][1],
'https://www.kijiji.ca/v-motorcycles/b1/%d' % lid,
img, json.dumps(imgs) if imgs else None, score, 'B1 fixture'))
conn.execute(
"INSERT OR REPLACE INTO swipes (listing_id, action, created_at,"
" last_photo_idx) VALUES (705, 'like', '2026-11-08T00:00:00Z', 0)")
# Close live-copied sections: the first /api/section read then composes
# a fresh 25 on the walk DB (deterministic fixture ordering).
conn.execute(
"UPDATE sections SET status='closed' WHERE status != 'closed'")
# A round-1 event per fixture keeps synthetics out of the wildcard
# pool (wildcards must be unseen); new-pool eligibility is unaffected.
# 705's event matches its stored verdict (round 1, like).
for lid in FIX:
conn.execute(
"INSERT INTO swipe_events (listing_id, action, round,"
" section_id) VALUES (?, ?, 1, NULL)",
(lid, 'like' if lid == 705 else 'maybe'))
conn.commit()
n = conn.execute('SELECT COUNT(*) AS n FROM listings').fetchone()['n']
conn.close()
print('setup: walk DB at %s (%s listings; 701-706 synthetic, 705 = liked'
' return w/ last_photo_idx=0)' % (WALK_DB, n))
def need_server():
try:
with urllib.request.urlopen(BASE_URL + '/api/health',
timeout=3) as r:
return r.status == 200
except Exception:
return False
# --- walk helpers (module-level `page` assigned inside walk) ----------------
def wait(selector, timeout=8000):
page.wait_for_selector(selector, timeout=timeout)
def card_box():
b = page.evaluate("""() => { const el=document.querySelector('.topcard');
if(!el) return null; const r=el.getBoundingClientRect();
return {x:r.x,y:r.y,w:r.width,h:r.height}; }""")
assert b, 'no .topcard in DOM'
return b
def top_state():
return page.evaluate("""() => {
const t=document.querySelector('.topcard');
const r={topcards:document.querySelectorAll('.topcard').length,
hasSegs:!!t.classList.contains('hasSegs'), segs:0, segOn:-1,
photo:null, nophoto:false, band:null,
stampLike:0, stampNope:0, stampMaybe:0};
const s=t.querySelector('.segments');
if(s){ r.segs=s.children.length;
[...s.children].forEach((x,k)=>{ if(x.classList.contains('on'))
r.segOn=k; }); }
const p=t.querySelector('.phwrap img.photo');
if(p) r.photo=p.getAttribute('src');
else if(t.querySelector('.nophoto')) r.nophoto=true;
const b=t.querySelector('.band'); if(b) r.band=b.textContent;
const sl=t.querySelector('.stamp.like');
if(sl) r.stampLike=sl.style.opacity;
const sn=t.querySelector('.stamp.nope');
if(sn) r.stampNope=sn.style.opacity;
const sm=t.querySelector('.stamp.maybe');
if(sm) r.stampMaybe=sm.style.opacity;
return r; }""")
def tap_third(which):
b = card_box()
y = b['y'] + b['h'] * 0.72
x = b['x'] + b['w'] * (0.16 if which == 'left'
else (0.84 if which == 'right' else 0.5))
page.mouse.click(x, y)
time.sleep(0.08)
def assert_item(cond, msg):
if not cond:
print('FAIL ' + msg)
sys.exit(1)
print('ok ' + msg)
def lead_of(item):
n = item.get('n_photos') or 0
li = item.get('last_photo_idx')
if not n or n < 2 or li is None:
return 0
return (li + 1) % n
def photo_url(item, i):
if not item.get('img'):
return None
return item['img'] if i == 0 else '/img/%s?i=%s' % (item['id'], i)
def viewer_on():
return page.evaluate(
"() => document.getElementById('viewer').classList.contains('on')")
def vimg_src():
return page.evaluate(
"() => document.getElementById('vimg').getAttribute('src')")
def vimg_transform():
return page.evaluate(
"() => document.getElementById('vimg').style.transform")
# --- the walk ---------------------------------------------------------------
def walk():
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
exe = os.path.expanduser(
'~/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome')
if not os.path.exists(exe):
exe = None
browser = pw.chromium.launch(headless=True, executable_path=exe,
args=['--no-sandbox'])
ctx = browser.new_context(viewport={'width': 390, 'height': 844},
has_touch=True, is_mobile=True)
global page
page = ctx.new_page()
page.goto(BASE_URL + '/', wait_until='networkidle')
wait('.topcard')
time.sleep(0.6)
section = None
for _ in range(40):
body = page.evaluate(
"() => fetch('/api/section').then(r=>r.json())")
if body and body.get('items'):
section = body
break
time.sleep(0.5)
assert section, 'no section payload'
items = list(section['items'])
print('section: %d items — recipe %s' % (len(items), json.dumps(
section['section']['recipe'], sort_keys=True)))
by_id = {i['id']: i for i in items}
for lid in (701, 702, 703, 704, 706):
assert lid in by_id, 'fixture %s missing from section' % lid
assert by_id[lid]['slot'] == 'new', (lid, by_id[lid]['slot'])
assert by_id[705]['slot'] == 'return', by_id[705]['slot']
seen = []
round_no = 0
while True:
remaining = [i['id'] for i in items if i['id'] not in seen]
if not remaining:
break
t = top_state()
top_id = None
m = re.search(r'/img/(\d+)(?:\?i=\d+)?$', t['photo'] or '')
if m:
top_id = int(m.group(1))
else:
for i in items: # zero-photo card: no src to match
if i['id'] not in seen and not i.get('img'):
top_id = i['id']
break
assert top_id in by_id, 'cannot identify top card'
item = by_id[top_id]
seen.append(top_id)
round_no += 1
n = item.get('n_photos') or 0
exp_lead = lead_of(item)
exp_src = photo_url(item, exp_lead)
print('-- top id=%s [%s] n=%d lead=%d slot=%s --'
% (top_id, item['title'][:26], n, exp_lead, item['slot']))
assert_item(t['topcards'] == 1,
'exactly one .topcard before gestures')
if n >= 2:
assert_item(t['hasSegs'] and t['segs'] == n,
'segments count == n (%d)' % n)
assert_item(t['segOn'] == exp_lead,
'segment idx == rotation lead (%d)' % exp_lead)
assert_item(t['photo'] == exp_src,
'photo src == expected lead url')
else:
assert_item(not t['hasSegs'] and t['segs'] == 0,
'no segments for n=%d' % n)
if n == 1:
assert_item(t['photo'] == photo_url(item, 0),
'single photo shows the primary')
else:
assert_item(t['nophoto'],
'zero-photo card shows the placeholder')
if item['slot'] == 'return' and item['id'] == 705:
assert_item(exp_lead == 1, 'rotation lead: (0+1)%%8 = 1')
assert_item(t['band'] and 'SECOND LOOK' in t['band'],
'return card carries the SECOND LOOK band '
'(with segments)')
if n >= 2:
# --- flip: advance + clamp both ends (no wrap) ----------
for i in range(n + 2):
tap_third('right')
st = top_state()
assert_item(st['topcards'] == 1,
'tap kept the one-.topcard invariant')
want = min(exp_lead + i + 1, n - 1)
assert_item(st['segOn'] == want,
'right-tap %d -> idx %d (clamp)' % (i + 1,
want))
for i in range(n + 2):
tap_third('left')
st = top_state()
assert_item(st['topcards'] == 1,
'tap kept the one-.topcard invariant')
want = max(n - 1 - (i + 1), 0)
assert_item(st['segOn'] == want,
'left-tap %d -> idx %d (clamp)' % (i + 1,
want))
st = top_state()
assert_item(st['topcards'] == 1
and st['stampLike'] in ('0', '')
and st['stampNope'] in ('0', '')
and st['stampMaybe'] in ('0', ''),
'taps never commit: no stamps, no flight')
# --- viewer: open on current photo, nav + clamp, zoom,
# pull-close restores the same card+photo ------------
want_idx = 1
while st['segOn'] > want_idx:
tap_third('left')
st = top_state()
if st['segOn'] < want_idx:
tap_third('right')
st = top_state()
before_src = st['photo']
tap_third('center')
assert_item(viewer_on(), 'center tap opens the viewer')
vsegs = page.evaluate(
"() => document.getElementById('vsegs')"
".querySelectorAll('i').length")
assert_item(vsegs == n, 'viewer segments == n (%d)' % n)
assert_item(vimg_src() == before_src,
'viewer opens on the current photo')
vp = page.locator('#vphoto')
vb = vp.bounding_box()
al, ar = (vb['x'] + vb['width'] * 0.75,
vb['x'] + vb['width'] * 0.15)
ay = vb['y'] + vb['height'] * 0.5
page.mouse.move(al, ay)
page.mouse.down()
page.mouse.move(ar, ay, steps=6)
page.mouse.up()
time.sleep(0.1)
land = min(want_idx + 1, n - 1)
assert_item(vimg_src() == photo_url(item, land),
'viewer swipe-left advances + clamps')
page.mouse.move(ar, ay)
page.mouse.down()
page.mouse.move(al, ay, steps=6)
page.mouse.up()
time.sleep(0.1)
back = max(land - 1, 0)
assert_item(vimg_src() == photo_url(item, back),
'viewer swipe-right steps back (clamped)')
page.mouse.dblclick(vb['x'] + vb['width'] * 0.5,
vb['y'] + vb['height'] * 0.5)
time.sleep(0.1)
assert_item('scale(2.5)' in vimg_transform(),
'double-tap zooms to 2.5x')
page.mouse.dblclick(vb['x'] + vb['width'] * 0.5,
vb['y'] + vb['height'] * 0.5)
time.sleep(0.1)
assert_item('scale(2.5)' not in vimg_transform(),
'double-tap again returns to 1x')
# pinch via synthetic pointer events (real handler path)
page.evaluate("""() => {
const v=document.getElementById('vphoto');
const r=v.getBoundingClientRect();
const mk=(t,id,x,y)=>{ v.dispatchEvent(new PointerEvent(t,
{pointerId:id, clientX:x, clientY:y, bubbles:true})); };
const x=r.width/2, y=r.height/2;
mk('pointerdown',10,x-25,y); mk('pointerdown',11,x+25,y);
for(let s=0;s<6;s++){ mk('pointermove',10,x-25-s*22,y);
mk('pointermove',11,x+25+s*22,y); }
mk('pointerup',10,x-25-6*22,y);
mk('pointerup',11,x+25+6*22,y);
}""")
assert_item('scale(4)' in vimg_transform(),
'pinch-zoom clamps at 4x')
page.mouse.dblclick(vb['x'] + vb['width'] * 0.5,
vb['y'] + vb['height'] * 0.5)
time.sleep(0.1) # back to 1x for the pull gesture
page.mouse.move(vb['x'] + vb['width'] * 0.5,
vb['y'] + vb['height'] * 0.35)
page.mouse.down()
page.mouse.move(vb['x'] + vb['width'] * 0.5,
vb['y'] + vb['height'] * 0.35 + 170, steps=8)
page.mouse.up()
time.sleep(0.2)
assert_item(not viewer_on(),
'pull-down (>110px) closes the viewer')
st = top_state()
assert_item(st['segOn'] == want_idx
and st['photo'] == before_src,
'close returns the same card + photo position')
else:
if n == 1:
tap_third('center')
assert_item(viewer_on(),
'center tap opens the viewer (single photo)')
page.locator('#vclose').click()
time.sleep(0.15)
if n == 0:
tap_third('center')
assert_item(not viewer_on(), 'zero-photo: no viewer')
# --- ⓘ beats the zones --------------------------------------
page.locator('.topcard .ibtn').click()
time.sleep(0.15)
assert_item(page.evaluate(
"() => document.getElementById('detail')"
".classList.contains('on')"),
'ⓘ opens the dossier (zones do not fire)')
pre = top_state()
page.locator('#dclose').click()
time.sleep(0.2)
post = top_state()
assert_item(pre['photo'] == post['photo']
and pre['segOn'] == post['segOn'],
'dossier close leaves the photo position intact')
if n >= 1:
# dossier hero -> viewer at photo 0 (dossier stays open)
page.locator('.topcard .ibtn').click()
time.sleep(0.15)
hb = page.locator('.dhero').bounding_box()
page.mouse.click(hb['x'] + hb['width'] / 2,
hb['y'] + hb['height'] / 2)
time.sleep(0.15)
assert_item(viewer_on() and page.evaluate(
"() => document.getElementById('detail')"
".classList.contains('on')"),
'dossier hero tap opens the viewer over the dossier')
assert_item(vimg_src() == photo_url(item, 0),
'dossier viewer starts at photo 0')
page.locator('#vclose').click()
time.sleep(0.15)
assert_item(not viewer_on() and page.evaluate(
"() => document.getElementById('detail')"
".classList.contains('on')"),
'viewer ✕ returns to the still-open dossier')
page.locator('#dclose').click()
time.sleep(0.15)
# single commit per round (fast chain runs after the report)
page.locator('#btnNope').click()
time.sleep(0.07)
# --- P3 regression: report -> undo x3 -> fast chain -> forge -----
wait('#report.on', 15000)
print('report visible: %s' % page.locator('#repCounts').inner_text())
assert_item('Section complete' in page.locator('#report').inner_text(),
'report screen after the full section')
for _ in range(4):
page.locator('#btnUndo').click()
time.sleep(0.08)
assert_item(page.evaluate(
"() => document.querySelectorAll('.topcard').length") == 1,
'undo x4 from the report re-opens the deck (one .topcard)')
for k in range(3):
b = card_box()
y = b['y'] + b['h'] * 0.6
page.mouse.move(b['x'] + b['w'] * 0.5, y)
page.mouse.down()
page.mouse.move(b['x'] + b['w'] * 0.5 + 320, y, steps=5)
page.mouse.up()
if k < 2:
time.sleep(0.07)
tc = page.evaluate(
"() => document.querySelectorAll('.topcard').length")
assert tc == 1, 'fast chain: topcard=%s (must be 1)' % tc
assert_item(True, '3 like-commits @70ms: one .topcard, no lockout')
page.locator('#btnNope').click() # finish the 4th restored card
time.sleep(0.1)
wait('#report.on', 15000)
page.locator('#btnForge').click()
wait('.topcard', 15000)
time.sleep(0.4)
sect = page.evaluate(
"() => document.getElementById('sect').textContent.trim()")
assert_item(re.match(r'^\d+/25$', sect) is not None,
'forge hydrates a fresh section (progress=%s)' % sect)
# --- List + Trends tabs render with P3 elements intact ----------
page.locator('.tab[data-tab="list"]').click()
wait('.row')
tanks = page.locator('.row .tank').count()
rows = page.locator('.row').count()
assert_item(tanks > 0 and rows > 0,
'list: %d rows, %d tank gauges (P3 intact)'
% (rows, tanks))
page.locator('.tab[data-tab="trends"]').click()
wait('#tLead')
assert_item((page.locator('#tLead').text_content() or '').strip()
!= '', 'trends tab renders')
print('WALK COMPLETE — all B1 FE checks green')
browser.close()
def main(argv=None):
if 'setup' in sys.argv or not os.path.exists(WALK_DB):
if not os.path.exists(PRISTINE):
print('pristine copy DB missing: %s' % PRISTINE)
return 1
setup_db()
stage_img_files()
print('fixtures staged in %s' % IMG_DIR)
if not need_server():
print('NO SERVER on %s — start it (see module docstring).' % BASE_URL)
return 1
walk()
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,18 +1,24 @@
"""Bike-Tinder local image mirror.
Downloads each active listing's card image once into data/img/{id}.webp.
Downloads each active listing's photos once:
- gallery listings (B1 §11.1): imgs[n] -> data/img/{id}_{n}.webp for the
extra photos; the primary (n=0) lands at the legacy data/img/{id}.webp
so the v1 single-image chain is untouched;
- legacy listings: img_url -> data/img/{id}.webp (as before).
.venv/bin/python -m app.imgfetch --db data/bike-tinder.sqlite3
.venv/bin/python -m app.imgfetch --limit 15
Polite by design: ~1 req/s (sleep between fetches), skip-existing, crash-safe
(partial bytes land in .part and are atomically renamed), per-item failures
never kill the run. Honest end summary: `fetched N · skipped M · failed K`.
never kill the run. Per-file CLI progress; honest end summary:
`fetched N · skipped M · failed K`.
UA + habits match the hunt scanner (reference/motorcycle-hunt-scan.py) so the
CDN sees the same client as the crawl. Stdlib only — no requests dependency.
"""
import argparse
import json
import os
import sys
import time
@@ -44,8 +50,10 @@ def fetch_one(img_url, dst):
})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
if getattr(resp, 'status', 200) != 200:
return 'HTTP %s' % resp.status
status = (getattr(resp, 'status', None)
or getattr(resp, 'code', None) or 200)
if status != 200:
return 'HTTP %s' % status
buf = resp.read()
if not is_webp(buf):
return 'not webp (%d bytes, %r)' % (len(buf), buf[:8])
@@ -65,33 +73,76 @@ def fetch_one(img_url, dst):
pass
def parse_imgs(raw):
"""listings.imgs TEXT -> list of non-empty URL strings ([] when unparsable)."""
if not raw:
return []
try:
v = json.loads(raw)
except ValueError:
return []
if not isinstance(v, list):
return []
return [u.strip() for u in v if isinstance(u, str) and u.strip()]
def build_work(rows, img_dir):
"""Flatten listings rows into (listing_id, n_or_None, url, dst) downloads.
Gallery rows fetch every imgs[n]: n=0 -> the legacy {id}.webp (the primary
— one canonical file, no duplicate fetch), n>=1 -> {id}_{n}.webp. Legacy
rows (no gallery) keep the img_url -> {id}.webp chain. Work is deduped by
destination path.
"""
work = []
for r in rows:
g = parse_imgs(r['imgs'])
if g:
for n, url in enumerate(g):
dst = os.path.join(img_dir, ('%d.webp' % r['id'] if n == 0
else '%d_%d.webp' % (r['id'], n)))
work.append((r['id'], n, url, dst))
elif r['img_url']:
work.append((r['id'], None, r['img_url'],
os.path.join(img_dir, '%d.webp' % r['id'])))
seen, deduped = set(), []
for item in work:
if item[3] not in seen:
seen.add(item[3])
deduped.append(item)
return deduped
def run_fetch(db_path, img_dir, limit=None, sleep=SLEEP, quiet=False):
conn = db.connect(db_path)
db.init_db(conn)
rows = conn.execute(
"SELECT id, img_url FROM listings"
" WHERE status='active' AND img_url IS NOT NULL"
" ORDER BY id").fetchall()
"SELECT id, img_url, imgs FROM listings"
" WHERE status='active' ORDER BY id").fetchall()
conn.close()
os.makedirs(img_dir, exist_ok=True)
work = build_work(rows, img_dir)
total = len(work)
fetched = skipped = failed = 0
for row in rows:
for idx, (lid, n, url, dst) in enumerate(work, 1):
if limit is not None and fetched >= limit:
break
dst = os.path.join(img_dir, '%d.webp' % row['id'])
if os.path.exists(dst):
skipped += 1
continue
result = fetch_one(row['img_url'], dst)
result = fetch_one(url, dst)
if result == 'fetched':
fetched += 1
if not quiet:
print(' [%d/%d] id=%d n=%s ok' % (idx, total, lid, n))
else:
failed += 1
if not quiet:
print(' fail id=%d %s' % (row['id'], result))
print(' fail id=%d n=%s %s' % (lid, n, result))
time.sleep(sleep)
return {'fetched': fetched, 'skipped': skipped, 'failed': failed}
return {'fetched': fetched, 'skipped': skipped, 'failed': failed,
'total': total}
def main(argv=None):
@@ -105,8 +156,8 @@ def main(argv=None):
args = ap.parse_args(argv)
res = run_fetch(args.db, args.img_dir, limit=args.limit, sleep=args.sleep)
print('fetched %d · skipped %d · failed %d' % (
res['fetched'], res['skipped'], res['failed']))
print('fetched %d · skipped %d · failed %d · total %d' % (
res['fetched'], res['skipped'], res['failed'], res['total']))
return 0

View File

@@ -88,6 +88,24 @@ def iter_items(st):
yield src_id, it
def imgs_from_item(it):
"""Gallery images from a state item: deduplicated, non-empty URL strings
in page order, or None. The hunt guarantees imgs[0] equals `img` (the
established primary)."""
raw = it.get('imgs')
if not isinstance(raw, (list, tuple)):
return None
out, seen = [], set()
for u in raw:
if not isinstance(u, str):
continue
u = u.strip()
if u and u not in seen:
seen.add(u)
out.append(u)
return out or None
def normalize(region, src_id, it, now):
"""Normalize one state item into a row dict (strings->ints robustly,
missing fields -> NULL)."""
@@ -102,6 +120,10 @@ def normalize(region, src_id, it, now):
year = rubric.year_from_title(title)
score, family = rubric.score_item(title, it.get('desc') or '', price_num, km, year)
extras = rubric.extras_from_text(title, it.get('desc') or '')
# B1: gallery (page order, entry 0 = the primary). img_url keeps the
# established single `img` signal and falls back to imgs[0].
imgs = imgs_from_item(it)
img = it.get('img') or (imgs[0] if imgs else None)
# seller_type only from a REAL signal in the state item (the hunt image
# backfill / enrichment passes) — never inferred from titles.
seller = it.get('seller_type') or it.get('seller')
@@ -116,7 +138,8 @@ def normalize(region, src_id, it, now):
'km': km,
'location': (it.get('loc') or it.get('city') or None),
'url': it.get('url') or '',
'img_url': it.get('img') or None,
'img_url': img,
'imgs': json.dumps(imgs) if imgs else None,
'desc': it.get('desc') or None,
'family': family,
'score': score,
@@ -138,25 +161,25 @@ def upsert(conn, row, now):
if existing:
conn.execute(
'''UPDATE listings SET title=?, price_display=?, price_num=?, km=?,
location=?, url=?, img_url=?, desc=?, family=?, score=?,
location=?, url=?, img_url=?, imgs=?, desc=?, family=?, score=?,
year=?, seller_type=?, extras=?,
status='active', dup_of=NULL, last_seen=?, updated_at=?
WHERE id=?''',
(row['title'], row['price_display'], row['price_num'], row['km'],
row['location'], row['url'], row['img_url'], row['desc'],
row['family'], row['score'], row['year'], row['seller_type'],
row['extras'], now, now, existing['id']))
row['location'], row['url'], row['img_url'], row['imgs'],
row['desc'], row['family'], row['score'], row['year'],
row['seller_type'], row['extras'], now, now, existing['id']))
return existing['id'], False
cur = conn.execute(
'''INSERT INTO listings (region, src_id, title, price_display, price_num,
km, location, url, img_url, desc, family, score, year, seller_type,
extras, status, dup_of,
km, location, url, img_url, imgs, desc, family, score, year,
seller_type, extras, status, dup_of,
first_seen, last_seen, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(row['region'], row['src_id'], row['title'], row['price_display'],
row['price_num'], row['km'], row['location'], row['url'],
row['img_url'], row['desc'], row['family'], row['score'], row['year'],
row['seller_type'], row['extras'], 'active',
row['img_url'], row['imgs'], row['desc'], row['family'], row['score'],
row['year'], row['seller_type'], row['extras'], 'active',
None, row['first_seen'], row['last_seen'], now, now))
return cur.lastrowid, True

489
app/sections.py Normal file
View File

@@ -0,0 +1,489 @@
"""Bike-Tinder P3 section engine — compose / read / exhaust / forge.
The deck is one finite SECTION at a time (default 25 cards):
2 wildcards + <=5 returns (<=2 nopes, "surprising" only) + remainder new,
per docs/RANKING.md + docs/DATA-MODEL.md "P3 additions".
Pinned order within a section (position 1 first):
new (points DESC) -> returns (maybe > like > nope, points DESC within
role) -> wildcards (wildcard score DESC). "The best suggestions lead"
(docs/PLAN.md); returns form their own sub-sequence in role order;
wildcard stretches sit at the back. (Wildcard placement inside the deck
is not pinned anywhere in the frozen docs — noted in the P3 report.)
Constraints: <=4 per non-NULL family per section; >=3 price bands when the
pool allows (soft repair swaps new-slot items within the final list).
A re-nope (round>=2 nope event) rests an item: no more second looks.
Rounds cap at 2 for influence (taste.py); returns are offered once.
Modes: 'ondemand' (default) — forge runs the agent pass then composes;
'strict' — forge refuses (409) unless a taste_profile strictly newer than
the current section's profile_version exists.
"""
import json
from . import taste as tastemod
from .taste import NOPE
FAMILY_CAP = 4
MAX_RETURNS = 5
MAX_RETURN_NOPES = 2
WILDCARDS = 2
DEFAULT_SIZE = 25
MIN_BANDS = 3
LAST_TWO_SQL = """
SELECT si.listing_id FROM section_items si
WHERE si.section_id IN (SELECT id FROM sections ORDER BY id DESC LIMIT 2)
"""
NEW_POOL_SQL = """
SELECT l.* FROM listings l
WHERE l.status = 'active'
AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id)
"""
RETURNS_SQL = """
SELECT l.*, s.action AS verdict FROM listings l
JOIN swipes s ON s.listing_id = l.id
WHERE l.status = 'active'
AND NOT EXISTS (SELECT 1 FROM swipe_events e
WHERE e.listing_id = l.id AND e.round >= 2
AND e.action = 'nope')
AND NOT EXISTS (SELECT 1 FROM section_items si
WHERE si.listing_id = l.id AND si.slot = 'return')
"""
WILDCARD_SQL = """
SELECT l.* FROM listings l
WHERE l.status = 'active'
AND NOT EXISTS (SELECT 1 FROM swipe_events e WHERE e.listing_id = l.id)
"""
# --- helpers ----------------------------------------------------------------
def _iso(now):
return now.strftime('%Y-%m-%dT%H:%M:%SZ')
def last_two_listing_ids(conn):
return {r['listing_id'] for r in conn.execute(LAST_TWO_SQL)}
def get_current_section(conn):
"""The newest open/report section, or None."""
return conn.execute(
"SELECT * FROM sections WHERE status IN ('open','report')"
' ORDER BY id DESC LIMIT 1').fetchone()
def latest_profile(conn):
return conn.execute(
'SELECT * FROM taste_profile ORDER BY version DESC LIMIT 1'
).fetchone()
def build_engine(conn, profile=None, now=None):
"""TasteEngine over active listings + every listing an event touches,
with the given profile payload (default: the latest version)."""
rows = [r for r in conn.execute(
"SELECT * FROM listings WHERE status='active'")]
ev_ids = {r['listing_id'] for r in conn.execute(
'SELECT DISTINCT listing_id FROM swipe_events')}
missing = ev_ids - {r['id'] for r in rows}
if missing:
qmarks = ','.join('?' * len(missing))
rows += [r for r in conn.execute(
'SELECT * FROM listings WHERE id IN (%s)' % qmarks,
tuple(missing))]
events = conn.execute('SELECT * FROM swipe_events ORDER BY id').fetchall()
payload = None
if profile is None:
profile = latest_profile(conn)
if profile is not None and profile['payload']:
try:
payload = json.loads(profile['payload'])
except ValueError:
payload = None
return tastemod.TasteEngine(rows, events, payload, now=now)
def fresh_profile_exists(conn, section):
"""Strict-mode freshness: a profile version newer than the section's."""
cur = section['profile_version'] if section else None
if latest_profile(conn) is None:
return False
if cur is None:
return True
return latest_profile(conn)['version'] > cur
# --- wildcard novelty --------------------------------------------------------
def _cosine(a, b):
dot = sum(a.get(k, 0.0) * b.get(k, 0.0) for k in (a.keys() | b.keys()))
na = sum(x * x for x in a.values()) ** 0.5
nb = sum(x * x for x in b.values()) ** 0.5
if not na or not nb:
return 0.0
return dot / (na * nb)
def novelty(rows_by_id, liked_ids, candidate_ids):
"""0..1 distance from the liked-profile centroid (1 = very novel)."""
if not liked_ids:
return {lid: 0.5 for lid in candidate_ids}
centroid = {}
liked = [l for l in liked_ids if l in rows_by_id]
for lid in liked:
for d, v in tastemod.extract_features(rows_by_id[lid]):
centroid[(d, v)] = centroid.get((d, v), 0.0) + 1.0
n = len(liked) or 1
centroid = {k: v / n for k, v in centroid.items()}
out = {}
for lid in candidate_ids:
vec = {(d, v): 1.0 for d, v in tastemod.extract_features(rows_by_id[lid])}
out[lid] = 1.0 - _cosine(vec, centroid)
return out
# --- compose -----------------------------------------------------------------
def _points_key(engine, row):
"""Deterministic sort key: points, rubric score, first_seen, id.
Use with reverse=True for descending order."""
return (engine.points(row['id']), row['score'] or 0,
row['first_seen'] or '', row['id'])
def pick_new(engine, candidates, want, fam_count, picked_ids):
"""Greedy new picks by points DESC honoring the family cap."""
out = []
ordered = sorted(candidates, key=lambda r: _points_key(engine, r),
reverse=True)
for cand in ordered:
if len(out) >= want:
break
lid = cand['id']
if lid in picked_ids:
continue
fam = cand['family']
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
continue
out.append(cand)
picked_ids.add(lid)
if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1
return out
def repair_bands(engine, picked, candidates, fam_count, picked_ids):
"""Swap new-slot items to reach >=3 price bands when the pool allows.
Only swaps INTO slots of new items in over-represented bands; returns
and wildcard slots are never touched. Deterministic; bounded passes.
"""
if len(picked) < MIN_BANDS:
return picked
bands_of = {r['id']: tastemod.price_band(r['price_num']) for r in candidates}
bands_of.update({r['id']: tastemod.price_band(r['price_num'])
for r in picked})
cur_bands = {bands_of[r['id']] for r in picked
if bands_of[r['id']] is not None}
if len(cur_bands) >= MIN_BANDS:
return picked
pool_bands = {bands_of[r['id']] for r in candidates
if bands_of[r['id']] is not None}
if len(pool_bands) < MIN_BANDS:
return picked # the pool does not allow 3 bands
picked = list(picked)
attempts = len(candidates)
for _ in range(attempts):
cur_bands = {bands_of[r['id']] for r in picked
if bands_of[r['id']] is not None}
if len(cur_bands) >= MIN_BANDS:
break
missing = [b for b in pool_bands if b not in cur_bands]
if not missing:
break
want_band = missing[0]
best = None
best_key = None
for cand in candidates:
if cand['id'] in picked_ids:
continue
if bands_of[cand['id']] != want_band:
continue
fam = cand['family']
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
continue
key = _points_key(engine, cand)
if best_key is None or key > best_key:
best, best_key = cand, key
if best is None:
pool_bands = pool_bands - {want_band}
if len(pool_bands) < MIN_BANDS:
break
continue
# victim: lowest-points new-slot item from a band with >=2 reps
counts = {}
for r in picked:
b = bands_of[r['id']]
if b is not None:
counts[b] = counts.get(b, 0) + 1
victim = None
worst_first = sorted(picked, key=lambda r: _points_key(engine, r),
reverse=True)
for band in sorted(counts, key=lambda b: -counts[b]):
if counts[band] < 2:
continue
in_band = [r for r in worst_first if bands_of[r['id']] == band]
if in_band:
victim = in_band[-1]
break
if victim is None:
break
victim_fam = victim['family']
idx = next(i for i, r in enumerate(picked) if r['id'] == victim['id'])
picked[idx] = best
if victim_fam and victim_fam in fam_count:
fam_count[victim_fam] = max(0, fam_count[victim_fam] - 1)
if best['family']:
fam_count[best['family']] = fam_count.get(best['family'], 0) + 1
picked_ids.remove(victim['id'])
picked_ids.add(best['id'])
return picked
def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
note='', now=None):
"""Compose and persist a new open section; returns the section row.
Caller closes/reports any prior section first. `engine` scores the
candidates (a TasteEngine over active listings + events + profile).
"""
import datetime
now = now or datetime.datetime.now(datetime.timezone.utc).replace(
tzinfo=None)
size = int(size)
rows_by_id = {r['id']: r for r in engine.listings}
last_two = last_two_listing_ids(conn)
new_cands = [r for r in conn.execute(NEW_POOL_SQL)
if r['id'] not in last_two]
ret_cands = [r for r in conn.execute(RETURNS_SQL)
if r['id'] not in last_two]
wild_cands = [r for r in conn.execute(WILDCARD_SQL)
if r['id'] not in last_two]
# wildcard family exclusions: >=3 cumulative nopes AND net-negative.
# Stats are computed for every family the engine knows (active + event
# listings), not just current candidates — a hard-noped family is
# excluded even when its rows are gone/cleared.
fam_stats = {f: engine.family_net(f)
for f in {r['family'] for r in engine.listings if r['family']}}
excluded_fams = {f for f, (net, nopes) in fam_stats.items()
if nopes >= 3 and net < 0}
wild_cands = [r for r in wild_cands
if not (r['family'] and r['family'] in excluded_fams)]
fam_count = {}
picked_ids = set()
# 1) preliminary top-3 new (uncommitted) — keys the wildcard preference
top3 = pick_new(engine, new_cands, 3, {}, set())
top_fams = {r['family'] for r in top3 if r['family']}
top_bands = {tastemod.price_band(r['price_num']) for r in top3
if tastemod.price_band(r['price_num']) is not None}
# 2) returns: maybe > like > nope, points DESC within role; nopes must be
# "surprising" (points >=55 or net-positive family); <=5, <=2 nopes.
# Budget: wildcards keep their 2 slots; returns never push the
# section past `size`.
wanted_rets = min(MAX_RETURNS, max(0, size - min(WILDCARDS, size)))
role_order = {'maybe': 0, 'like': 1, 'nope': 2}
rets = []
nope_count = 0
for cand in sorted(ret_cands, reverse=True,
key=lambda r: (-role_order.get(r['verdict'], 9),
_points_key(engine, r))):
if len(rets) >= wanted_rets:
break
fam = cand['family']
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
continue
if cand['verdict'] == NOPE:
if nope_count >= MAX_RETURN_NOPES:
continue
net, _nopes = fam_stats.get(fam, (0.0, 0))
if engine.points(cand['id']) < 55 and not (fam and net > 0):
continue
nope_count += 1
rets.append(cand)
picked_ids.add(cand['id'])
if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1
# 3) wildcards: min(2, size), preferred different family AND price band
liked_ids = [r['listing_id'] for r in conn.execute(
"SELECT listing_id FROM swipes WHERE action='like'")]
nov = novelty(rows_by_id, liked_ids,
[r['id'] for r in wild_cands])
wild_key = lambda r: (
-(0.5 * engine.points(r['id']) + 50.0 * nov[r['id']]), r['id'])
def _wild_preferred(rs):
return [r for r in rs
if (not r['family'] or r['family'] not in top_fams)
and (tastemod.price_band(r['price_num']) is None
or tastemod.price_band(r['price_num']) not in top_bands)]
n_wilds = min(WILDCARDS, size)
wilds = []
avail = sorted([r for r in wild_cands if r['id'] not in picked_ids],
key=wild_key)
preferred = sorted(_wild_preferred(avail), key=wild_key)
pref_ids = {r['id'] for r in preferred}
for cand in preferred + [r for r in avail if r['id'] not in pref_ids]:
if len(wilds) >= n_wilds:
break
fam = cand['family']
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
continue
wilds.append(cand)
picked_ids.add(cand['id'])
if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1
n_wilds = len(wilds) # pool shortfall reverts to new
# 4) new fills the remainder of the section (size - returns - wildcards)
want_new = max(0, size - len(rets) - n_wilds)
picked_new = pick_new(engine, new_cands, want_new, fam_count, picked_ids)
# final pinned order: new -> returns -> wildcards
ordered = list(picked_new) + rets + wilds
# price-band repair (new slots only — ordered[:len(picked_new)])
n_new = len(picked_new)
before = len({tastemod.price_band(r['price_num']) for r in ordered
if tastemod.price_band(r['price_num']) is not None})
if n_new:
ordered[:n_new] = repair_bands(engine, ordered[:n_new], new_cands,
fam_count, picked_ids)
after = len({tastemod.price_band(r['price_num']) for r in ordered
if tastemod.price_band(r['price_num']) is not None})
recipe = {
'new': len(picked_new),
'return': len(rets),
'wildcard': len(wilds),
'bands': before if before == after else after,
'caps_applied': {f: fam_count[f] for f in fam_count
if fam_count[f] >= FAMILY_CAP},
'note': note or ('bands %d->%d repaired'
% (before, after) if before != after else ''),
}
cur = conn.execute(
"""INSERT INTO sections (status, size, profile_version, recipe,
created_at)
VALUES ('open', ?, ?, ?, ?)""",
(size, profile_version, json.dumps(recipe), _iso(now)))
section_id = cur.lastrowid
conn.executemany(
"""INSERT INTO section_items (section_id, listing_id, slot, position)
VALUES (?, ?, ?, ?)""",
[(section_id, r['id'], slot, pos)
for pos, (r, slot) in enumerate(
[(r, 'new') for r in ordered[:n_new]]
+ [(r, 'return') for r in rets]
+ [(r, 'wildcard') for r in wilds], 1)])
conn.commit()
return conn.execute('SELECT * FROM sections WHERE id=?',
(section_id,)).fetchone()
# --- read / exhaust ----------------------------------------------------------
def close_section(conn, section, now, status='closed',
created_at_col='closed_at'):
conn.execute(
'UPDATE sections SET status=?, %s=? WHERE id=?' % created_at_col,
(status, _iso(now), section['id']))
conn.commit()
def section_items(conn, section):
return conn.execute(
"""SELECT l.*, s.action AS decision, s.last_photo_idx AS last_photo_idx,
si.slot, si.position
FROM section_items si
JOIN listings l ON l.id = si.listing_id
LEFT JOIN swipes s ON s.listing_id = l.id
WHERE si.section_id = ?
ORDER BY si.position ASC""", (section['id'],)).fetchall()
def section_counts(conn, section):
"""Per-action counts + second-look / wildcard engagement for this
section (events stamped with this section_id only)."""
counts = {'likes': 0, 'maybes': 0, 'nopes': 0}
for item in section_items(conn, section):
if item['decision'] == 'like':
counts['likes'] += 1
elif item['decision'] == 'maybe':
counts['maybes'] += 1
elif item['decision'] == 'nope':
counts['nopes'] += 1
restruck = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
WHERE e.section_id = ? AND si.slot = 'return'""",
(section['id'],)).fetchone()['n']
wild_hits = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
WHERE e.section_id = ? AND si.slot = 'wildcard'
AND e.action IN ('like','maybe')""",
(section['id'],)).fetchone()['n']
counts['restruck'] = restruck
counts['wildcard_hits'] = wild_hits
return counts
def maybe_exhaust(conn, section, now):
"""Flip an all-decided open section to 'report' (returns the fresh row)."""
if section['status'] != 'open':
return section
items = section_items(conn, section)
if all(it['decision'] is not None for it in items):
close_section(conn, section, now, status='report')
return conn.execute('SELECT * FROM sections WHERE id=?',
(section['id'],)).fetchone()
return section
def read_section(conn, section, now):
"""Meta + items + progress + exhausted for GET /api/section."""
section = maybe_exhaust(conn, section, now)
items = section_items(conn, section)
decided = sum(1 for it in items if it['decision'] is not None)
return {
'section': {
'id': section['id'],
'status': section['status'],
'size': section['size'],
'profile_version': section['profile_version'],
'recipe': json.loads(section['recipe']) if section['recipe']
else {},
'created_at': section['created_at'],
'closed_at': section['closed_at'],
},
'items': items,
'progress': {'decided': decided, 'undecided': len(items) - decided,
'total': len(items)},
'exhausted': section['status'] != 'open',
}

View File

@@ -1,9 +1,12 @@
"""Dependency-free self-check for the Phase 1 data spine.
"""Dependency-free self-check for the Bike-Tinder data spine.
python3 -m app.selfcheck
Covers: gate rejections, dedupe (incl. false-positive sanity), scoring
spot-checks against the reference rubric, and gone-after-2-misses.
spot-checks against the reference rubric, gone-after-2-misses, the P2/P3
API surface (on temp copies of the DB only — the live file is never opened
for writing), and the B1 photo-experience checks (gallery import,
/img?i= chain, photo_idx persistence, rotation math, 640-row stability).
"""
import datetime
import json
@@ -308,10 +311,23 @@ TESTS = [
PAYLOAD_KEYS = {'id', 'title', 'price_display', 'price_num', 'km', 'location',
'region', 'url', 'img', 'desc', 'family', 'score', 'year',
'seller_type', 'extras', 'decision', 'first_seen'}
'seller_type', 'extras', 'decision', 'first_seen',
'n_photos', 'last_photo_idx'} # B1 payload additions
# P3 additive payload fields (UI-SPEC §10.7)
P3_PAYLOAD_KEYS = {'points', 'points_breakdown', 'tank', 'slot', 'round_next'}
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LIVE_DB = os.path.join(REPO_ROOT, 'data', 'bike-tinder.sqlite3')
LIVE_DB = os.environ.get('BT_TEST_DB') or os.path.join(
REPO_ROOT, 'data', 'bike-tinder.sqlite3')
if not os.path.exists(LIVE_DB):
# B1 test-arena copy (the brief's "copy DB"): pristine snapshot of the
# live DB, never written by selfcheck (tests copy it further).
_b1_copy = '/var/home/admin/dev/bike-tinder-b1/test-copy.sqlite3'
if os.path.exists(_b1_copy):
LIVE_DB = _b1_copy
B1_STATE_CANDIDATES = ('/var/home/admin/dev/bike-tinder-b1',
os.path.expanduser(
'~/.hermes/scripts/motorcycle-hunt.d'))
# --- P2 API checks (need the venv: fastapi + httpx). Run against a TEMP COPY
# --- of the live DB — the live file is never opened by these tests. ---------
@@ -358,13 +374,17 @@ def test_api_stats_health():
active = conn.execute(
"SELECT COUNT(*) AS n FROM listings WHERE status='active'").fetchone()[0]
total = conn.execute('SELECT COUNT(*) AS n FROM listings').fetchone()[0]
expect_new = conn.execute(
"""SELECT COUNT(*) AS n FROM listings
WHERE status='active' AND COALESCE(first_seen,'') > COALESCE(
(SELECT value FROM prefs WHERE key='badge_seen_at'), '')"""
).fetchone()[0]
conn.close()
s = c.get('/api/stats').json()
assert s['live'] == active, (s['live'], active)
assert set(s) == {'live', 'new', 'likes', 'maybes', 'nopes', 'last_import'}
assert s['last_import'] is not None and s['last_import']['ok'] is True
# badge never stamped on a fresh copy -> every active row is new
assert s['new'] == active, (s['new'], active)
assert s['new'] == expect_new, (s['new'], expect_new)
h = c.get('/api/health').json()
assert h['ok'] is True
assert h['counts']['active'] == active and h['counts']['total'] == total
@@ -401,8 +421,12 @@ def test_api_deck():
# monotonic page 1 + only undecided + payload contract
prev = None
for it in items:
assert set(it.keys()) == PAYLOAD_KEYS, set(it.keys())
assert set(it.keys()) == PAYLOAD_KEYS | P3_PAYLOAD_KEYS, \
set(it.keys()) ^ (PAYLOAD_KEYS | P3_PAYLOAD_KEYS)
assert it['decision'] is None
if 'tank' in PAYLOAD_KEYS | P3_PAYLOAD_KEYS:
assert isinstance(it['tank'], dict) and \
it['tank'].get('band') in ('like', 'maybe', 'nope')
tup = (it['score'], it['first_seen'] or '', it['id'])
if prev:
assert tup <= prev, (tup, prev)
@@ -518,6 +542,769 @@ def test_api_badge_roundtrip():
shutil.rmtree(d, ignore_errors=True)
# --- P3 checks (temp copies of the live DB; the live file is never opened) --
def p3_env():
"""Temp copy of the live DB + TestClient + the copy's path."""
if not os.path.exists(LIVE_DB):
raise RuntimeError('live DB not found (%s)' % LIVE_DB)
d = tempfile.mkdtemp(prefix='bt-p3-')
db_path = os.path.join(d, 'test.sqlite3')
shutil.copy2(LIVE_DB, db_path)
return d, TestClient(create_app(db_path, os.path.join(d, 'img'))), db_path
def _sq_conn(db_path):
import sqlite3
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def test_p3_event_seeding_idempotent():
d, c, db_path = p3_env()
try:
conn = _sq_conn(db_path)
n_swipes = conn.execute('SELECT COUNT(*) AS n FROM swipes').fetchone()['n']
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
n_events_before = 0
if 'swipe_events' in tables:
n_events_before = conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events').fetchone()['n']
conn.close()
conn = db.connect(db_path)
db.init_db(conn)
n1 = conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events').fetchone()['n']
if n_events_before == 0:
# fresh pre-P3 DB: seeded exactly once from swipes rows
assert n1 == n_swipes, (n1, n_swipes)
seeded = conn.execute(
'SELECT listing_id, round, created_at FROM swipe_events LIMIT 1'
).fetchone()
assert seeded['round'] == 1
match = conn.execute(
'SELECT created_at FROM swipes WHERE listing_id=?',
(seeded['listing_id'],)).fetchone()
assert seeded['created_at'] == match['created_at']
else:
# migrated DB that already carries events (cleared swipes keep
# their history) — seeding must not fire, events stay as found
assert n1 == n_events_before, (n1, n_events_before)
# second init does not reseed/duplicate
db.init_db(conn)
assert conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events').fetchone()['n'] == n1
# a new swipe row after seeding is NOT re-seeded (only-when-empty)
conn.execute(
"""INSERT INTO swipes (listing_id, action)
SELECT id, 'like' FROM listings
WHERE id NOT IN (SELECT listing_id FROM swipes)
AND status='active' LIMIT 1""")
conn.commit()
db.init_db(conn)
assert conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events').fetchone()['n'] == n1
conn.close()
finally:
shutil.rmtree(d, ignore_errors=True)
def test_p3_round_math_and_clear():
d, c, db_path = p3_env()
try:
s = c.get('/api/section').json() # first-read cold compose
assert s['section']['status'] == 'open'
it = [i for i in s['items'] if i['decision'] is None][0]
lid, sec = it['id'], s['section']['id']
r1 = c.post('/api/swipe', json={'listing_id': lid, 'action': 'like',
'section_id': sec}).json()
assert r1['round'] == 1 and r1['section_id'] == sec
r2 = c.post('/api/swipe', json={'listing_id': lid, 'action': 'nope',
'section_id': sec}).json()
assert r2['round'] == 2
r3 = c.post('/api/swipe', json={'listing_id': lid,
'action': 'nope'}).json() # list edit
assert r3['round'] == 3 and r3['section_id'] is None
conn = _sq_conn(db_path)
evs = conn.execute(
'SELECT action, round, section_id FROM swipe_events'
' WHERE listing_id=? ORDER BY id', (lid,)).fetchall()
shapes = [(e['action'], e['round'], e['section_id']) for e in evs]
assert shapes[0] == ('like', 1, sec), shapes
assert shapes[1] == ('nope', 2, sec), shapes
assert shapes[2] == ('nope', 3, None), shapes
# clear: UI verdict gone, event history kept
r = c.delete('/api/swipe/%d' % lid)
assert r.status_code == 200 and r.json()['cleared'] is True
items = c.get('/api/list?decision=all&limit=500').json()['items']
row = [i for i in items if i['id'] == lid][0]
assert row['decision'] is None
assert conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events WHERE listing_id=?',
(lid,)).fetchone()['n'] == 3
# re-decide after clear -> round 4 (stored true round)
r4 = c.post('/api/swipe', json={'listing_id': lid,
'action': 'maybe'}).json()
assert r4['round'] == 4
conn.close()
# engine rule: round>=2 weighs exactly 2x round 1 (same recency)
from . import taste
now = datetime.datetime(2026, 11, 11, 0, 0, 0)
w1 = taste.event_weight('like', 1, '2026-11-10T00:00:00Z', now)
w2 = taste.event_weight('like', 2, '2026-11-10T00:00:00Z', now)
assert abs(w2 - 2.0 * w1) < 1e-9, (w1, w2)
w3 = taste.event_weight('like', 9, '2026-11-10T00:00:00Z', now)
assert abs(w3 - w2) < 1e-9 # rounds cap at 2
finally:
shutil.rmtree(d, ignore_errors=True)
def test_p3_section_invariants():
d, c, db_path = p3_env()
try:
from . import taste
s = c.get('/api/section').json()
assert {'section', 'items', 'progress', 'exhausted', 'mode',
'can_forge', 'counts', 'read_info'} <= set(s), set(s)
items = s['items']
assert len(items) == 25 and s['section']['size'] == 25
by_slot = {}
for it in items:
by_slot.setdefault(it['slot'], []).append(it)
assert it['slot'] in ('new', 'return', 'wildcard')
assert len(by_slot['wildcard']) == 2
assert len(by_slot['return']) <= 5
assert sum(1 for i in by_slot['return'] if i['decision'] == 'nope') <= 2
assert len(by_slot['return']) + len(by_slot['wildcard']) + \
len(by_slot['new']) == 25
# returns carry a verdict and come after every new item, maybe>like>nope
roles = ['new', 'return', 'wildcard']
seen = []
for it in items:
seen.append(it['slot'])
if it['slot'] == 'return':
assert it['decision'] is not None
first_return = seen.index('return') if 'return' in seen else None
if first_return is not None:
assert all(x == 'new' for x in seen[:first_return])
assert all(x != 'wildcard' for x in
seen[:seen.index('wildcard', first_return)])
ret_roles = [i['decision'] for i in by_slot['return']]
order = {'maybe': 0, 'like': 1, 'nope': 2}
assert ret_roles == sorted(ret_roles, key=lambda a: order[a]), \
ret_roles
# family cap <=4 (only non-NULL families are capped)
fam = {}
for it in items:
if it['family']:
fam[it['family']] = fam.get(it['family'], 0) + 1
assert all(v <= 4 for v in fam.values()), fam
# unique positions 1..25, pinned order == payload order
conn = _sq_conn(db_path)
pos = [r['position'] for r in conn.execute(
'SELECT position FROM section_items WHERE section_id=?',
(s['section']['id'],))]
assert sorted(pos) == list(range(1, 26)), pos[:10]
# >=3 price bands (the pool allows — 563 active rows)
bands = {taste.price_band(it['price_num']) for it in items}
assert len(bands - {None}) >= 3, bands
# wildcards are unseen + not in any prior section (fresh copy)
for w in by_slot['wildcard']:
n = conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events WHERE listing_id=?',
(w['id'],)).fetchone()['n']
assert n == 0, (w['id'], n)
# undecided items at read show decision None
assert all(it['decision'] is None for it in items
if it['slot'] == 'new')
# size override forge composes exactly that size
f = c.post('/api/section/forge', json={'size': 12}).json()
assert len(f['items']) == 12 and f['section']['size'] == 12
slots = {}
for it in f['items']:
slots.setdefault(it['slot'], []).append(it)
assert len(slots['wildcard']) == min(2, 12)
assert len(slots['return']) <= 5
assert sum(len(v) for v in slots.values()) == 12
conn.close()
finally:
shutil.rmtree(d, ignore_errors=True)
def test_p3_exhaust_and_report():
d, c, db_path = p3_env()
try:
c.post('/api/prefs', json={'key': 'forge_mode', 'value': 'ondemand'})
f = c.post('/api/section/forge', json={'size': 6}).json()
sid = f['section']['id']
for it in [i for i in f['items'] if i['decision'] is None]:
r = c.post('/api/swipe', json={'listing_id': it['id'],
'action': 'like', 'section_id': sid})
assert r.status_code == 200
s = c.get('/api/section').json()
assert s['exhausted'] is True
assert s['section']['status'] == 'report'
assert s['progress'] == {'decided': 6, 'undecided': 0, 'total': 6}, \
s['progress']
assert s['counts']['likes'] + s['counts']['maybes'] + \
s['counts']['nopes'] == 6, s['counts']
assert s['can_forge'] is True and s['mode'] == 'ondemand'
finally:
shutil.rmtree(d, ignore_errors=True)
def test_p3_forge_modes_strict_409():
d, c, db_path = p3_env()
try:
s = c.get('/api/section').json() # composed on profile None
r = c.post('/api/prefs', json={'key': 'forge_mode', 'value': 'strict'})
assert r.status_code == 200 and r.json()['value'] == 'strict'
fr = c.post('/api/section/forge', json={'size': 10})
assert fr.status_code == 409, fr.status_code
ar = c.post('/api/agent/run', json={'source': 'mock'})
assert ar.status_code == 200 and ar.json()['source'] == 'mock'
v = ar.json()['profile_version']
fr = c.post('/api/section/forge', json={'size': 10})
assert fr.status_code == 200, fr.text
f = fr.json()
assert f['section']['profile_version'] == v
assert 'strict' in (f.get('note') or '')
# ondemand forge runs a (mock) pass itself: version moves again
c.post('/api/prefs', json={'key': 'forge_mode', 'value': 'ondemand'})
f2 = c.post('/api/section/forge', json={'size': 10}).json()
assert f2['section']['profile_version'] > v, \
(f2['section']['profile_version'], v)
assert 'pass mock' in (f2.get('note') or '')
# live without a key refuses loudly
assert c.post('/api/agent/run',
json={'source': 'live'}).status_code == 400
finally:
shutil.rmtree(d, ignore_errors=True)
def test_p3_trends_shape_and_stats():
d, c, db_path = p3_env()
try:
t = c.get('/api/trends').json()
assert set(t) == {'profile', 'stats', 'last_run', 'cold_start',
'forge_mode'}, set(t)
st = t['stats']
assert {'decided', 'likes', 'maybes', 'nopes', 'hit_rate',
'hit_rate_plus_maybe', 'returns_placed',
'returns_re_struck', 'wildcards_placed', 'wildcards_hit',
'flips'} <= set(st), set(st)
if st['decided']:
assert st['likes'] + st['maybes'] + st['nopes'] == st['decided']
assert abs(st['hit_rate'] - st['likes'] / st['decided']) < 1e-9
else:
assert st['hit_rate'] is None
finally:
shutil.rmtree(d, ignore_errors=True)
def test_p3_agentpass_mock_and_fallback():
from . import agentpass as ag
d, _c, db_path = p3_env()
try:
res = ag.run_pass(db_path, source='mock')
assert res['ok'] is True and res['source'] == 'mock'
assert res['profile_version'] >= 1
ag.validate_payload(res['payload'])
conn = _sq_conn(db_path)
run = conn.execute(
'SELECT ok, source, profile_version FROM agent_runs'
' ORDER BY id DESC LIMIT 1').fetchone()
assert run['ok'] == 1 and run['source'] == 'mock'
assert run['profile_version'] == res['profile_version']
conn.close()
# deterministic: no randomness anywhere — same DB state + fixed clock
# yields identical summary and payload on repeated calls
now = datetime.datetime(2026, 11, 12, 0, 0, 0)
s1 = ag.build_summary(db.connect(db_path), now=now)
s2 = ag.build_summary(db.connect(db_path), now=now)
assert s1 == s2, 'strike summary must be deterministic'
p1 = ag.synthesize_payload(db.connect(db_path), now=now)
p2 = ag.synthesize_payload(db.connect(db_path), now=now)
assert p1 == p2, 'mock payload must be deterministic'
ag.validate_payload(p1)
# fallback: live attempt fails -> deterministic fallback profile.
# (The copy may carry profiles from earlier live runs — scope the
# assertion to the versions created by THIS test.)
new_min = res['profile_version']
os.environ['OPENROUTER_API_KEY'] = 'test-key-never-used'
orig = ag._live_call
ag._live_call = lambda model, summary: (None, None, 'boom', None)
try:
res3 = ag.run_pass(db_path, source='live')
finally:
ag._live_call = orig
del os.environ['OPENROUTER_API_KEY']
assert res3['source'] == 'fallback' and res3['ok'] is False
assert res3['profile_version'] > res['profile_version']
ag.validate_payload(res3['payload'])
conn = _sq_conn(db_path)
sources = [r['source'] for r in conn.execute(
'SELECT source FROM taste_profile WHERE version >= ?'
' ORDER BY version', (new_min,))]
assert sources == ['mock', 'fallback'], sources
conn.close()
finally:
shutil.rmtree(d, ignore_errors=True)
def test_p3_points_and_band():
from . import taste
now = datetime.datetime(2026, 11, 10, 12, 0, 0)
def L(i, title='2021 Indian Scout Bobber', desc='MVI done, red',
family=None, score=0, price=None, km=None, year=2021, seller=None,
extras=None, region='ns'):
return {'id': i, 'title': title, 'desc': desc, 'family': family,
'score': score, 'price_num': price, 'km': km, 'year': year,
'seller_type': seller, 'extras': extras, 'region': region}
fam = 'Indian Scout — top priority (sport×cruise)'
listings = [
L(1, family=fam, score=91),
L(2, '2018 Vulcan 900 Classic', 'black',
family='Vulcan 900 Classic — the dream spec', score=73,
price=5200, km=20000, year=2018),
L(3, '2015 C50', family='Boulevard C50', score=50,
price=4300, km=88000, year=2015),
L(4, '2019 Rebel 500', family='Rebel 500', score=50,
price=6100, km=4000, year=2019),
]
events = [
{'listing_id': 1, 'action': 'like', 'round': 1,
'created_at': '2026-11-05T12:00:00Z'},
{'listing_id': 2, 'action': 'like', 'round': 1,
'created_at': '2026-11-05T12:00:00Z'},
{'listing_id': 1, 'action': 'like', 'round': 2,
'created_at': '2026-11-08T12:00:00Z'},
{'listing_id': 3, 'action': 'nope', 'round': 1,
'created_at': '2026-11-05T12:00:00Z'},
{'listing_id': 4, 'action': 'maybe', 'round': 1,
'created_at': '2026-11-05T12:00:00Z'},
]
eng = taste.TasteEngine(listings, events, None, now=now)
assert eng.w == min(0.85, 5 / 60.0)
assert eng.points(1) == 91 # known answers for these exact fixtures
assert eng.points(2) == 70
assert eng.points(3) == 47
assert eng.points(4) == 51
# zero events: w=0 -> points == rubric score
z = taste.TasteEngine(listings, [], None, now=now)
assert z.w == 0.0
assert all(z.points(l['id']) == l['score'] for l in listings)
# zero profile: engine identical to null adjustments
assert z.agent_adj(1) == 0.0
# band gates
assert taste.TasteEngine.band(29) == 'nope'
assert taste.TasteEngine.band(30) == 'maybe'
assert taste.TasteEngine.band(70) == 'maybe'
assert taste.TasteEngine.band(71) == 'like'
assert taste.TasteEngine.band(None) is None
# ramp caps at 0.85 with enough events
many = [{'listing_id': 1, 'action': 'like', 'round': 1,
'created_at': '2026-11-10T12:00:00Z'}] * 120
big = taste.TasteEngine(listings, many, None, now=now)
assert big.w == 0.85
# adjustments clamp at +/-10 per listing
prof = {'adjustments': [
{'feature': 'family', 'value': fam, 'delta': 7},
{'feature': 'color', 'value': 'red', 'delta': 5},
]}
c_eng = taste.TasteEngine(listings, events, prof, now=now)
assert c_eng.agent_adj(1) == 10.0
# breakdown is source-labeled
br = eng.breakdown(1)
assert {b['source'] for b in br} <= {'rubric', 'taste', 'agent'}
assert br[0]['source'] == 'rubric'
def test_p3_payload_additions():
d, c, db_path = p3_env()
try:
lst = c.get('/api/list?limit=5').json()['items']
assert lst
for it in lst:
assert set(it.keys()) == PAYLOAD_KEYS | P3_PAYLOAD_KEYS
assert it['tank'] and it['tank']['band'] in ('like', 'maybe',
'nope')
assert it['round_next'] in (1, 2)
assert it['slot'] is None
one = c.get('/api/listing/%d' % lst[0]['id']).json()
assert isinstance(one['points_breakdown'], list) and \
one['points_breakdown']
assert all('source' in l for l in one['points_breakdown'])
sec = c.get('/api/section').json()
for it in sec['items']:
assert it['slot'] in ('new', 'return', 'wildcard')
assert isinstance(it['points_breakdown'], list)
finally:
shutil.rmtree(d, ignore_errors=True)
# --- B1 checks (photo experience v2, UI-SPEC §11 / DATA-MODEL B1) -----------
def make_b1_item(title, imgs=None, img=None, price='$5,000', km='8000'):
it = make_item(title, price=price, km=km)
if img is not None:
it['img'] = img
if imgs is not None:
it['imgs'] = imgs
return it
def test_b1_migration_guarded():
d, db_path = fresh_db()
try:
conn = db.connect(db_path)
db.init_db(conn)
lc = {r['name'] for r in conn.execute(
"PRAGMA table_info('listings')")}
sc = {r['name'] for r in conn.execute("PRAGMA table_info('swipes')")}
assert 'imgs' in lc and 'last_photo_idx' in sc, (lc, sc)
db.init_db(conn) # second pass: idempotent
lc2 = {r['name'] for r in conn.execute(
"PRAGMA table_info('listings')")}
assert lc2 == lc
conn.close()
finally:
shutil.rmtree(d, ignore_errors=True)
def test_b1_importer_gallery_carry():
"""imgs -> listings.imgs (JSON, page order); img_url keeps `img` and falls
back to imgs[0]; empty/junk galleries -> NULL; re-import idempotent."""
d, db_path = fresh_db()
try:
state_dir = os.path.join(d, 'state')
CDN = 'https://media.kijiji.ca/api/v1/x/%s?rule=kijijica-640-webp'
n8 = [CDN % ('a%d' % i) for i in range(8)]
n3 = [CDN % ('b%d' % i) for i in range(3)]
ns = {
'r8': make_b1_item('2021 Indian Scout Bobber 8shot', imgs=n8),
'r3': make_b1_item('2021 Indian Scout Bobber 3shot', imgs=n3,
img=n3[0]),
'empty': make_b1_item('Kawasaki Vulcan 900 Custom', imgs=[]),
'plain': make_b1_item('Honda Shadow'),
'junk': make_b1_item('Suzuki C50', imgs='not-a-list'),
}
nb = {'other': make_item('Some Other Bike', price_num=9999)}
write_states(state_dir, make_state(ns), make_state(nb))
FIXED = '2026-11-11T12:00:00Z'
orig = imp.now_iso
imp.now_iso = lambda: FIXED
try:
r1 = imp.run_import(state_dir, db_path)
r2 = imp.run_import(state_dir, db_path)
finally:
imp.now_iso = orig
assert r1['ok'] and r2['ok'] and r2['total'] == 6 and r2['new'] == 0, \
(r1, r2)
conn = db.connect(db_path)
db.init_db(conn)
row = lambda s: conn.execute(
'SELECT * FROM listings WHERE src_id=?', (s,)).fetchone()
r8 = row('r8')
assert json.loads(r8['imgs']) == n8 and r8['img_url'] == n8[0]
r3 = row('r3')
assert json.loads(r3['imgs']) == n3 and r3['img_url'] == n3[0]
assert row('empty')['imgs'] is None and row('empty')['img_url'] is None
plain = row('plain')
assert plain['imgs'] is None and plain['img_url'] is None
assert row('junk')['imgs'] is None
before = [tuple(x) for x in conn.execute(
'SELECT * FROM listings ORDER BY id')]
s_n = conn.execute(
'SELECT COUNT(*) AS n FROM swipes').fetchone()['n']
conn.close()
imp.now_iso = lambda: FIXED
try:
r3 = imp.run_import(state_dir, db_path)
finally:
imp.now_iso = orig
conn = db.connect(db_path)
db.init_db(conn)
after = [tuple(x) for x in conn.execute(
'SELECT * FROM listings ORDER BY id')]
assert s_n == conn.execute(
'SELECT COUNT(*) AS n FROM swipes').fetchone()['n']
conn.close()
assert before == after, 're-import must be byte-stable (fixed clock)'
finally:
shutil.rmtree(d, ignore_errors=True)
def test_b1_640row_reimport_idempotence():
"""Deliverable A: the 640-row copy DB re-imported from the hunt state
snapshot stays row-for-row stable across two runs (fixed clock)."""
sourced = None
for cand in B1_STATE_CANDIDATES:
if (os.path.exists(os.path.join(cand, 'kijiji-ns.json'))
and os.path.exists(os.path.join(cand, 'kijiji-nb.json'))):
sourced = cand
break
assert sourced, 'hunt state snapshot not found (%s)' % (
B1_STATE_CANDIDATES,)
with open(os.path.join(sourced, 'kijiji-ns.json')) as fh:
ns = json.load(fh)
with open(os.path.join(sourced, 'kijiji-nb.json')) as fh:
nb = json.load(fh)
d = tempfile.mkdtemp(prefix='bt-b1-640-')
try:
state_dir = os.path.join(d, 'state')
db_path = os.path.join(d, 'copy.sqlite3')
shutil.copy2(LIVE_DB, db_path)
ns['fetched_at'] = nb['fetched_at'] = recent_iso() # evergreen gates
write_states(state_dir, ns, nb)
FIXED = '2026-11-11T12:00:00Z'
orig = imp.now_iso
imp.now_iso = lambda: FIXED
try:
r1 = imp.run_import(state_dir, db_path)
finally:
imp.now_iso = orig
assert r1['ok'], r1
conn = db.connect(db_path)
db.init_db(conn)
total = conn.execute('SELECT COUNT(*) AS n FROM listings').fetchone()['n']
active = conn.execute(
"SELECT COUNT(*) AS n FROM listings WHERE status='active'"
).fetchone()['n']
dup = conn.execute(
"SELECT COUNT(*) AS n FROM listings WHERE status='dup'"
).fetchone()['n']
before = [tuple(x) for x in conn.execute(
'SELECT * FROM listings ORDER BY id')]
s_n = conn.execute('SELECT COUNT(*) AS n FROM swipes').fetchone()['n']
conn.close()
assert total == 640 and active == 563 and dup == 77, \
(total, active, dup) # the B1 fixture is stable at 640
imp.now_iso = lambda: FIXED
try:
r2 = imp.run_import(state_dir, db_path)
finally:
imp.now_iso = orig
assert r2['ok'] and r2['new'] == 0 and r2['total'] == 640, r2
conn = db.connect(db_path)
db.init_db(conn)
after = [tuple(x) for x in conn.execute(
'SELECT * FROM listings ORDER BY id')]
s_n2 = conn.execute('SELECT COUNT(*) AS n FROM swipes').fetchone()['n']
conn.close()
assert before == after, '640 rows must be byte-stable across import'
assert s_n == s_n2, 'swipes must be untouched by re-import'
finally:
shutil.rmtree(d, ignore_errors=True)
def test_b1_payload_fields_and_rotation_math():
"""n_photos == len(imgs) (segments count = n) on deck/section/list rows;
§11.5 rotation lead = (last_photo_idx + 1) % n for null / n=1 / n=3 / n=8."""
d, c, db_path = p3_env()
try:
_conn = db.connect(db_path)
db.init_db(_conn) # B1 columns on the pristine copy
_conn.close()
conn = _sq_conn(db_path)
CDN = 'https://media.kijiji.ca/api/v1/x/%s?rule=kijijica-640-webp'
n8 = [CDN % ('g8-%d' % i) for i in range(8)]
n3 = [CDN % ('g3-%d' % i) for i in range(3)]
conn.execute(
"INSERT INTO listings (region, src_id, title, url, status,"
" img_url, imgs, score, first_seen) VALUES"
" ('ns','b1g8','B1 eight-photo','http://x/e','active',?,?,200,"
" '2026-11-11T00:00:00Z')",
(n8[0], json.dumps(n8)))
g8 = conn.execute("SELECT id FROM listings WHERE src_id='b1g8'"
).fetchone()['id']
conn.execute(
"INSERT INTO listings (region, src_id, title, url, status,"
" img_url, imgs, score, first_seen) VALUES"
" ('ns','b1g3','B1 three-photo','http://x/t','active',?,?,190,"
" '2026-11-11T00:00:01Z')",
(n3[0], json.dumps(n3)))
g3 = conn.execute("SELECT id FROM listings WHERE src_id='b1g3'"
).fetchone()['id']
# a swiped fixture row with a stored photo idx (rotation source)
conn.execute(
"INSERT INTO listings (region, src_id, title, url, status,"
" img_url, imgs, score, first_seen) VALUES"
" ('ns','b1rot','B1 rotation','http://x/r','active',?,?,180,"
" '2026-11-11T00:00:02Z')",
(n8[0], json.dumps(n8)))
rot = conn.execute("SELECT id FROM listings WHERE src_id='b1rot'"
).fetchone()['id']
conn.execute(
"INSERT INTO swipes (listing_id, action, last_photo_idx)"
" VALUES (?, 'like', 2)", (rot,))
conn.commit()
conn.close()
# payload contract: n_photos mirrors the gallery length
for payload in (c.get('/api/list?limit=500').json()['items'],
c.get('/api/deck?limit=100').json()['items']):
m = {i['id']: i for i in payload}
for gid, want in ((g8, 8), (g3, 3), (rot, 8)):
if gid in m:
assert m[gid]['n_photos'] == want, (gid, m[gid])
one = c.get('/api/listing/%d' % rot).json()
assert one['n_photos'] == 8 and one['last_photo_idx'] == 2
# rotation math on the real stored values (FE: (last+1) % n)
def lead(li, n):
if not n or n < 2 or li is None or li < 0:
return 0
return (li + 1) % n
assert lead(None, 8) == 0 # no stored index -> photo 0
assert lead(0, 1) == 0 # single photo -> always 0
assert lead(0, 3) == 1 and lead(2, 3) == 0 # 3-photo wrap
assert lead(1, 8) == 2 and lead(7, 8) == 0 # 8-photo wrap
finally:
shutil.rmtree(d, ignore_errors=True)
def test_b1_img_route_chain():
"""GET /img/{id}?i=n: mirrored file (incl. {id}_0.webp as the gallery
primary name) -> CDN 302 -> 404; i=0 keeps the legacy chain; out-of-range
and unknown rows 404."""
d, c, db_path = p3_env()
try:
import sqlite3 as _sq
_conn = db.connect(db_path)
db.init_db(_conn) # B1 columns on the pristine copy
_conn.close()
conn = _sq.connect(db_path)
conn.row_factory = _sq.Row
CDN = 'https://media.kijiji.ca/api/v1/x/%s?rule=kijijica-640-webp'
n3 = [CDN % ('c%d' % i) for i in range(3)]
conn.execute(
"INSERT INTO listings (region, src_id, title, url, status,"
" img_url, imgs) VALUES ('ns','b1img','B1 images','http://x/i',"
" 'active',?,?)", (n3[0], json.dumps(n3)))
gid = conn.execute("SELECT id FROM listings WHERE src_id='b1img'"
).fetchone()['id']
conn.execute(
"INSERT INTO listings (region, src_id, title, url, status,"
" img_url) VALUES ('ns','b1leg','B1 legacy','http://x/l',"
" 'active',?)", (CDN % 'L',))
lg = conn.execute("SELECT id FROM listings WHERE src_id='b1leg'"
).fetchone()['id']
conn.commit()
conn.close()
img_dir = os.path.join(d, 'img')
for n in range(3):
p = os.path.join(img_dir, '%d_%d.webp' % (gid, n))
with open(p, 'wb') as fh:
fh.write(b'RIFF\x00\x00\x00\x00WEBP g%d n%d' % (gid, n))
# legacy chain: {id}.webp file wins for i=0
with open(os.path.join(img_dir, '%d.webp' % lg), 'wb') as fh:
fh.write(b'RIFF\x00\x00\x00\x00WEBP legacy')
r = c.get('/img/%d' % lg)
assert r.status_code == 200 and r.headers.get(
'content-type', '').startswith('image/webp')
# i=0 with no {id}.webp -> accepts the gallery primary {id}_0.webp
r = c.get('/img/%d' % gid)
assert r.status_code == 200, r.status_code
# gallery extras answered for every n
for n in (1, 2):
r = c.get('/img/%d?i=%d' % (gid, n))
assert r.status_code == 200, (n, r.status_code)
assert r.headers.get('content-type', '').startswith('image/webp')
# out-of-range -> 404; negative -> validation error; unknown -> 404
assert c.get('/img/%d?i=3' % gid).status_code == 404
assert c.get('/img/%d?i=-1' % gid).status_code in (404, 422)
assert c.get('/img/%d?i=1' % lg).status_code == 404 # legacy 1-photo
assert c.get('/img/999999').status_code == 404
finally:
shutil.rmtree(d, ignore_errors=True)
def test_b1_swipe_photo_idx():
"""POST /api/swipe optionally persists photo_idx; an absent key preserves
the stored index; delete clears it (payload last_photo_idx -> null)."""
d, c, db_path = p3_env()
try:
import sqlite3 as _sq
_conn = db.connect(db_path)
db.init_db(_conn) # B1 columns on the pristine copy
_conn.close()
conn = _sq.connect(db_path)
conn.row_factory = _sq.Row
CDN = 'https://media.kijiji.ca/api/v1/x/%s.jpg'
n3 = [CDN % ('p%d' % i) for i in range(3)]
conn.execute(
"INSERT INTO listings (region, src_id, title, url, status,"
" img_url, imgs, score, first_seen) VALUES ('ns','b1sw',"
" 'B1 swipe idx','http://x/s','active',?,?,9999,"
" '2026-11-11T00:00:00Z')", (n3[0], json.dumps(n3)))
gid = conn.execute("SELECT id FROM listings WHERE src_id='b1sw'"
).fetchone()['id']
conn.commit()
conn.close()
r = c.post('/api/swipe', json={'listing_id': gid, 'action': 'maybe',
'photo_idx': 2})
assert r.status_code == 200 and r.json()['last_photo_idx'] == 2, r.text
# absent photo_idx preserves the stored index
r = c.post('/api/swipe', json={'listing_id': gid, 'action': 'nope'})
assert r.status_code == 200 and r.json()['last_photo_idx'] == 2, r.text
# explicit overwrite (0 is a valid photo index)
r = c.post('/api/swipe', json={'listing_id': gid, 'action': 'like',
'photo_idx': 0})
assert r.status_code == 200 and r.json()['last_photo_idx'] == 0, r.text
# invalid values rejected loudly
assert c.post('/api/swipe', json={'listing_id': gid, 'action': 'nope',
'photo_idx': -1}).status_code == 400
assert c.post('/api/swipe', json={'listing_id': gid, 'action': 'nope',
'photo_idx': 'x'}).status_code == 400
assert c.post('/api/swipe', json={'listing_id': gid, 'action': 'nope',
'photo_idx': True}).status_code == 400
# explicit null clears the index
r = c.post('/api/swipe', json={'listing_id': gid, 'action': 'maybe',
'photo_idx': None})
assert r.status_code == 200 and r.json()['last_photo_idx'] is None
# payload reflects the persisted index; delete removes the row
c.post('/api/swipe', json={'listing_id': gid, 'action': 'like',
'photo_idx': 1})
lst = c.get('/api/list?limit=500').json()['items']
row = [i for i in lst if i['id'] == gid][0]
assert row['n_photos'] == 3 and row['last_photo_idx'] == 1
c.delete('/api/swipe/%d' % gid)
lst = c.get('/api/list?limit=500').json()['items']
row = [i for i in lst if i['id'] == gid][0]
assert row['last_photo_idx'] is None and row['n_photos'] == 3
finally:
shutil.rmtree(d, ignore_errors=True)
P3_API_TESTS = [
('p3: events seeding idempotent (from swipes)', test_p3_event_seeding_idempotent),
('p3: swipe round math + clear-then-re-decide', test_p3_round_math_and_clear),
('p3: section compose invariants (25/12, caps, bands)', test_p3_section_invariants),
('p3: exhaust -> section report', test_p3_exhaust_and_report),
('p3: forge modes (strict 409 · ondemand mock pass)', test_p3_forge_modes_strict_409),
('p3: trends endpoint shape + stats', test_p3_trends_shape_and_stats),
('p3: agentpass mock determinism + live fallback', test_p3_agentpass_mock_and_fallback),
('p3: points/band function known answers', test_p3_points_and_band),
('p3: payload additions (list/listing/section)', test_p3_payload_additions),
]
if API_DEPS:
TESTS += P3_API_TESTS
B1_TESTS = [
('b1: guarded migrations (imgs + last_photo_idx, idempotent)',
test_b1_migration_guarded),
('b1: importer carries imgs gallery (JSON, order, img fallback.)',
test_b1_importer_gallery_carry),
('b1: 640-row copy re-import byte-stable (A)',
test_b1_640row_reimport_idempotence),
]
TESTS += B1_TESTS
API_TESTS = [
('api: / serves the app + static + manifest/sw', test_api_root),
('api: stats live == active count, health counts', test_api_stats_health),
@@ -525,6 +1312,12 @@ API_TESTS = [
('api: swipe upsert/edit/delete lifecycle', test_api_swipe_lifecycle),
('api: /img/{id} 200 cached | 302 hotlink | 404 none', test_api_img_routes),
('api: badge seen roundtrip', test_api_badge_roundtrip),
('b1: /img/{id}?i=n gallery chain (200/302/404, {id}_0 primary)',
test_b1_img_route_chain),
('b1: swipe photo_idx persists / preserves / clears',
test_b1_swipe_photo_idx),
('b1: payload n_photos + last_photo_idx, rotation math',
test_b1_payload_fields_and_rotation_math),
]
if API_DEPS:

View File

@@ -133,17 +133,95 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
.search::placeholder{color:#6f6f7a}
.toast{position:fixed;left:50%;bottom:150px;transform:translateX(-50%);background:#1d1d24;border:1px solid #2a2a33;color:#fff;padding:10px 16px;border-radius:12px;font-size:13px;z-index:80;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;max-width:92vw;overflow:hidden;text-overflow:ellipsis}
.toast.on{opacity:1}
/* P3 additions */
.sect{margin-left:4px;font-size:12px;font-weight:650;color:var(--mut);white-space:nowrap}
.sect.hot{color:var(--accent1)}
.band{position:absolute;top:12px;left:12px;z-index:4;max-width:78%;font-size:11px;font-weight:650;letter-spacing:.4px;border-radius:999px;padding:4px 9px;pointer-events:none;line-height:1.35;user-select:none}
.band.wild{color:#ffb43a;background:rgba(255,180,58,.14);border:1px solid rgba(255,180,58,.35)}
.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)}
.report{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px;z-index:3}
.report.on{display:flex}
.report b{color:#fff;font-size:16px}
.rforge{margin-top:10px;border:0;border-radius:14px;background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;font-size:15px;font-weight:700;padding:12px 22px;cursor:pointer;box-shadow:0 8px 22px rgba(253,38,125,.3)}
.rforge:disabled{background:#23232b;color:#6f6f7a;box-shadow:none;cursor:default}
.rstatus{font-size:12.5px;min-height:16px}
.rmode{display:flex;align-items:center;gap:7px;font-size:12px;margin-top:6px;flex-wrap:wrap;justify-content:center}
.rmode .ron{color:#fff;border-bottom:1px solid #fff}
.rmode span{cursor:pointer;color:var(--mut)}
.rmode .sep{color:#4a4a55;cursor:default}
.rcap{flex-basis:100%;color:var(--mut);font-size:12px}
/* tank gauge (non-numeric) */
.tank{width:18px;height:22px;flex:none;display:block}
.tank.big{width:28px;height:34px}
.tank .tf{transition:y .25s ease,height .25s ease}
.rpt{display:flex;align-items:center;gap:7px}
/* dossier why-this-rank */
.drank{display:flex;gap:12px;align-items:flex-start;margin-top:10px;padding:10px;background:#15151b;border-radius:12px}
.drank .tankwrap{flex:none;padding-top:2px}
.dpts{font-size:17px;font-weight:700}
.dlines{font-size:12.5px;color:var(--mut);line-height:1.55}
.dlines .pos{color:#9ee8a8}
.dlines .neg{color:#f0a5ab}
.dfoot{margin-top:6px;font-size:11.5px;color:#7b7b88}
/* trends screen */
.trends{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px;overflow-y:auto}
.trends.on{display:flex}
.tsec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:16px 18px 6px}
.tlead{font-size:18px;font-weight:700;margin:0 18px;line-height:1.35}
.tsub{font-size:12.5px;color:var(--mut);margin:4px 18px 0}
.tbullets{margin:0 18px;font-size:14px;color:#d7d7dd;line-height:1.6}
.tminis{display:flex;gap:10px;margin:0 18px}
.tmini{flex:1;background:#141419;border:1px solid #1f1f27;border-radius:12px;padding:12px 10px;text-align:center}
.tmv{font-size:19px;font-weight:800}
.tmk{font-size:10.5px;color:var(--mut);margin-top:4px;letter-spacing:.3px;text-transform:uppercase}
.tacts{margin:8px 18px 0;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.tstatus{font-size:12.5px;color:var(--mut)}
.trow{display:flex;align-items:center;gap:8px;margin:12px 18px 0;font-size:13px;color:var(--mut)}
.trow .on{color:#fff;border-bottom:1px solid #fff}
.trow span{cursor:pointer}
.trow i{color:#4a4a55;font-style:normal;cursor:default}
.tmuted{font-size:12px;color:#6f6f7a;margin:10px 18px 20px}
/* B1 §11 — photo galleries: segments, tap zones, fullscreen viewer */
.segments{position:absolute;top:8px;left:50%;transform:translateX(-50%);display:flex;gap:4px;z-index:4;pointer-events:none}
.segments i{width:22px;height:3px;border-radius:2px;background:rgba(255,255,255,.35);transition:background .15s}
.segments i.on{background:#fff}
.card.hasSegs .band{top:22px} /* §11.2 amendment: bands drop below the bars */
.vback{position:absolute;inset:0;z-index:71;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .28s}
.vback.on{opacity:1;pointer-events:auto}
.viewer{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:72;background:#0b0b0f;display:flex;flex-direction:column;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.5);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);overflow:hidden}
.viewer.on{transform:translateY(0)}
.vgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none}
.vgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
.vclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:16px;z-index:6;cursor:pointer;display:flex;align-items:center;justify-content:center}
.vphoto{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none}
.vimg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;transform-origin:center center}
.viewer .segments{top:34px}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><span class="flame"></span>Bike-Tinder</div>
<div class="pill" id="pill">—</div>
<div style="display:flex;align-items:center">
<div class="pill" id="pill">—</div>
<div class="sect" id="sect"></div>
</div>
</header>
<div class="stage" id="stage">
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
<div class="report" id="report">
<b>Section complete.</b>
<div id="repCounts"></div>
<div id="repSub">The file is digesting your strikes.</div>
<div id="repLine2"></div>
<button class="rforge" id="btnForge">Forge next 25</button>
<div class="rstatus" id="forgeStatus"></div>
<div class="rmode" id="modeRow">
<span id="modeOnd" class="on">on demand</span><i class="sep">⇄</i><span id="modeStr">strict</span>
<div class="rcap" id="strictCap" style="display:none">Waiting for the nightly read — next section after the agent runs.</div>
</div>
</div>
</div>
<div class="actions" id="actions">
@@ -169,6 +247,14 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
<div class="dtitle" id="dtitle"></div>
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
<div class="dchips" id="dchips"></div>
<div class="drank" id="drank" style="display:none">
<div class="tankwrap" id="drankTank"></div>
<div style="flex:1;min-width:0">
<div class="dpts" id="dpts"></div>
<div class="dlines" id="dlines"></div>
<div class="dfoot" id="dfoot"></div>
</div>
</div>
<div class="dsec">The machine</div>
<div class="specs" id="dspecs"></div>
<div class="dsec">Seller's notes</div>
@@ -182,6 +268,14 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
</div>
</div>
<div class="vback" id="vback"></div>
<div class="viewer" id="viewer">
<div class="vgrab" id="vgrab"><i></i></div>
<button class="vclose" id="vclose">✕</button>
<div class="segments" id="vsegs"></div>
<div class="vphoto" id="vphoto"><img class="vimg" id="vimg" alt="" draggable="false"></div>
</div>
<div class="list" id="listScreen">
<div class="lhead"><h1>All bikes</h1><div class="sub" id="lsub">—</div></div>
<input class="search" id="lsearch" type="search" placeholder="Search titles" autocomplete="off">
@@ -203,7 +297,30 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
<div class="mempty" id="mempty">No likes yet — swipe right on the deck.</div>
</div>
<div class="stub" id="stubTrends"><b>Trends — Phase 3</b>The deck learns your taste as you swipe.</div>
<div class="trends" id="trendsScreen">
<div class="lhead"><h1>Trends</h1></div>
<div class="tsec">Your read</div>
<div class="tlead" id="tLead">—</div>
<div class="tsub" id="tReadAt"></div>
<div class="tsub" id="tCold" style="display:none">Swipe ~10 more and the read sharpens.</div>
<div class="tsec" id="tLeanSec" style="display:none">Leanings</div>
<div class="tbullets" id="tLeanings"></div>
<div class="tsec">Stats</div>
<div class="tminis">
<div class="tmini"><div class="tmv" id="tmHit">—</div><div class="tmk">Hit rate</div></div>
<div class="tmini"><div class="tmv" id="tmRet">—</div><div class="tmk">Returns re-struck</div></div>
<div class="tmini"><div class="tmv" id="tmWild">—</div><div class="tmk">Wildcards hit</div></div>
</div>
<div class="tsec" id="tSinceSec" style="display:none">Since last read</div>
<div class="tsub" id="tSince" style="display:none"></div>
<div class="tsec">Actions</div>
<div class="tacts">
<button class="rforge" id="btnRunAgent" style="margin-top:0">Run agent now</button>
<div class="tstatus" id="runStatus"></div>
</div>
<div class="trow"><span>Forge:</span><span id="tOnd" class="on">on demand</span><i>⇄</i><span id="tStr">strict</span></div>
<div class="tmuted" id="tLastRun"></div>
</div>
</div>
<div class="toast" id="toast"></div>
@@ -216,8 +333,11 @@ const SC=.026, TY=16, DIM=.08; // per-depth step: scale, y-offset, dimming
let pile=[]; // top-first [{d,el}] — every card its own swipable object
let hist=[]; // undo history: full payloads
let dragEl=null, sx=0, sy=0, dx=0, dy=0, moved=false;
let buffer=[]; // deck feed from GET /api/deck (payloads)
let buffer=[]; // deck feed (payloads, pinned section order in P3)
let deckCursor=null, deckLoaded=false, deckLoading=false;
let section=null; // P3: latest GET /api/section body
let legacyMode=false; // true when the API has no /api/section (P2 API)
let forgeBusy=false, runBusy=false;
/* ---------- production wiring: API + retry queue + toast ---------- */
let toastTimer=null;
@@ -235,12 +355,18 @@ async function getJSON(url){
/* swipe persistence: optimistic, never waits; failures retry 2s/5s/10s then
every 15s while the tab lives. Last write per listing wins; the queue runs
serially so reorderings can't land out of sequence. */
const pend=new Map(); // listing_id -> {act, fails, warned}
const pend=new Map(); // listing_id -> {act, secId, photoIdx, fails, warned}
let qRunning=false, qTimer=null, qWarned=false;
function enqueue(listingId, act){ // act: 'like'|'nope'|'maybe'|null (delete)
pend.set(listingId,{act,fails:0});
function enqueue(listingId, act, secId=null, photoIdx=null){ // act: 'like'|'nope'|'maybe'|null (delete)
pend.set(listingId,{act,secId,photoIdx,fails:0});
scheduleQueue();
}
function swipeBody(id, act, secId, photoIdx){
const b={listing_id:id,action:act};
if(secId!=null) b.section_id=secId;
if(photoIdx!=null) b.photo_idx=photoIdx; // B1 §11.5: photo on screen at strike
return b;
}
function queDelay(op){ return op.fails===1?2000:op.fails===2?5000:op.fails===3?10000:15000; }
function scheduleQueue(){
if(qRunning||!pend.size||qTimer) return;
@@ -258,7 +384,7 @@ async function runQueue(){
try{
const res= p.act==null
? await fetch('/api/swipe/'+id,{method:'DELETE'})
: await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({listing_id:id,action:p.act})});
: await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(swipeBody(id,p.act,p.secId,p.photoIdx))});
if(res.ok){ pend.delete(id); }
else { p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); } minDelay=Math.max(minDelay,queDelay(p)); }
}catch(e){
@@ -269,10 +395,15 @@ async function runQueue(){
}finally{ qRunning=false; }
if(pend.size){ qTimer=setTimeout(runQueue,Math.max(minDelay||15000,1000)); }
}
/* optimistic decision set for list/matches + deck commits share this */
function setDecision(id, act){
/* optimistic decision set for list/matches + deck commits share this.
localDecisions remembers the latest intent per listing so section
progress stays correct while the persistence queue is mid-flight. */
const localDecisions=new Map(); // listing_id -> action|null
let lastResyncAt=0;
function setDecision(id, act, secId=null, photoIdx=null){
localDecisions.set(id, act);
updateRowDecision(id, act); // optimistic paint
enqueue(id, act);
enqueue(id, act, secId, photoIdx);
}
/* ---------- payload -> display helpers ---------- */
@@ -305,6 +436,31 @@ function relSeen(fs){
const days=Math.floor((now-d)/86400000);
return days<=0?'Today':(days===1?'1 day ago':days+' days ago');
}
function relTime(ts){
if(!ts) return '—';
const d=new Date(ts), now=new Date();
if(isNaN(d)) return '—';
const sec=Math.max(0,Math.floor((now-d)/1000));
if(sec<60) return 'just now';
const min=Math.floor(sec/60);
if(min<60) return min+'m ago';
const hr=Math.floor(min/60);
if(hr<24) return hr+'h ago';
const days=Math.floor(hr/24);
return days===1?'1 day ago':days+' days ago';
}
/* P3: non-numeric tank gauge (filled per points, colored per band) */
const TANK_COLORS={like:'var(--like)',maybe:'var(--maybe)',nope:'var(--nope)'};
function tankSVG(tank,big){
const v= tank&&tank.value!=null?Math.max(0,Math.min(100,tank.value)):0;
const color= TANK_COLORS[tank&&tank.band]||'var(--maybe)';
const fillH=13.5*v/100, y=18-fillH;
return `<svg class="tank${big?' big':''}" viewBox="0 0 18 22" aria-hidden="true">`+
`<rect x="5" y="4.5" width="8" height="13.5" rx="4" fill="rgba(255,255,255,.12)"/>`+
`<rect x="7.4" y="5.6" width="3.2" height="2.2" rx="1.1" fill="rgba(255,255,255,.28)"/>`+
`<rect class="tf" x="6.4" y="${y}" width="5.2" height="${fillH}" rx="2.2" fill="${color}"/>`+
`</svg>`;
}
function ccFrom(it){
const m=((it.title||'')+' '+((it.desc||'').split(' [also listed:')[0])).match(/(\d[\d,]*)\s*cc/i);
return m?(fmtNum(m[1].replace(/,/g,''))+' cc'):null;
@@ -323,11 +479,86 @@ function setStamps(el,a,b,c){
el.querySelector('.stamp.nope').style.opacity=b;
el.querySelector('.stamp.maybe').style.opacity=c;
}
function bandHTML(item){ // P3 system bands — the ONLY card-face additions
if(item.slot==='wildcard') return `<div class="band wild">WILDCARD · maybe you&rsquo;ll like this one instead</div>`;
if(item.slot==='return'){
const verb= item.decision==='like'?'liked':item.decision==='maybe'?'maybed':item.decision==='nope'?'noped':'seen';
return `<div class="band sl">SECOND LOOK — you ${verb} this</div>`;
}
return '';
}
/* ---------- B1 §11: photo galleries ---------- */
function nPhotos(it){ // payload n_photos; tolerant of old APIs
const n= it&&typeof it.n_photos==='number'?it.n_photos:(it&&it.img?1:0);
return Math.max(0, Math.trunc(n));
}
function leadFor(it){ // §11.5 repeat rotation: (last+1) % n
const n=nPhotos(it);
if(n<2) return 0;
const li=it.last_photo_idx;
if(!Number.isInteger(li)||li<0) return 0;
return (li+1)%n;
}
function photoSrc(it,i){ if(!it||!it.img) return null; return i>0?('/img/'+it.id+'?i='+i):it.img; }
function segBars(n,cur){ // the <i> bars only (no wrapper)
let h='';
for(let k=0;k<n;k++) h+=`<i${k===cur?' class="on"':''}></i>`;
return h;
}
function segHTML(n,cur){
if(!n||n<2) return '';
return `<div class="segments">${segBars(n,cur)}</div>`;
}
function phwrapHTML(src){
return `<div class="phwrap">${src?`<img class="photo" src="${src}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}</div>`;
}
function attachPhError(entry){
const wrap=entry.el.querySelector('.phwrap');
const im=wrap.querySelector('img');
if(im) im.addEventListener('error',()=>{ wrap.innerHTML='<div class="nophoto">Photo pending — thumbnail backfill queued</div>'; });
}
function paintSegs(entry){
const segs=entry.el.querySelector('.segments');
if(segs) [...segs.children].forEach((x,k)=>x.classList.toggle('on',k===(entry.cur||0)));
}
/* §11.2 flip — instant, clamped at the ends (no wrap), commits nothing */
function flipCard(entry,dir){
const n=nPhotos(entry.d);
if(n<2) return;
const cur=(entry.cur||0)+dir;
if(cur<0||cur>=n) return;
entry.cur=cur;
const wrap=entry.el.querySelector('.phwrap');
wrap.innerHTML= phwrapHTML(photoSrc(entry.d,cur));
attachPhError(entry);
paintSegs(entry);
preloadPhotos();
}
/* §11.6 preload: top card's next photo + next card's lead photo, off-path */
function preloadPhotos(){
pile.slice(0,2).forEach((entry,pos)=>{
const d=entry.d;
if(!d) return;
const targets= pos===0
? ((()=>{ const n=nPhotos(d); const nx=(entry.cur||0)+1;
return (n>1&&nx<n)?[photoSrc(d,nx)]:[]; })())
: [photoSrc(d,leadFor(d))];
for(const s of targets){
if(!s) continue;
const im=new Image(); im.decoding='async'; im.src=s;
}
});
}
function cardNode(item){
const el=document.createElement('div');
el.className='card';
el.innerHTML=`${item.img?`<img class="photo" src="${item.img}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}
const n=nPhotos(item);
const lead=leadFor(item);
if(n>=2) el.classList.add('hasSegs');
el.innerHTML=`${phwrapHTML(photoSrc(item,lead))}
<div class="grad"></div>
${bandHTML(item)}
${segHTML(n,lead)}
<div class="stamp like">LIKE</div>
<div class="stamp nope">NOPE</div>
<div class="stamp maybe">MAYBE</div>
@@ -338,11 +569,8 @@ function cardNode(item){
info.querySelector('.title').textContent=item.title||'';
info.querySelector('.price').textContent=priceFor(item);
info.querySelector('.sub').textContent=subFor(item);
const ph=el.querySelector('.photo');
if(ph) ph.addEventListener('error',()=>{
ph.remove();
el.insertAdjacentHTML('afterbegin','<div class="nophoto">Photo pending — thumbnail backfill queued</div>');
});
const entry={d:item,el,cur:lead};
attachPhError(entry);
el.addEventListener('pointerdown',down);
el.addEventListener('pointermove',move);
el.addEventListener('pointerup',upH);
@@ -350,7 +578,7 @@ function cardNode(item){
const ib=el.querySelector('.ibtn');
ib.addEventListener('pointerdown',e=>e.stopPropagation());
ib.addEventListener('click',e=>{ e.stopPropagation(); if(isTop(el)) openDetail(item,{type:'deck'}); });
return {d:item,el};
return entry;
}
function layer(el,d,anim){
el.classList.toggle('topcard',d===0);
@@ -375,9 +603,10 @@ function fillPile(){
layer(entry.el,d,true);
}
relayer();
preloadPhotos();
}
/* deck feed: cursor pages of 40; refill whenever the buffer thins */
async function loadDeck(){
/* deck feed: P3 sections (pinned order) with a legacy /api/deck fallback */
async function loadDeck(){ // legacy cursor feed (pre-P3 API)
if(deckLoading||deckLoaded) return;
deckLoading=true;
try{
@@ -390,10 +619,58 @@ async function loadDeck(){
deckCursor=res.next_cursor||null;
if(!deckCursor) deckLoaded=true;
}finally{ deckLoading=false; }
maybeRefill();
}
async function loadSection(){
if(deckLoading) return;
deckLoading=true;
try{
const res=await fetch('/api/section');
if(res.status===404){ // pre-P3 server: fall back to the deck feed
legacyMode=true;
deckLoading=false;
loadDeck();
return;
}
if(!res.ok){ setTimeout(()=>loadSection(),15000); return; }
const body=await res.json();
section=body;
const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id),...buffer.map(b=>b.id)]);
/* P3: second-look returns carry prior decisions — they must still enter
the swipe buffer; only ids already handled in this session are skipped. */
const fresh=(body.items||[]).filter(it=>(it.decision===null||it.slot==='return')&&!live.has(it.id));
buffer.push(...fresh);
fillPile();
updateSectionUI(body);
}catch(e){ setTimeout(()=>loadSection(),15000); }
finally{ deckLoading=false; }
maybeRefill();
}
function maybeRefill(){
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('on');
if(legacyMode){
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('on');
return;
}
if(!pile.length&&!buffer.length&&!deckLoading){
if(section&&sectDecided>=sectTotal){
// locally exhausted (last POSTs may still be flushing — the report
// shows from localDecisions; a quiet resync freshens counts and the
// server's report flip, rate-limited so a failing last POST can't
// cause a resync loop)
$('report').classList.add('on');
$('empty').classList.remove('on');
if(!section.exhausted&&Date.now()-lastResyncAt>2000){
lastResyncAt=Date.now();
loadSection();
}
}else if(section){
$('empty').classList.remove('on'); $('report').classList.remove('on');
}
}else{
$('empty').classList.remove('on');
$('report').classList.remove('on');
}
}
/* ---------- docs/mockup core: commit / fly / undo — no lock ---------- */
@@ -401,7 +678,11 @@ function commit(el,dir){
if(!isTop(el)) return;
const t=pile.shift();
hist.push(t.d);
setDecision(t.d.id, dir==='maybe'?'maybe':dir); // optimistic persist
const act= dir==='maybe'?'maybe':dir;
setDecision(t.d.id, act, (!legacyMode&&section&&!section.exhausted)?section.section.id:null,
nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike
sectDecided++;
paintSect();
flyOut(el,dir);
fillPile(); relayer();
maybeRefill();
@@ -422,6 +703,8 @@ function undo(){
if(!hist.length) return;
const item=hist.pop();
setDecision(item.id, null); // DELETE through the queue
sectDecided=Math.max(0,sectDecided-1);
paintSect();
const entry=cardNode(item);
entry.el.style.transition='none';
entry.el.style.transform='translateY(-46px) scale(1.02)';
@@ -431,7 +714,9 @@ function undo(){
if(pile.length>DEPTH){ const drop=pile.pop(); drop.el.remove(); buffer.unshift(drop.d); }
void entry.el.offsetWidth;
relayer();
preloadPhotos();
$('empty').classList.remove('on');
$('report').classList.remove('on');
}
$('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); };
$('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); };
@@ -464,13 +749,25 @@ function upH(e){
if(dx>w*0.35) commit(el,'like');
else if(dx<-w*0.35) commit(el,'nope');
else if(dy<-140) commit(el,'maybe');
else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) openDetail(pile[0].d,{type:'deck'}); }
else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) tapZone(pile[0],e); }
}
/* §11.2/§11.3/§11.4 tap zones: sides flip (clamped), center = fullscreen.
The old tap-anywhere -> dossier gesture is retired (ⓘ stays the door). */
function tapZone(entry,e){
const n=nPhotos(entry.d);
if(n<1 || !entry.d.img) return;
const r=stage.getBoundingClientRect();
const rel=e.clientX-r.left, w=r.width||360;
if(rel>=w/3 && rel<=w*2/3) openViewer(entry.d, entry.cur||0, {type:'card',entry});
else flipCard(entry, rel<w/3?-1:1);
}
/* ---------- docs/mockup core: fullscreen dossier ---------- */
let dCtx=null;
let dData=null; // B1: dossier payload (hero -> viewer)
function openDetail(data,ctx){
dCtx=ctx||null;
dData=data;
const ph=$('dphoto');
if(data.img){ ph.src=data.img; ph.classList.remove('ph'); }
else { ph.removeAttribute('src'); ph.classList.add('ph'); }
@@ -493,9 +790,35 @@ function openDetail(data,ctx){
$('dspecs').innerHTML=rows.map(r=>`<div class="spec"><span class="k">${r[0]}</span><span class="v${(!r[1]||r[1]==='—')?' z':''}">${r[1]||'—'}</span></div>`).join('');
$('dnotes').textContent=(data.desc||'').split(' [also listed:')[0]||'';
$('dopen').href=data.url||'#';
fillRank(data); // P3 "Why this rank" (may be progressive)
$('detail').classList.add('on'); $('dback').classList.add('on');
$('dbody').scrollTop=0;
}
/* P3 dossier rank block: gauge + points + strongest ± lines + audit footer */
function fillRank(data){
if(!data||data.tank==null||data.tank.value==null){
$('drank').style.display='none';
return;
}
$('drank').style.display='flex';
$('drankTank').innerHTML=tankSVG(data.tank,true);
$('dpts').textContent=data.tank.value+'/100';
const lines=(data.points_breakdown||[]).slice()
.sort((a,b)=>Math.abs(b.value)-Math.abs(a.value)).slice(0,4);
$('dlines').innerHTML=lines.map(l=>{
const lbl= l.source==='agent'?('agent: '+l.label):l.label;
const n= l.value>=0?(' +'+l.value):(' '+l.value);
return `<div class="${l.value>=0?'pos':'neg'}">· ${lbl}${n}</div>`;
}).join('');
const ri= section&&section.read_info;
$('dfoot').textContent= ri?`from ${ri.events} strikes · read ${relTime(ri.profile_at)}`:'';
// list/matches payloads carry no breakdown — fill it on the quiet
if(!data.points_breakdown&&data.id&&!legacyMode){
getJSON('/api/listing/'+data.id).then(full=>{
if(full&&full.points_breakdown&&dCtx) fillRank(full);
});
}
}
function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; }
$('dclose').onclick=closeDetail;
$('dclose').addEventListener('pointerdown',e=>e.stopPropagation());
@@ -505,7 +828,12 @@ function dStart(e){ dDragY=e.clientY; $('detail').style.transition='none'; try{e
function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); $('detail').style.transform=`translateY(${dy}px)`; }
function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null;
if(dy>110){ closeDetail(); }
else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform=''; } }
else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform='';
/* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at photo 0 */
if(e.type==='pointerup' && dy<=6 && e.currentTarget&&e.currentTarget.classList
&& e.currentTarget.classList.contains('dhero') && dData&&dData.img){
openViewer(dData,0,{type:'dossier'});
} } }
[document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); });
function dAction(a){
const ctx=dCtx; closeDetail();
@@ -520,6 +848,113 @@ function dAction(a){
}
$('dNo').onclick=()=>dAction('nope'); $('dMb').onclick=()=>dAction('maybe'); $('dLk').onclick=()=>dAction('like');
/* ---------- B1 §11.3: fullscreen viewer (swipe nav, pinch/double-tap zoom,
✕ + pull-down close) — returns to the same card & photo position ---------- */
let vCtx=null; // {item, idx, back} — deck cards untouched
let vScale=1, vTx=0, vTy=0;
let vDrag=null; // single-pointer gesture state
const vPtrs=new Map(); // pointerId -> {x,y}
let vPinch=null; // {d0, scale0}
let vLastTap=0;
function vCount(){ return vCtx&&vCtx.item?nPhotos(vCtx.item):0; }
function vApply(){ if(!vCtx) return; if(vScale<=1){ vTx=0; vTy=0; } $('vimg').style.transform=`translate(${vTx}px,${vTy}px) scale(${vScale})`; }
function vPaint(){
const item=vCtx.item, i=vCtx.idx, n=vCount();
$('vsegs').innerHTML= n>=2?segBars(n,i):'';
const im=$('vimg'), s=photoSrc(item,i);
if(s){ im.src=s; im.style.opacity=1; }
else { im.removeAttribute('src'); im.style.opacity=.25; }
im.onerror=()=>{ im.style.opacity=.25; };
vApply();
}
function openViewer(item, idx, back){
const n=nPhotos(item);
if(!n||!item||!item.img) return;
vCtx={item, idx:Math.max(0,Math.min(idx||0,n-1)), back:back||null};
vScale=1; vTx=0; vTy=0;
vPaint();
$('viewer').classList.add('on'); $('vback').classList.add('on');
}
function closeViewer(){
const v=$('viewer');
v.classList.remove('on'); $('vback').classList.remove('on');
v.style.transition=''; v.style.transform='';
vCtx=null; vPtrs.clear(); vPinch=null; vDrag=null;
}
$('vback').addEventListener('click',closeViewer);
$('vclose').onclick=closeViewer;
$('vclose').addEventListener('pointerdown',e=>e.stopPropagation());
const vph=$('vphoto');
function vDist(){
const pts=[...vPtrs.values()];
return pts.length>=2?Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y):null;
}
vph.addEventListener('pointerdown',e=>{
if(!vCtx) return;
vPtrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
try{ vph.setPointerCapture(e.pointerId); }catch(_){}
if(vPtrs.size===1){ vDrag={sx:e.clientX,sy:e.clientY,px:vTx,py:vTy,scale0:vScale,mode:null}; }
else if(vPtrs.size===2){ vDrag=null; vPinch={d0:vDist()||1,scale0:vScale}; }
});
vph.addEventListener('pointermove',e=>{
if(!vCtx) return;
const pt=vPtrs.get(e.pointerId); if(pt){ pt.x=e.clientX; pt.y=e.clientY; }
if(vPtrs.size>=2&&vPinch){
const dd=vDist(); if(!dd) return;
vScale=Math.max(1,Math.min(4,vPinch.scale0*dd/vPinch.d0)); // 1×–4×
vApply(); return;
}
if(!vDrag) return;
const dx=e.clientX-vDrag.sx, dy=e.clientY-vDrag.sy;
if(!vDrag.mode){
if(Math.abs(dx)<=6&&Math.abs(dy)<=6) return;
vDrag.mode= vScale>1?'pan':(Math.abs(dx)>=Math.abs(dy)?'nav':'pull');
}
if(vDrag.mode==='pull'){
const v=$('viewer'); v.style.transition='none';
v.style.transform=`translateY(${Math.max(0,dy)}px)`;
} else if(vDrag.mode==='nav'){
vTx=Math.max(-70,Math.min(70,dx)); vApply(); // live edge-feedback
} else { vTx=vDrag.px+dx; vTy=vDrag.py+dy; vApply(); } // pan while zoomed
});
function vUp(e){
const wasDrag=vDrag, dx=wasDrag?e.clientX-wasDrag.sx:0, dy=wasDrag?e.clientY-wasDrag.sy:0;
if(wasDrag&&vPtrs.size<=1){
const mode=wasDrag.mode;
if(mode==='pull'){
if(dy>110){ closeViewer(); }
else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; }
} else if(mode==='nav'){
if(Math.abs(dx)>=50&&vCtx){
const nx=vCtx.idx+(dx>0?-1:1); // clamp at the ends
if(nx>=0&&nx<vCount()){ vCtx.idx=nx; vPaint(); }
}
vTx=0; vApply();
} else if(mode==='pan'){
// keep pan position
} else if(e.type==='pointerup'){ // tap -> double-tap zoom toggle
const now=Date.now();
if(now-vLastTap<300){ vLastTap=0; vScale= vScale>1?1:2.5; vApply(); }
else vLastTap=now;
}
vDrag=null;
}
vPtrs.delete(e.pointerId); vPinch=null;
}
vph.addEventListener('pointerup',vUp);
vph.addEventListener('pointercancel',vUp);
/* pull-down close from the grabber (the §3 idiom) */
let vgY=null;
function vgStart(e){ vgY=e.clientY; $('viewer').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} }
function vgMove(e){ if(vgY===null) return; $('viewer').style.transform=`translateY(${Math.max(0,e.clientY-vgY)}px)`; }
function vgEnd(e){ if(vgY===null) return; const dy=Math.max(0,e.clientY-vgY); vgY=null;
if(dy>110){ closeViewer(); }
else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; } }
$('vgrab').addEventListener('pointerdown',vgStart);
$('vgrab').addEventListener('pointermove',vgMove);
$('vgrab').addEventListener('pointerup',vgEnd);
$('vgrab').addEventListener('pointercancel',vgEnd);
/* ---------- list screen (real filters + search) ---------- */
let listItems=[];
const listCtl={decision:'all',sort:'score',q:'',priceDir:1};
@@ -547,7 +982,7 @@ function rowHTML(it){
<div class="row" data-i="${it.id}">
${thumbFor(it)}
<div class="rmain"><div class="rtitle"></div><div class="rsub"></div></div>
<div class="rright"><div class="rprice"></div>
<div class="rright"><div class="rpt">${it.tank?tankSVG(it.tank):''}<div class="rprice"></div></div>
<div class="rc" data-i="${it.id}">
<button class="rbtn like${on('like')?' on':''}" data-s="like">♥</button>
<button class="rbtn maybe${on('maybe')?' on':''}" data-s="maybe">★</button>
@@ -700,20 +1135,134 @@ async function loadStats(){
}
}
/* ---------- P3 section UI: progress pill + report + forge + modes ---------- */
let sectDecided=0, sectTotal=0;
function paintSect(){
const t=$('sect');
if(legacyMode||!section){ t.textContent=''; return; }
t.textContent=`${sectDecided}/${sectTotal}`;
t.classList.toggle('hot',sectDecided>=sectTotal);
}
function paintMode(){
const m= section?section.mode:'ondemand';
$('modeOnd').classList.toggle('on',m==='ondemand');
$('modeStr').classList.toggle('on',m==='strict');
$('tOnd').classList.toggle('on',m==='ondemand');
$('tStr').classList.toggle('on',m==='strict');
$('strictCap').style.display= m==='strict'?'':'none';
const btn=$('btnForge');
if(m==='strict'){ btn.disabled=true; }
else if(!forgeBusy) btn.disabled=false;
}
function updateSectionUI(body){
sectTotal=body.progress.total;
let decided=0;
for(const it of (body.items||[])){
const local=localDecisions.get(it.id);
if(local===undefined? it.decision!=null : local!=null) decided++;
}
sectDecided=decided;
paintSect();
paintMode();
const c=body.counts||{};
$('repCounts').innerHTML=`♥ ${c.likes||0} · ★ ${c.maybes||0} · ✕ ${c.nopes||0}`;
const hits=[];
if(c.restruck>0) hits.push('Second looks re-struck: '+c.restruck);
if(c.wildcard_hits>0) hits.push('Wildcards that hit: '+c.wildcard_hits);
$('repLine2').textContent=hits.join(' · ');
}
async function setMode(m){
const res=await fetch('/api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:'forge_mode',value:m})}).catch(()=>null);
if(res&&res.ok){ if(section) section.mode=m; paintMode(); }
}
$('modeOnd').onclick=()=>setMode('ondemand');
$('modeStr').onclick=()=>setMode('strict');
$('btnForge').onclick=async()=>{
if(forgeBusy||!section) return;
forgeBusy=true;
const btn=$('btnForge'); btn.disabled=true;
$('forgeStatus').textContent='The agent is reading your strikes…';
try{
const res=await fetch('/api/section/forge',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const body=await res.json();
if(res.ok){
$('forgeStatus').textContent= body.note&&body.note.indexOf('fallback')>=0
?'Agent offline — forging on the last read.':'';
section=body;
buffer.length=0;
const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id)]);
const fresh=(body.items||[]).filter(it=>(it.decision===null||it.slot==='return')&&!live.has(it.id));
buffer.push(...fresh);
fillPile();
updateSectionUI(body);
if(pile.length){ $('report').classList.remove('on'); }
}else{
$('forgeStatus').textContent= body.detail||'Forge failed';
paintMode();
}
}catch(e){ $('forgeStatus').textContent='Offline — retrying'; }
forgeBusy=false;
paintMode();
};
/* ---------- P3 trends tab ---------- */
function fmtPct(f){ return f==null?'—':((Math.round(f*1000)/10)+'%'); }
async function fetchTrends(){
const t=await getJSON('/api/trends');
if(!t) return;
const p=t.profile;
$('tLead').textContent= p?p.lead:'—';
$('tReadAt').textContent= p?('updated '+relTime(p.created_at)):'';
$('tCold').style.display= p?'none':'';
$('tLeanSec').style.display= p&&p.leanings&&p.leanings.length?'':'none';
$('tLeanings').innerHTML= ((p&&p.leanings)||[]).map(x=>`<div>· ${x}</div>`).join('');
const s=t.stats||{};
$('tmHit').textContent= fmtPct(s.hit_rate);
$('tmRet').textContent= s.returns_re_struck??'0';
$('tmWild').textContent= s.wildcards_hit??'0';
const since= (p&&p.changes&&p.changes[0])||null;
$('tSinceSec').style.display= since?'':'none';
$('tSince').style.display= since?'':'none';
$('tSince').textContent= since||'';
const lr=t.last_run;
$('tLastRun').textContent= lr&&lr.ok
?`Last read ${relTime(lr.finished_at)} · ${lr.model||'—'}`
:(lr?`Last run failed (${lr.source||'?'}) — will retry`:'Last read —');
if(section) section.mode=t.forge_mode;
paintMode();
}
$('btnRunAgent').onclick=async()=>{
if(runBusy) return;
runBusy=true;
$('runStatus').textContent='Reading your strikes…';
const res=await fetch('/api/agent/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>null);
if(res&&res.ok){
const b=await res.json();
$('runStatus').textContent= b.source==='fallback'?'Agent offline — ran on the last read':'Done — the next read uses it.';
}else{
$('runStatus').textContent="Couldn't run — see the read above.";
}
runBusy=false;
fetchTrends();
};
$('tOnd').onclick=()=>setMode('ondemand');
$('tStr').onclick=()=>setMode('strict');
/* ---------- tabs ---------- */
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
const name=t.dataset.tab;
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x===t));
$('listScreen').classList.toggle('on',name==='list');
$('matchesScreen').classList.toggle('on',name==='matches');
$('stubTrends').classList.toggle('on',name==='trends');
$('trendsScreen').classList.toggle('on',name==='trends');
if(name==='list') fetchList(); // fresh decisions every visit
else if(name==='matches') fetchMatches();
else if(name==='trends') fetchTrends();
});
/* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')) return;
if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')||$('viewer').classList.contains('on')) return;
if(e.key==='ArrowRight'){ if(pile[0]) commit(pile[0].el,'like'); }
else if(e.key==='ArrowLeft'){ if(pile[0]) commit(pile[0].el,'nope'); }
else if(e.key==='ArrowUp'){ if(pile[0]) commit(pile[0].el,'maybe'); }
@@ -722,7 +1271,7 @@ document.addEventListener('keydown',e=>{
/* ---------- boot ---------- */
loadStats();
loadDeck();
loadSection();
/* PWA */
if('serviceWorker' in navigator && (location.protocol==='https:'||location.hostname==='localhost'||location.hostname==='127.0.0.1')){

View File

@@ -6,7 +6,7 @@
offline once seen).
- Everything else (shell/navigation): network-first, fall back to cache.
*/
const VERSION = 'bike-tinder-v1';
const VERSION = 'bike-tinder-v3';
const SHELL = [
'/',
'/manifest.json',

398
app/taste.py Normal file
View File

@@ -0,0 +1,398 @@
"""Bike-Tinder P3 taste engine — deterministic, pure, stdlib-only.
Implements docs/RANKING.md "Stage 2 — P3 taste engine":
- event weights: action (like +1.0 / maybe +0.4 / nope -1.0) x round
multiplier (round 1 x1, round >=2 x2) x recency (0.5^(age_days/21)).
- feature weights per (feature, value): raw = sum of event weights,
shrunk = raw * n/(n+3) with n the number of events in that cell.
- taste_raw = sum of shrunk weights over the listing's feature values.
- taste_norm = percentile rank of taste_raw among active listings (0-100).
- ramp w = min(0.85, N/60) with N = total events; zero events => w = 0.
- points = clip(round((1-w)*rubric + w*taste_norm + agent_adj), 0, 100)
with agent_adj = sum of matching profile adjustments clamped to +/-10.
- band() maps points to a non-numeric tank band: <30 nope / 30-70 maybe / >70 like.
No randomness, no network. Everything is a pure function of
(now, listings, events, profile_payload).
"""
import bisect
import datetime
import json
import math
import re
from collections import Counter
from typing import Any, Dict, List, Optional, Tuple
LIKE, MAYBE, NOPE = 'like', 'maybe', 'nope'
ACTIONS = (LIKE, MAYBE, NOPE)
ACTION_WEIGHT = {LIKE: 1.0, MAYBE: 0.4, NOPE: -1.0}
ROUND_CAP = 2 # engine caps round influence at 2
HALF_LIFE_DAYS = 21.0
RAMP_MAX = 0.85
RAMP_DENOM = 60.0
ADJ_CLAMP = 10.0
# --- feature extraction ------------------------------------------------------
PRICE_BANDS = [
(None, 3000, '<3k'),
(3000, 5000, '3-5k'),
(5000, 8000, '5-8k'),
(8000, 12000, '8-12k'),
(12000, None, '12k+'),
]
DISP_BANDS = [
(None, 400, '<400'),
(400, 650, '400-649'),
(650, 900, '650-899'),
(900, 1250, '900-1249'),
(1250, None, '1250+'),
]
YEAR_BANDS = [
(None, 2000, '<2000'),
(2000, 2010, '2000-2009'),
(2010, 2020, '2010-2019'),
(2020, None, '2020+'),
]
KM_BANDS = [
(None, 10000, '<10k'),
(10000, 25000, '10-25k'),
(25000, 60000, '25-60k'),
(60000, None, '60k+'),
]
SILHOUETTES = ['bobber', 'custom', 'classic', 'cruiser']
COLORS = [
('red', 'red'),
('black', 'black'),
('white', 'white'),
('blue', 'blue'),
('green', 'green'),
('silver|grey|gray', 'silver'),
('orange', 'orange'),
('yellow', 'yellow'),
('brown|maroon', 'brown'),
('tan', 'tan'),
('gold', 'gold'),
]
_CC_RE = re.compile(r'(\d[\d,]*)\s*cc\b', re.IGNORECASE)
def band_for(sorted_edges, value):
"""Find the containing band (edges [(lo, hi, label)]); value None -> None."""
if value is None:
return None
for lo, hi, label in sorted_edges:
if (lo is None or value >= lo) and (hi is None or value < hi):
return label
return None
def price_band(price_num):
return band_for(PRICE_BANDS, price_num)
def disp_cc(title, desc):
m = _CC_RE.search('%s %s' % (title or '', desc or ''))
if not m:
return None
return int(m.group(1).replace(',', ''))
def disp_band(cc):
return band_for(DISP_BANDS, cc)
def year_band(year):
return band_for(YEAR_BANDS, year)
def km_band(km):
return band_for(KM_BANDS, km)
def extract_features(row):
"""Return the listing's list of (dimension, value) feature cells.
Missing values yield no cell (neutral per RANKING.md).
"""
title = row['title'] or ''
desc = (row['desc'] or '').split(' [also listed:')[0]
text = (title + ' ' + desc).lower()
feats = []
if row['family']:
feats.append(('family', row['family']))
pb = price_band(row['price_num'])
if pb:
feats.append(('price_band', pb))
db_ = disp_band(disp_cc(title, desc))
if db_:
feats.append(('displacement', db_))
yb = year_band(row['year'])
if yb:
feats.append(('year', yb))
kb = km_band(row['km'])
if kb:
feats.append(('km', kb))
extras = row['extras']
if isinstance(extras, str):
try:
extras = json.loads(extras)
except ValueError:
extras = []
for label in extras or []:
feats.append(('extras', label))
if row['seller_type'] in ('dealer', 'private'):
feats.append(('seller', row['seller_type']))
for pat in SILHOUETTES:
if re.search(r'\b%s\b' % pat, text):
feats.append(('silhouette', pat))
for pat, label in COLORS:
if re.search(r'\b%s\b' % pat, text):
feats.append(('color', label))
if row['region'] in ('ns', 'nb'):
feats.append(('region', row['region']))
return feats
FEATURE_DIMS = ('family', 'price_band', 'displacement', 'year', 'km',
'extras', 'seller', 'silhouette', 'color', 'region')
def family_short(family):
"""Compact family label for display (' — ' suffix trimmed)."""
if not family:
return family
return family.split(' — ')[0].strip()
# --- datetime handling -------------------------------------------------------
def parse_ts(value, now):
"""Parse an event created_at ('...T...Z' ISO or 'YYYY-MM-DD HH:MM:SS'
sqlite default, both UTC) into a naive-UTC datetime; failures -> None."""
del now # kept in the signature for call sites
base = (value or '').strip()
if not base:
return None
if base.endswith('Z'):
base = base[:-1]
try:
return datetime.datetime.fromisoformat(base)
except ValueError:
pass
return None
def event_weight(action, round_, created_at, now):
"""Weight of one swipe event: action x round x recency."""
w = ACTION_WEIGHT.get(action, 0.0)
r = ROUND_CAP if (round_ or 1) >= ROUND_CAP else (round_ or 1)
if r >= 2:
w *= 2.0
ts = parse_ts(created_at, now)
if ts is not None:
age_days = max(0.0, (now - ts).total_seconds() / 86400.0)
w *= math.pow(0.5, age_days / HALF_LIFE_DAYS)
return w
# --- the engine --------------------------------------------------------------
class TasteEngine:
"""Compute points / breakdown / tank for each listing from events.
listings: rows (sqlite.Row or dict) with keys id, title, desc, family,
score, price_num, km, year, seller_type, extras, region.
events: rows with keys listing_id, action, round, created_at.
profile: taste_profile payload dict (adjustments key used) or None.
now: naive-UTC datetime for recency (default: datetime.utcnow()).
"""
def __init__(
self,
listings: List[Dict[str, Any]],
events: List[Dict[str, Any]],
profile: Optional[Dict[str, Any]] = None,
now: Optional[datetime.datetime] = None,
) -> None:
self.now = now or datetime.datetime.now(datetime.timezone.utc).replace(
tzinfo=None)
self.listings: List[Dict[str, Any]] = listings
self.profile: Dict[str, Any] = profile or {}
self.adjustments: List[Dict[str, Any]] = (
self.profile.get('adjustments') or [])
# listing_id -> row (also covers listings reachable only via events)
self.by_id: Dict[int, Dict[str, Any]] = {
r['id']: r for r in self.listings}
# feature cells per listing id
self.cells: Dict[int, List[Tuple[str, str]]] = {
r['id']: extract_features(r) for r in self.listings}
# events -> weights, aggregated per (dim, value)
self.events = list(events)
cells_raw = {}
cells_n = {}
for ev in self.events:
lid = ev['listing_id']
w = event_weight(ev['action'], ev['round'], ev['created_at'],
self.now)
for dim, val in self.cells.get(lid, []):
key = (dim, val)
cells_raw[key] = cells_raw.get(key, 0.0) + w
cells_n[key] = cells_n.get(key, 0) + 1
self.cells_raw = cells_raw
self.cells_n = cells_n
self.event_counts = Counter(ev['listing_id'] for ev in self.events)
self._norm_cache = None
def round_next(self, listing_id):
"""Round the NEXT event on this listing would get, capped at 2."""
return min(self.event_counts.get(listing_id, 0) + 1, 2)
# -- aggregates -----------------------------------------------------------
@property
def n_events(self):
return len(self.events)
@property
def w(self):
"""Taste share of the blend (ramp)."""
return min(RAMP_MAX, self.n_events / RAMP_DENOM)
def shrunk(self, dim, val):
raw = self.cells_raw.get((dim, val), 0.0)
n = self.cells_n.get((dim, val), 0)
return raw * (n / (n + 3.0)) if n else 0.0
def family_net(self, family):
"""Net raw weight + nope count for a family label (all events)."""
raw = 0.0
nopes = 0
for ev in self.events:
row = self.by_id.get(ev['listing_id'])
if row and row['family'] == family and family:
raw += event_weight(ev['action'], ev['round'],
ev['created_at'], self.now)
if ev['action'] == NOPE:
nopes += 1
return raw, nopes
# -- per-listing scores ---------------------------------------------------
def taste_raw(self, listing_id):
return sum(self.shrunk(d, v) for d, v in self.cells.get(listing_id, []))
def taste_norm(self, listing_id):
"""Median-rank percentile (0-100) among active listings."""
if self._norm_cache is None:
raws = {}
for lid, cells in self.cells.items():
raws[lid] = sum(self.shrunk(d, v) for d, v in cells)
ranked = sorted(raws.values())
n = len(ranked)
self._norm_cache = {}
for lid, r in raws.items():
less = bisect.bisect_left(ranked, r)
eq = bisect.bisect_right(ranked, r) - less
pct = 100.0 * (less + 0.5 * eq) / n if n else 50.0
self._norm_cache[lid] = pct
return self._norm_cache.get(listing_id, 50.0)
def agent_adj(self, listing_id):
"""Sum of matching profile adjustments, clamped to +/-10."""
total = 0.0
cells = {d: set() for d, _ in self.cells.get(listing_id, [])}
for d, v in self.cells.get(listing_id, []):
cells[d].add(v)
for adj in self.adjustments:
dim = adj.get('feature')
val = adj.get('value')
delta = adj.get('delta')
if dim not in FEATURE_DIMS:
continue
if not isinstance(delta, (int, float)):
continue
if val in cells.get(dim, set()):
total += float(delta)
return max(-ADJ_CLAMP, min(ADJ_CLAMP, total))
def points(self, listing_id):
row = self.by_id.get(listing_id)
if row is None:
return None
pts = (1.0 - self.w) * (row['score'] or 0)
pts += self.w * self.taste_norm(listing_id)
pts += self.agent_adj(listing_id)
return int(max(0, min(100, round(pts))))
@staticmethod
def band(points):
if points is None:
return None
if points < 30:
return NOPE
if points <= 70:
return MAYBE
return LIKE
def tank(self, listing_id):
pts = self.points(listing_id)
band = self.band(pts)
return {'value': pts, 'band': band}
# -- explainability -------------------------------------------------------
def contributions(self, listing_id):
"""[(display_label, shrunk_weight, dim, val)] sorted by |w| DESC."""
out = []
for d, v in self.cells.get(listing_id, []):
s = self.shrunk(d, v)
if not s:
continue
label = ('%s %s' % (d, v))
if d == 'family':
label = family_short(v) + ' family'
out.append((label, s, d, v))
out.sort(key=lambda t: abs(t[1]), reverse=True)
return out
def breakdown(self, listing_id):
"""Source-labeled breakdown lines for the dossier."""
row = self.by_id.get(listing_id)
lines = []
lines.append({'source': 'rubric', 'label': 'rubric fit',
'value': int(row['score'] or 0)})
for label, s, _d, _v in self.contributions(listing_id)[:3]:
lines.append({'source': 'taste', 'label': label,
'value': round(s, 1)})
for adj in self.adjustments:
dim = adj.get('feature')
val = adj.get('value')
delta = adj.get('delta')
if dim not in FEATURE_DIMS or not isinstance(delta, (int, float)):
continue
if val in {v for d, v in self.cells.get(listing_id, [])
if d == dim}:
why = adj.get('why') or ('%s %s' % (dim, val))
lines.append({'source': 'agent', 'label': why,
'value': round(float(delta), 1)})
return lines

28
docs/BACKLOG.md Normal file
View File

@@ -0,0 +1,28 @@
# BACKLOG — Bike-Tinder
Glenn's "later versions" idea queue. He drops ideas in conversation; they land here (near-verbatim + a scope sketch + open questions), and get built in a future version — never mid-phase. Newest first.
## B1 · Photo experience v2 — multi-image galleries, photo swipe, fullscreen viewer
**Queued 2026-11-10** (during the P3 build; for the version after it). **PULLED FORWARD 2026-11-10 — building now, before Phase 4 (Glenn's call).** Spec frozen in `UI-SPEC.md` §11 + `DATA-MODEL.md` B1 additions; live tree untouched during the build.
Glenn's words: *"more pictures! As many pictures as we can get honestly. I'd like to swipe through them left and right like tinder does. One repeats, the image should change as to not tip me off."* · *"I'd like to tap the image to 'full screen' it so I can see it better."*
Scope sketch:
1. **Capture every gallery photo.** Today the pipeline keeps ONE image per listing. Verified 2026-11-10: Kijiji detail pages expose the full gallery (probe — 2026 Sport Scout page: 8 unique images; Scout Sixty Bobber: 3). The image tool already opens each listing's page when filling photos — it just keeps the first image. Extension = grab all unique gallery images (dedupe by image uuid, keep page order). State gains `imgs: [...]` beside `img`; DB carries the photo list; the mirror downloads all (sizing rule unchanged: `?rule=kijijica-640-webp`). Storage estimate: ~640 listings × ~5–10 photos × ~70–120 KB ≈ **350–600 MB** — fine for the mirror.
2. **Photo swipe on the card** — Tinder-style: tap-left / tap-right zones advance photos (segment indicators on the photo's top edge); horizontal drag stays LIKE/NOPE — the no-lock law outranks everything.
3. **Fullscreen viewer** — tap the image → fullscreen photo (pinch-zoom, swipe through the gallery, pull/✕ to close); works from the deck card and the dossier hero.
4. **Repeat handling — "the image should change so it doesn't tip me off."** Two readings to settle at spec time with Glenn:
- (a) when a card repeats (second look / re-listed ad), lead with a *different* photo than the one previously shown, so the repeat doesn't read as déjà vu;
- (b) dedupe/rotate so the same photo never repeats twice in a row within or across a swipe session.
Both are cheap once galleries exist; ask which was meant at spec time.
5. **Gesture-map tension to resolve at spec:** today a tap on the card opens the dossier, and Glenn now wants tap = fullscreen; Tinder's model taps to advance photos. The exact zone map (tap-left/right = photo, center/ⓘ = dossier vs fullscreen) is a spec-time design session with Glenn. Dossier remains reachable from ⓘ and list rows either way.
> **Settled 2026-11-10 (Glenn, in chat):** gesture map = tap sides flip photos · tap center fullscreen · ⓘ details. Repeat handling = lead with a *different* photo each time (rotation). Frozen in `UI-SPEC.md` §11.
Not for P3 — nothing here blocks or alters the current phase. Forward-compatible: gallery segment indicators and the P3 card bands (§10.3) must share the card's top area; the stack/drag engine is untouched.
---
*Other v2+ seeds already live where they were planted:* distance feature (`RANKING.md` open decisions) · `AUTH_TOKEN` hardening (`DESIGN.md` security) · Facebook paste-the-link box (`SOURCES.md`) · weekly digest, "hide this seller" (`RANKING.md` / `DESIGN.md` risk notes).

View File

@@ -105,3 +105,74 @@ LIMIT :deck_size;
- **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).

View File

@@ -62,6 +62,22 @@ The nightly import is triggered by its own Hermes cron job — `bike-tinder-impo
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`.

View File

@@ -1,6 +1,6 @@
# PLAN — Bike-Tinder
**Status:** Build phase — Phase 1 complete (2026-11-10); Phase 2 not yet started. Phases are gated: each lands, is verified, then the next begins. Glenn's standing rule applies — "pause" stops all spend immediately.
**Status:** Build phase — Phase 2 complete + phone-proven (2026-11-10); **Phase 3 built, verified, and live on the dev server (2026-11-11)**; Phase 4 next. Phases are gated: each lands, is verified, then the next begins. Glenn's standing rule applies — "pause" stops all spend immediately.
## v1 goal
@@ -34,20 +34,32 @@ A single-source, single-user, tailnet-only Tinder-style PWA on the Rotarran: nig
## Phase 2 — The swipe app (core)
**Status: built by Torres + verified by Alex (2026-11-10) on `dev`; awaiting Glenn's phone pass.** Contract frozen: `UI-SPEC.md` v2 + `reference/mockup-001-fidelity.html` (Variant A "Pure", approved by Glenn; 5-deep live stack, no-lock commit, dossier sheet). Image backfill + app-side mirror (`app/imgfetch.py`) done: 619/620 listings enriched, 559 local webp.
**Status: built by Torres + verified by Alex (2026-11-10) on `dev`; phone pass LIVE (swiping over the tailnet).** Contract frozen: `UI-SPEC.md` v2 + `reference/mockup-001-fidelity.html` (Variant A "Pure", approved by Glenn; 5-deep live stack, no-lock commit, dossier sheet). Image backfill + app-side mirror (`app/imgfetch.py`) done: 619/620 listings enriched, 559 local webp.
- [x] 2–3 UI mock variants → Glenn picked **Variant A "Pure"** (approved 2026-11-10 after two fix rounds)
- [x] API (deck cursor, swipe upsert/clear, list + filters, stats, media, import) + PWA frontend (faithful mockup port: stack engine, drag, Like / Nope / Maybe, dossier, empty/undo) — no match overlay (removed by Glenn's call)
- [x] Full list view with inline edit; basic filters (decision + sort + search)
- [ ] Proof: runs locally on :9453 (systemd dev unit) + tailnet `http://rotarran.tailf35a34.ts.net:9453`; phone-viewport browser pass green — **Glenn's phone pass pending**
- **Exit criteria:** Glenn can swipe a real deck on his phone and edit results in the list.
- [x] Proof: runs locally on :9453 (systemd dev unit) + tailnet `http://rotarran.tailf35a34.ts.net:9453`; phone-viewport browser pass green; **phone pass live 2026-11-10** — Glenn swiping the real deck over the tailnet (24 swipes recorded + verified from the DB; list-edit tap still unexercised by Glenn — automation-covered)
- **Exit criteria:** Glenn can swipe a real deck on his phone and edit results in the list — swiping proven; edit-tap pending.
## Phase 3 — Personalization
## Phase 3 — Personalization ("Trends")
- Swipe history → nightly retrain of explainable ranking
- Trends panel ("you lean: …"); "because you liked X" chips
- Deck ordering by learned taste with cold-start guardrails
- **Exit criteria:** after ~30 swipes, deck order visibly reflects taste; explainability shown in UI.
**Status: BUILT → VERIFIED → LIVE (2026-11-11).** Contract frozen 2026-11-10; built by Torres in ~45 min ($3.80, 128 calls, 25/25 selfcheck; commits `04463ca`, `c205d31`, `b6e1851`); independently verified by Alex on DB copies — selfcheck 25/25 re-run, full section loop (compose → strikes → rounds → report → forge), strict-mode 409/200, forge-on-demand, trends, tank gauges, dossier "Why this rank". **One real defect found + fixed** (`1e4d840`): second-look returns were filtered out of the swipe buffer (`decision===null`), leaving round-2 dead in the UI — fixed and verified end to end (SECOND LOOK band, restrike writes round 2). **Live integration 2026-11-11:** backup at `~/dev/bike-tinder-p3/db-backup-pre-p3-20261111.sqlite3`; service restarted; P3 tables + 66 seeded events; section 1 composed; live calibration pass run (`deepseek/deepseek-v4.1-flash`, $0.0042; `agent_runs` #1) → taste profile v1 (`source=agent`).
Glenn's approved design (2026-11-10):
- **Sections of 25:** the deck is a finite batch; the agent always runs between sections (learning segmentation + anti-doom-scroll). Modes: forge-on-demand (default) / strict (nightly read only). Fallback to the last profile if the agent fails.
- **Section lead:** on open/refresh the best suggestions lead; the 5-card live stack is unchanged.
- **Wildcards (~1-in-10):** labeled "maybe you'll like this one instead"; plausible stretches; never hard-noped families; their answers teach the model.
- **Second looks:** likes + maybes + nopes return (chosen; ≤5/section, ≤2 nopes); Round 2 weighs ×2; flips are the loudest signal; a re-nope rests the item; rounds cap at 2. Returning maybes can bring their variants.
- **Match points:** taste + agent adjustments; drive ordering, the section recipe (family caps, price mix), and returns. Explainable in the dossier; tank gauge (non-numeric) in the List.
- **Hits objective:** order = expected-like; Trends reports the hit rate (likes; likes+maybes).
- **Agent pass:** nightly/on-demand read (mock + live + fallback); **no LLM in the swipe path — ever.**
- **Exit criteria:** after ≥30 swipes the deck order visibly reflects taste; explainability visible (dossier breakdown + Trends); the section loop works end-to-end; the agent kill-switch test passes (app fully functional on the last profile with the pass disabled).
**Carried forward:** service-side OpenRouter key wiring at P4 — until then each forge's agent pass runs in mock mode and supersedes the live profile · wildcard pool hygiene (rare non-bike listings can surface) · early-ramp texture: dense nopes punish broad cells (year 2020+, 12k+, nb) — the Scouts read mid until the agent carves family-level exceptions · minor reload edge: an already-restruck second look can re-cycle on mid-section reload.
**Glenn's call (2026-11-10, late): B1 "Photo experience v2" pulled forward** — built next, *before* Phase 4 ("get that feature baked in before we proceed to phase 4"). Spec frozen in `UI-SPEC.md` §11 (galleries · tap-side photo flip · center-tap fullscreen viewer · fresh lead photo on repeats) + `DATA-MODEL.md` B1 additions. Rail side: hunt gallery capture + full-catalog backfill (Alex). App side: Torres on a fresh clone (`~/dev/bike-tinder-b1-build`) — the live tree and live DB stay untouched during the build; tests on port 9454 + DB copies.
## Phase 4 — Deploy + automate

View File

@@ -3,7 +3,7 @@
The deck is ordered by predicted taste. Three rungs, in order: the hunt's existing rubric (v1), a cheap explainable boost from swipe clusters (v1.x), then a small learned model (Phase 3). Nothing ever becomes a black box.
## Signals
- Like = strong positive (+1). Maybe = weak positive (+0.25, kept in its own bucket). Nope = negative (−1). List-view edits count identically to in-deck swipes; the latest call wins.
- Like = strong positive (+1). Maybe = weak positive (+0.4) on the *direction* — "right lane, wrong trim" (Glenn's read): the family/silhouette reads positive, the specific build is uncertain; maybes replenish via second looks + variant hunts. Nope = negative (−1). List-view edits count identically; the latest call wins (full history lives in `swipe_events`).
- Planned v2 signals: dwell time on card, "open listing" click-outs.
## Features (v1)
@@ -16,22 +16,32 @@ Order by the hunt's existing rubric score (family tiers + MVI / whitewalls / win
## Stage 1.5 — Cheap swipe boost (v1.x, post-launch)
Before any model exists, the importer can nudge `score` with plain swarm signals — e.g. +N when recent likes cluster in a family, small time-decay otherwise — recomputed each import, zero ML, fully explainable. Ships as v1.x if Glenn wants it; v1 itself stays pure rubric.
## Stage 2 — Learned order (Phase 3 roadmap)
- Model: logistic regression on the features above; target = like vs nope (maybes contribute softly positive). Trained on all swipe history including edits.
- Pure-Python or numpy implementation (tiny data — no heavy ML dependency). Params + training metadata live in the DB; versioned.
- Retrain nightly right after import; on-demand retrain available from the app.
- Guardrails:
- Blend: until 100 swipes, deck order = 50% rubric rank + 50% model rank; after, model order with an exploration slice (roughly every 8th card = top-rubric card not yet shown).
- Never hide: everything stays in List; ranking only reorders the deck.
- Sanity: if model like-rate underperforms the rubric baseline (tracked weekly), it reverts to rubric order silently.
## Stage 2 — P3 taste engine (frozen 2026-11-10)
Deterministic, explainable, pure-Python. Learning reads `swipe_events` only.
**Event weights.** action: like +1.0 · maybe +0.4 · nope −1.0; round multiplier: round 1 ×1, round ≥2 **×2** (second strikes hit twice as hard — reaffirmations and flips both); recency: ×0.5^(age_days / 21). Event weight = product.
**Feature weights.** Feature set per listing: family · price band (`<3k, 3–5k, 5–8k, 8–12k, 12k+`) · displacement band (from title/desc) · year band · km band (when present) · each extras flag · seller type · silhouette keywords (bobber/custom/classic/cruiser) · color words · region. For each (feature, value): `raw = Σ event weights`; `shrunk = raw × n/(n+3)` — no single strike taboos a family.
**Score.** `taste_raw` = Σ shrunk weights over the listing's values; `taste_norm` = percentile rank among active listings (0–100). Blend: `w = min(0.85, N/60)` where N = total events; `points = clip( round( (1−w)·rubric + w·taste_norm + agent_adj ), 0, 100 )`. `agent_adj` = Σ profile adjustments (clamped ±10 per listing). Breakdown for the dossier: rubric share · top 3 ± taste features · agent lines — all source-labeled.
**Section recipe.** 25 cards = 2 wildcards + ≤5 returns + remainder new (points DESC). Constraints: ≤4 per family per section; ≥3 price bands when the pool allows; shortfalls fill from new.
**Wildcards.** Candidates: active, unseen, not in the last two sections. Exclusions: families with ≥3 cumulative nopes and net-negative weight. Prefer: different family AND different price band than the section's top new items. Score = `0.5·points + 0.5·novelty` (novelty = distance from the liked-profile centroid). Picks: 2 per section.
**Returns ("second looks").** Pool + caps per DATA-MODEL. Order: maybe > like > surprising nope (points ≥55 or net-positive family); rank within role by points DESC. A re-nope (round≥2 nope) rests the item permanently in v1.
**Guardrails.** Never hide (List untouched; ranking only reorders). The ramp keeps cold start on rubric; the engine must run with zero profile (w=0). Agent offline → compose on the last profile (source-labeled). **No LLM inside any request path or computed score** — the pass only writes the profile.
## Explainability
Each card can show up to 2 chips: top positive contributions for that bike ("low km for the money", "Vulcan family", "red", "MVI"). Chips derive from the live feature contributions; during cold start they come from the rubric's existing reasons.
## Evaluation
- Track: swipes/session, like-rate trend, maybe-rate, undo rate, and "liked-item rank" (how deep liked items sat — should trend shallow).
- P3 adds: hit rate (likes/decided · likes+maybes), returns re-strike rate, wildcard hit rate, flip count — surfaced in the Trends tab.
- Notifications (decided 2026-11-10): in-app "new since last visit" badge only — no Conduit pushes. A weekly digest remains possible later if asked for.
## Open decisions
- Maybe-handling: weak positive + separate bucket (proposed) vs pure third bucket — confirm with Glenn at Phase 2.
- Maybe-handling — **resolved 2026-11-10 (Glenn):** weak positive on the direction ("right lane, wrong trim"); maybes replenish via second looks + variant hunts.
- Distance feature (map-based proximity) — likely v2.

View File

@@ -81,7 +81,7 @@ Opened from deck ⓘ (or tap) AND list row tap. iOS-sheet geometry: top gap 26px
## 6. Trends
- Stub (Phase 3): "Trends — Phase 3" / "The deck learns your taste as you swipe."
- **Real (P3):** see §10.6.
## 7. Production deltas (the ONLY changes from the mockup)
@@ -110,3 +110,116 @@ Machine-checkable:
- Selfcheck: all P1 checks green + new API checks green.
Human (parent-run after build): Glenn swipes a real deck from his phone and edits results in the list — the Phase 2 exit criterion.
## 10. P3 additions (v3) — Personalization UI
**Frozen 2026-11-10 (Glenn approved).** Everything in §1–§7 remains in force; all of this is additive. The card face gains only the two system bands (§10.3); everything else lives in the list, the report screen, the dossier, and Trends.
### 10.1 Sections — the deck becomes a finite batch
- The deck serves ONE section at a time: 25 cards (default). A section is the unit of a swipe burst; when it is exhausted, the next one is forged (with an agent pass in between — §10.2).
- Header right side, next to the stats: section progress "7/25" (12px/650, muted; turns `--accent1` at 25/25; fits within the existing header, no layout shift).
- Section composition (engine side — RANKING.md): ≤5 SECOND LOOK cards + 2 WILDCARD cards + the remainder new.
- ALL §2 mechanics apply unchanged inside a section: 5-deep stack, `.topcard` invariant, no-lock commit, stamps, undo; the buffer refills from the pinned section order.
### 10.2 Section report + forge
- When no undecided item remains, the deck shows the report card (replaces the §2 empty state when a section exists; same visual language):
- Title: **"Section complete."**
- Counts row: "♥ {likes} · ★ {maybes} · ✕ {nopes}"
- Sub: "The file is digesting your strikes."
- Line 2 (only when >0): "Second looks re-struck: {n} · Wildcards that hit: {n}"
- Actions:
- **Forge mode `ondemand` (default):** primary button **"Forge next 25"**. Tap → inline status "The agent is reading your strikes…" → new section hydrates. If the pass fails: "Agent offline — forging on the last read." and the forge still lands (fallback profile).
- **Forge mode `strict`:** button disabled; caption "Waiting for the nightly read — next section after the agent runs."
- Both modes: small toggle row under the actions — "Forge: on demand ⇄ strict" (persists `prefs['forge_mode']`).
- Undo from the report screen re-opens the section with the last card (§2 undo semantics).
### 10.3 Card bands (the ONLY card-face additions)
- Position: top-left inset (12px); `pointer-events:none`; no layout shift; no effect on stack metrics or drag. 11px/650, letter-spacing .4px, radius 999px, padding 4px 9px, max-width 78%; may wrap to 2 lines.
- **WILDCARD — maybe you'll like this one instead** — color `#ffb43a`; bg `rgba(255,180,58,.14)`; border `1px solid rgba(255,180,58,.35)`.
- **SECOND LOOK — you {liked|maybed|noped} this** — color `rgba(255,255,255,.85)`; bg `rgba(255,255,255,.10)`; border `1px solid rgba(255,255,255,.22)`.
- No other text may sit on the card face in v3.
### 10.4 Tank gauge (match points, non-numeric)
- Small fuel-tank glyph; fill = match points (0–100). List rows: 18×22px in the trailing cluster, immediately left of the price, vertically centered. Dossier: 28×34px.
- Fill band colors: `<30` → `--nope`; `30–70` → `--maybe`; `>70` → `--like`. Track: `rgba(255,255,255,.12)`, radius 4px. Fill animates `.25s ease` on update.
- The List view NEVER shows raw numbers.
### 10.5 Dossier — "Why this rank"
- New block after chips, before "The machine":
- Gauge + "{points}/100" (17px/700).
- Up to 4 breakdown lines, strongest first, muted: "· {feature} {±n}"; agent contributions render as "· agent: {why} {±n}".
- Footer: "from {N} strikes · read {relative}" (N = events count; time = profile version time).
- This is the only numeric display in the app (accepted — it exists for audit).
### 10.6 Trends tab (real)
Top→bottom:
1. **Your read** — lead line (18px/700) + "updated {rel}" muted; cold start copy: "Swipe ~10 more and the read sharpens."
2. **Leanings** — up to 8 bullets (14px).
3. **Stats** — three mini blocks: "Hit rate" ({likes}/{decided}) · "Returns re-struck" ({n}) · "Wildcards hit" ({n}).
4. **Since last read** — one muted line (hidden when null).
5. **Actions** — "Run agent now" (POST /api/agent/run; inline status) · forge-mode toggle · muted "Last read {time} · {model}".
- No LLM call at view time; the tab renders the stored profile only.
### 10.7 Wiring deltas
- Deck buffer source becomes `GET /api/section` (pinned order). All P2 wiring (optimistic swipe + retry queue, badge, SW) unchanged; bump the `sw.js` cache version.
- Card payload additions the FE reads: `slot` ('new'|'return'|'wildcard'), `points`, `tank` {value, band}, `round_next` (1|2, for the SECOND LOOK band copy when needed).
## 11. Photo experience v2 (B1) — galleries, photo flip, fullscreen viewer
**Frozen 2026-11-10 (Glenn approved: gesture map + repeat rotation picked by him in chat).** Additive; §1–§10 remain in force except where explicitly superseded here (§2's "Tap … → dossier" and §3's "(or tap)" are retired — §11.4).
### 11.1 Galleries
- Every listing carries its full photo gallery: `imgs[]` (page order, deduped by image id; entry 0 = the established primary = today's `img`/`img_url`).
- Every gallery image is mirrored locally (`data/img/{id}_{n}.webp`) and served by the app (`GET /img/{id}?i={n}`, `i` default 0; falls back into the §7.6 chain). The client never hotlinks directly.
- Zero photos → §2 placeholder; one photo → single photo, no segments, no flip.
### 11.2 Photo flip (deck card — top card only)
- Tap LEFT third → previous photo; tap RIGHT third → next photo. **Clamp at the ends** (no wrap). Tap test = §2's (≤6px movement on pointerup); a tap inside the ⓘ button hits ⓘ, not the zones.
- Segment indicator bars: one segment per photo along the top edge of the photo, centered, ~3px tall; current = solid white, others `rgba(255,255,255,.35)`; Tinder-style. (§10.3 amendment: when segments are present, band chips render at `top:22px`; no other change to §10.3.)
- Flip is instant, commits nothing, locks nothing (§2 THE law). A drag that starts anywhere still drags for LIKE/NOPE/MAYBE — the zones apply to taps only.
- Photo position is per-card-session state; a card re-shown fresh returns to §11.5's lead photo.
### 11.3 Fullscreen viewer
- Tap the CENTER third → fullscreen viewer opens on the photo currently shown.
- Viewer: horizontal swipe = prev/next (clamp); pinch-zoom + double-tap = zoom (1×–~4×); segment bars stay visible; close via ✕ (36px, top-left) or pull-down (grabber or photo, `dy > 110` — the §3 idiom). Closing returns to the same card, same photo position; deck state untouched.
- Also opens from the dossier hero (starts at photo 0). Same sheet geometry as the dossier (z-band 69/70), photo-first: no body, no actions row.
### 11.4 Doors (supersedes the old tap→dossier)
- ⓘ (top-right of card) → dossier (unchanged). The card face now owns: **sides = flip, center = fullscreen**; the "tap anywhere → dossier" gesture is RETIRED.
- The dossier stays reachable from ⓘ and from list rows (§3/§4 unchanged).
### 11.5 Repeat rotation
- A listing presented again (second look / re-show) opens on a different photo than the last one viewed: lead = `(last_photo_idx + 1) % n`; no stored index → photo 0.
- The index is persisted server-side at swipe-commit time (survives sessions and devices; works with §7.2's optimistic retry queue).
- List thumbnails and the dossier hero keep photo 0 (the canonical shot) — rotation is deck-only.
### 11.6 Performance & invariants
- Preload the top card's next photo and the next card's lead photo; decode off the critical path; **no network on the swipe path** (all mirrored locally).
- Stack metrics, `.topcard`, z-order law, stamps, undo, no-lock — all §2 laws untouched. Bands and segments coexist per §11.2.
### 11.7 Acceptance (B1)
Machine-checkable:
- Gallery capture ≥1 photo for every listing with a page image; multi-photo listings show N segments = N photos; the mirror holds all gallery files; `/img/{id}?i=n` answers for every n.
- Flip: left/right taps advance and clamp; a tap never commits (no stamp flash, no flight); drag-commit regression passes (fast-chain, no-lock, one `.topcard`).
- Viewer: opens from center tap and dossier hero; swipe/zoom/close work; close restores the card and photo position.
- Rotation: a re-shown card leads at `last+1`; persisted across page reload (server-side).
- Fallbacks: 0-photo placeholder, 1-photo (no segments), broken file → §7.6 chain.
- All §9/§10 acceptance re-run green.
Human: Glenn flips photos on his phone, opens fullscreen, and sees a fresh photo on a repeat — the B1 exit criterion.

View File

@@ -2,3 +2,4 @@
fastapi
uvicorn
httpx
playwright==1.54.0 # B1 dev/test only: headless FE walk (app/fe_walk_b1.py)