"""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())