P3: personalization core — event log + taste engine + section engine + agent pass + API + frontend

- db.py: guarded P3 tables (swipe_events/sections/section_items/taste_profile/
  agent_runs), one-time event seeding from swipes, append_event round math
- taste.py: deterministic taste engine (action x round x recency weights,
  n/(n+3) shrink, ramp w=min(.85,N/60), percentile norm, agent adjustments
  +/-10, points + source-labeled breakdown + tank bands)
- sections.py: compose/read/exhaust/forge — 2 wildcards + <=5 returns
  (<=2 nopes, surprising only) + remainder new, <=4/family, >=3 price
  bands w/ repair, returns maybe>like>nope, last-two-section exclusion,
  ondemand/strict modes
- agentpass.py: mock (deterministic) / live (OpenRouter, one retry) /
  fallback; strict payload schema; --print-summary/--dry-run
- api.py: GET /api/section, POST /api/section/forge, GET /api/trends,
  POST /api/agent/run, POST /api/prefs; P3 payload fields (points, tank,
  slot, round_next, points_breakdown); events stamped with section_id
- frontend: section buffer + progress pill, report screen + forge + mode
  toggle, WILDCARD/SECOND LOOK bands, tank gauges (list + dossier), dossier
  Why-this-rank, real Trends tab, /api/deck fallback for pre-P3 API, sw v2
- selfcheck: 9 new P3 checks (25/25 green)
This commit is contained in:
Torres
2026-11-10 23:16:32 -04:00
parent 8060852b83
commit 04463cadcd
8 changed files with 2504 additions and 26 deletions

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

@@ -13,14 +13,17 @@ on; frozen in the task brief):
family, score, year, seller_type, extras (array of strings),
decision (null|'like'|'maybe'|'nope'), first_seen}
"""
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')
@@ -73,6 +76,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 (?, ?) "
@@ -149,6 +160,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 +194,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 +254,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 +262,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 +286,10 @@ 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
conn = get_conn(app)
try:
row = conn.execute('SELECT id FROM listings WHERE id=?',
@@ -269,11 +303,17 @@ def create_app(db_path, img_dir, state_dir=None):
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)
result = dict(stored)
result['round'] = round_
result['section_id'] = section_id
return result
finally:
conn.close()
@@ -307,6 +347,7 @@ 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'
@@ -316,7 +357,8 @@ def create_app(db_path, img_dir, state_dir=None):
' 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 +366,14 @@ 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'
' 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,6 +400,228 @@ 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}')

105
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,50 @@ 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')
# 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)

488
app/sections.py Normal file
View File

@@ -0,0 +1,488 @@
"""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, 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

@@ -309,6 +309,8 @@ TESTS = [
PAYLOAD_KEYS = {'id', 'title', 'price_display', 'price_num', 'km', 'location',
'region', 'url', 'img', 'desc', 'family', 'score', 'year',
'seller_type', 'extras', 'decision', 'first_seen'}
# 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')
@@ -358,13 +360,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 +407,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 +528,411 @@ 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']
conn.close()
conn = db.connect(db_path)
db.init_db(conn)
n1 = conn.execute(
'SELECT COUNT(*) AS n FROM swipe_events').fetchone()['n']
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']
# 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
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 ORDER BY version')]
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)
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
API_TESTS = [
('api: / serves the app + static + manifest/sw', test_api_root),
('api: stats live == active count, health counts', test_api_stats_health),

View File

@@ -133,17 +133,80 @@ 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}
</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 +232,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>
@@ -203,7 +274,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 +310,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,10 +332,10 @@ 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, 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){ // act: 'like'|'nope'|'maybe'|null (delete)
pend.set(listingId,{act,secId,fails:0});
scheduleQueue();
}
function queDelay(op){ return op.fails===1?2000:op.fails===2?5000:op.fails===3?10000:15000; }
@@ -258,7 +355,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(p.secId?{listing_id:id,action:p.act,section_id:p.secId}:{listing_id:id,action:p.act})});
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){
@@ -270,9 +367,9 @@ async function runQueue(){
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){
function setDecision(id, act, secId=null){
updateRowDecision(id, act); // optimistic paint
enqueue(id, act);
enqueue(id, act, secId);
}
/* ---------- payload -> display helpers ---------- */
@@ -305,6 +402,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 +445,20 @@ 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 '';
}
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>`}
<div class="grad"></div>
${bandHTML(item)}
<div class="stamp like">LIKE</div>
<div class="stamp nope">NOPE</div>
<div class="stamp maybe">MAYBE</div>
@@ -376,8 +507,8 @@ function fillPile(){
}
relayer();
}
/* 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 +521,48 @@ 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)]);
const fresh=(body.items||[]).filter(it=>it.decision===null&&!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&&(section.exhausted||(section.progress&&section.progress.undecided===0))){
$('report').classList.add('on');
$('empty').classList.remove('on');
}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 +570,10 @@ 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);
sectDecided++;
paintSect();
flyOut(el,dir);
fillPile(); relayer();
maybeRefill();
@@ -422,6 +594,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)';
@@ -432,6 +606,7 @@ function undo(){
void entry.el.offsetWidth;
relayer();
$('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'); };
@@ -493,9 +668,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());
@@ -547,7 +748,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,15 +901,122 @@ 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){
sectDecided=body.progress.decided; sectTotal=body.progress.total;
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 fresh=(body.items||[]).filter(it=>it.decision===null);
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) ---------- */
@@ -722,7 +1030,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-v2';
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