- 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)
501 lines
20 KiB
Python
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()) |