diff --git a/app/agentpass.py b/app/agentpass.py new file mode 100644 index 0000000..78021c1 --- /dev/null +++ b/app/agentpass.py @@ -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": "", "leanings": [""], ' + '"changes": [""], ' + '"chips": [""], ' + '"adjustments": [{"feature": "<'+ '|'.join(FEATURE_DIMS) +'>", ' + '"value": "", "delta": , ' + '"why": ""}], ' + '"wildcards": {"families": [""], ' + '"price_bands": [""]}}' + "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()) \ No newline at end of file diff --git a/app/api.py b/app/api.py index 64a54ce..2aa9f47 100644 --- a/app/api.py +++ b/app/api.py @@ -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}') diff --git a/app/db.py b/app/db.py index 899c86c..76fc2c0 100644 --- a/app/db.py +++ b/app/db.py @@ -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) \ No newline at end of file diff --git a/app/sections.py b/app/sections.py new file mode 100644 index 0000000..73b6fa2 --- /dev/null +++ b/app/sections.py @@ -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', + } \ No newline at end of file diff --git a/app/selfcheck.py b/app/selfcheck.py index 0e1ac11..f575247 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -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), diff --git a/app/static/index.html b/app/static/index.html index 0efa349..7de7785 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -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}
Bike-Tinder
-
—
+
+
—
+
+
You're all caught up.New bikes arrive nightly — next import 08:30.
↺ brings the last card back.
+
+ Section complete. +
+
The file is digesting your strikes.
+
+ +
+
+ on demand⇄strict + +
+
@@ -169,6 +232,14 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
+
The machine
Seller's notes
@@ -203,7 +274,30 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
No likes yet — swipe right on the deck.
-
Trends — Phase 3The deck learns your taste as you swipe.
+
@@ -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 ``; +} 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 `
WILDCARD · maybe you’ll like this one instead
`; + if(item.slot==='return'){ + const verb= item.decision==='like'?'liked':item.decision==='maybe'?'maybed':item.decision==='nope'?'noped':'seen'; + return `
SECOND LOOK — you ${verb} this
`; + } + return ''; +} function cardNode(item){ const el=document.createElement('div'); el.className='card'; el.innerHTML=`${item.img?``:`
Photo pending — thumbnail backfill queued
`}
+ ${bandHTML(item)}
NOPE
MAYBE
@@ -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{ 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=>`
${r[0]}${r[1]||'—'}
`).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 `
· ${lbl}${n}
`; + }).join(''); + const ri= section&§ion.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){
${thumbFor(it)}
-
+
${it.tank?tankSVG(it.tank):''}
@@ -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=>`
· ${x}
`).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')){ diff --git a/app/static/sw.js b/app/static/sw.js index 2241f98..3c26a6c 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -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', diff --git a/app/taste.py b/app/taste.py new file mode 100644 index 0000000..2bf665d --- /dev/null +++ b/app/taste.py @@ -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 \ No newline at end of file