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

501 lines
20 KiB
Python

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