P3: personalization core — event log + taste engine + section engine + agent pass + API + frontend
- db.py: guarded P3 tables (swipe_events/sections/section_items/taste_profile/ agent_runs), one-time event seeding from swipes, append_event round math - taste.py: deterministic taste engine (action x round x recency weights, n/(n+3) shrink, ramp w=min(.85,N/60), percentile norm, agent adjustments +/-10, points + source-labeled breakdown + tank bands) - sections.py: compose/read/exhaust/forge — 2 wildcards + <=5 returns (<=2 nopes, surprising only) + remainder new, <=4/family, >=3 price bands w/ repair, returns maybe>like>nope, last-two-section exclusion, ondemand/strict modes - agentpass.py: mock (deterministic) / live (OpenRouter, one retry) / fallback; strict payload schema; --print-summary/--dry-run - api.py: GET /api/section, POST /api/section/forge, GET /api/trends, POST /api/agent/run, POST /api/prefs; P3 payload fields (points, tank, slot, round_next, points_breakdown); events stamped with section_id - frontend: section buffer + progress pill, report screen + forge + mode toggle, WILDCARD/SECOND LOOK bands, tank gauges (list + dossier), dossier Why-this-rank, real Trends tab, /api/deck fallback for pre-P3 API, sw v2 - selfcheck: 9 new P3 checks (25/25 green)
This commit is contained in:
501
app/agentpass.py
Normal file
501
app/agentpass.py
Normal file
@@ -0,0 +1,501 @@
|
||||
"""Bike-Tinder P3 agent pass — the ONLY LLM step (docs/DESIGN.md).
|
||||
|
||||
Reads a compact, deterministic strike summary and writes ONE taste_profile
|
||||
row (payload schema {lead, leanings, changes, chips, adjustments,
|
||||
wildcards}) plus ONE agent_runs observability row. Never part of any
|
||||
request path; the deck never waits on it.
|
||||
|
||||
Sources:
|
||||
mock — deterministic payload synthesis from the strike data; zero
|
||||
network; used by tests and offline forge.
|
||||
live — OpenRouter chat completion (requests; env OPENROUTER_API_KEY,
|
||||
BT_AGENT_MODEL; timeout + one retry; malformed -> fallback).
|
||||
The key is never logged or printed.
|
||||
fallback — deterministic synthesis again (source-labeled 'fallback').
|
||||
|
||||
CLI:
|
||||
.venv/bin/python -m app.agentpass --db PATH [--mock|--live] [--model M]
|
||||
[--print-summary] [--dry-run]
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import db
|
||||
from . import sections
|
||||
from . import taste as tastemod
|
||||
|
||||
DEFAULT_MODEL = 'openrouter/auto'
|
||||
API_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
TIMEOUT = 45
|
||||
|
||||
FEATURE_DIMS = tastemod.FEATURE_DIMS
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are the taste-profile writer for a single-user motorcycle-swipe "
|
||||
"app. Reply with ONLY strict JSON, no markdown, matching exactly:\n"
|
||||
'{"lead": "<one line>", "leanings": ["<up to 8 bullets>"], '
|
||||
'"changes": ["<since last read, may be empty>"], '
|
||||
'"chips": ["<up to 6 short explanations>"], '
|
||||
'"adjustments": [{"feature": "<'+ '|'.join(FEATURE_DIMS) +'>", '
|
||||
'"value": "<feature value>", "delta": <number -3..3>, '
|
||||
'"why": "<short reason>"}], '
|
||||
'"wildcards": {"families": ["<family label>"], '
|
||||
'"price_bands": ["<band>"]}}'
|
||||
"Adjustments nudge listing scores; keep them small and few. "
|
||||
"Never invent feature values that are not in the data."
|
||||
)
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
def _now_dt():
|
||||
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# --- strike summary (deterministic: a pure function of DB rows) --------------
|
||||
|
||||
def build_summary(conn, now=None):
|
||||
"""Compact text summary of the strikes — exactly what the live pass
|
||||
is sent. Deterministic for identical DB content (no wall clock)."""
|
||||
now = now or _now_dt()
|
||||
engine = sections.build_engine(conn, now=now)
|
||||
lines = []
|
||||
counts = {r['action']: r['n'] for r in conn.execute(
|
||||
'SELECT action, COUNT(*) AS n FROM swipes GROUP BY action')}
|
||||
decided = sum(counts.values())
|
||||
lines.append('decided=%d like=%d maybe=%d nope=%d events=%d' % (
|
||||
decided, counts.get('like', 0), counts.get('maybe', 0),
|
||||
counts.get('nope', 0), engine.n_events))
|
||||
|
||||
prof = sections.latest_profile(conn)
|
||||
if prof:
|
||||
lines.append('profile_version=%d source=%s' % (prof['version'],
|
||||
prof['source']))
|
||||
|
||||
# families: action counts + net weight
|
||||
fam_rows = {}
|
||||
for ev in engine.events:
|
||||
row = engine.by_id.get(ev['listing_id'])
|
||||
fam = row['family'] if row else None
|
||||
if fam:
|
||||
rec = fam_rows.setdefault(fam, {'like': 0, 'maybe': 0, 'nope': 0,
|
||||
'net': 0.0})
|
||||
rec[ev['action']] = rec.get(ev['action'], 0) + 1
|
||||
rec['net'] += tastemod.event_weight(ev['action'], ev['round'],
|
||||
ev['created_at'], now)
|
||||
lines.append('families:')
|
||||
if fam_rows:
|
||||
for fam, rec in sorted(fam_rows.items(),
|
||||
key=lambda kv: -abs(kv[1]['net']))[:10]:
|
||||
lines.append('- %s | like=%d maybe=%d nope=%d | net=%.2f' % (
|
||||
tasname(fam), rec['like'], rec['maybe'], rec['nope'],
|
||||
rec['net']))
|
||||
else:
|
||||
lines.append('- (none matched yet)')
|
||||
|
||||
# feature cells (non-family) with verdicts
|
||||
lines.append('signals:')
|
||||
cells = {}
|
||||
for ev in engine.events:
|
||||
for dim, val in engine.cells.get(ev['listing_id'], []):
|
||||
if dim == 'family':
|
||||
continue
|
||||
rec = cells.setdefault((dim, val), {'n': 0, 'w': 0.0})
|
||||
rec['n'] += 1
|
||||
rec['w'] += tastemod.event_weight(ev['action'], ev['round'],
|
||||
ev['created_at'], now)
|
||||
for (dim, val), rec in sorted(cells.items(),
|
||||
key=lambda kv: -abs(kv[1]['w']))[:10]:
|
||||
wtxt = '%.2f' % rec['w']
|
||||
lines.append('- %s %s | n=%d | w=%s' % (dim, val, rec['n'], wtxt))
|
||||
|
||||
# recent strikes (chronological tail)
|
||||
recent = conn.execute(
|
||||
"""SELECT e.action, e.round, e.created_at, l.title, l.family,
|
||||
e.section_id
|
||||
FROM swipe_events e JOIN listings l ON l.id = e.listing_id
|
||||
ORDER BY e.id DESC LIMIT 10""").fetchall()
|
||||
lines.append('recent:')
|
||||
for r in reversed(recent):
|
||||
lines.append('- %s r%d %s | %s%s' % (
|
||||
r['action'], min(r['round'] or 1, 2), r['created_at'],
|
||||
(r['title'] or '')[:60], (' | %s' % tasname(r['family']))
|
||||
if r['family'] else ''))
|
||||
|
||||
# liked exemplars
|
||||
liked = conn.execute(
|
||||
"""SELECT l.title, l.family, l.price_num, l.year
|
||||
FROM swipes s JOIN listings l ON l.id = s.listing_id
|
||||
WHERE s.action='like' ORDER BY s.updated_at DESC LIMIT 5"""
|
||||
).fetchall()
|
||||
lines.append('liked:' if liked else 'liked: (none yet)')
|
||||
for r in liked:
|
||||
lines.append('- %s | %s | %s | %s' % (
|
||||
(r['title'] or '')[:60], tasname(r['family']), r['price_num'],
|
||||
r['year']))
|
||||
|
||||
# returns / wildcard engagement so far
|
||||
restruck = conn.execute(
|
||||
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
|
||||
JOIN section_items si
|
||||
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
|
||||
WHERE si.slot='return'""").fetchone()['n']
|
||||
wild_hits = conn.execute(
|
||||
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
|
||||
JOIN section_items si
|
||||
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
|
||||
WHERE si.slot='wildcard' AND e.action IN ('like','maybe')"""
|
||||
).fetchone()['n']
|
||||
lines.append('second_looks_re_struck=%d wildcards_hit=%d' % (restruck,
|
||||
wild_hits))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def tasname(label):
|
||||
"""Short family name for the summary (trim trailing rationale)."""
|
||||
return (label or '').split(' — ')[0].strip()
|
||||
|
||||
|
||||
# --- deterministic payload synthesis (mock + fallback share this) ------------
|
||||
|
||||
def synthesize_payload(conn, now=None):
|
||||
"""Deterministic {lead, leanings, changes, chips, adjustments,
|
||||
wildcards} derived purely from the strike data. No randomness, no
|
||||
network."""
|
||||
now = now or _now_dt()
|
||||
engine = sections.build_engine(conn, now=now)
|
||||
counts = {r['action']: r['n'] for r in conn.execute(
|
||||
'SELECT action, COUNT(*) AS n FROM swipes GROUP BY action')}
|
||||
decided = sum(counts.values())
|
||||
likes = counts.get('like', 0)
|
||||
|
||||
cells = {} # (dim, val) -> {n, w}
|
||||
for ev in engine.events:
|
||||
for dim, val in engine.cells.get(ev['listing_id'], []):
|
||||
rec = cells.setdefault((dim, val), {'n': 0, 'w': 0.0})
|
||||
rec['n'] += 1
|
||||
rec['w'] += tastemod.event_weight(ev['action'], ev['round'],
|
||||
ev['created_at'], now)
|
||||
ranked = sorted(cells.items(), key=lambda kv: -abs(kv[1]['w']))
|
||||
pos = [(d, v, r) for (d, v), r in ranked if r['w'] > 0][:4]
|
||||
neg = [(d, v, r) for (d, v), r in ranked if r['w'] < 0][:4]
|
||||
|
||||
def lab(dim, val):
|
||||
return tasname(val) if dim == 'family' else ('%s %s' % (dim, val))
|
||||
|
||||
# lead
|
||||
if not engine.events:
|
||||
lead = 'Cold start — rubric order until the first strikes land.'
|
||||
elif likes and pos:
|
||||
d, v, r = pos[0]
|
||||
lead = ('You favor %s — %d of your likes cluster there.'
|
||||
% (lab(d, v), r['n']))
|
||||
elif pos:
|
||||
d, v, r = pos[0]
|
||||
lead = ('Leaning toward %s (%d strike%s).'
|
||||
% (lab(d, v), r['n'], 's' if r['n'] != 1 else ''))
|
||||
else:
|
||||
lead = 'No strong signal yet — keep swiping.'
|
||||
|
||||
# leanings (<=8)
|
||||
leanings = []
|
||||
for d, v, r in pos:
|
||||
leanings.append('%s pulls you in (+%.1f over %d strike%s)' % (
|
||||
lab(d, v), r['w'], r['n'], 's' if r['n'] != 1 else ''))
|
||||
for d, v, r in neg:
|
||||
leanings.append('%s reads as a miss (%+.1f over %d strike%s)' % (
|
||||
lab(d, v), r['w'], r['n'], 's' if r['n'] != 1 else ''))
|
||||
leanings = leanings[:8]
|
||||
if not leanings and engine.events:
|
||||
leanings = ['No feature clusters yet — the ramp keeps the rubric '
|
||||
'in charge.']
|
||||
|
||||
# changes (since the previous profile version)
|
||||
prof = sections.latest_profile(conn)
|
||||
if prof is None:
|
||||
changes = ['first read — %d strikes so far' % engine.n_events]
|
||||
else:
|
||||
try:
|
||||
prev_dt = tastemod.parse_ts(prof['created_at'], now)
|
||||
except Exception: # noqa: BLE001
|
||||
prev_dt = None
|
||||
if prev_dt is None:
|
||||
changes = ['unable to diff against the previous read']
|
||||
else:
|
||||
new_n = sum(1 for ev in engine.events
|
||||
if (tastemod.parse_ts(ev['created_at'], now) or now)
|
||||
> prev_dt)
|
||||
if new_n:
|
||||
deltas = []
|
||||
for (d, v), r in ranked[:3]:
|
||||
sub = 0.0
|
||||
for ev in engine.events:
|
||||
dt = tastemod.parse_ts(ev['created_at'], now)
|
||||
if dt is not None and dt > prev_dt and (
|
||||
d, v) in engine.cells.get(
|
||||
ev['listing_id'], []):
|
||||
sub += tastemod.event_weight(
|
||||
ev['action'], ev['round'],
|
||||
ev['created_at'], now)
|
||||
if abs(sub) >= 0.05:
|
||||
deltas.append('%s %+.1f' % (lab(d, v), sub))
|
||||
head = '%d new strike%s since the last read' % (
|
||||
new_n, 's' if new_n != 1 else '')
|
||||
changes = [head + ('; ' + ', '.join(deltas)
|
||||
if deltas else '')]
|
||||
else:
|
||||
changes = ['no new strikes since the last read']
|
||||
|
||||
# chips (up to 6 short explanations)
|
||||
chips = [lab(d, v) for d, v, r in pos[:6]]
|
||||
if not chips:
|
||||
chips = ['rubric fit']
|
||||
|
||||
# adjustments (small deterministic nudges on the top cells)
|
||||
adjustments = []
|
||||
for (d, v), r in ranked[:8]:
|
||||
if abs(r['w']) < 0.5:
|
||||
continue
|
||||
delta = max(-3.0, min(3.0, round(r['w'] / 2.0, 1)))
|
||||
adjustments.append({
|
||||
'feature': d, 'value': v, 'delta': delta,
|
||||
'why': 'you %s %s %d time%s' % (
|
||||
'favored' if r['w'] > 0 else 'passed on', lab(d, v), r['n'],
|
||||
's' if r['n'] != 1 else ''),
|
||||
})
|
||||
|
||||
# wildcard hints: families without hard nopes, off the beaten likes
|
||||
liked_fams = {ev['listing_id'] for ev in engine.events
|
||||
if ev['action'] == 'like'}
|
||||
fam_nopes_w = {}
|
||||
for ev in engine.events:
|
||||
row = engine.by_id.get(ev['listing_id'])
|
||||
if not row or not row['family']:
|
||||
continue
|
||||
rec = fam_nopes_w.setdefault(row['family'], {'nopes': 0, 'w': 0.0})
|
||||
if ev['action'] == 'nope':
|
||||
rec['nopes'] += 1
|
||||
rec['w'] += tastemod.event_weight(ev['action'], ev['round'],
|
||||
ev['created_at'], now)
|
||||
fam_stretch = [f for f, r in fam_nopes_w.items()
|
||||
if r['nopes'] <= 1 and r['w'] >= 0]
|
||||
fam_stretch = sorted(fam_stretch, key=lambda f: -fam_nopes_w[f]['w'])[:3]
|
||||
band_rows = [(tastemod.price_band(row['price_num']), ev['action'])
|
||||
for ev in engine.events
|
||||
if (row := engine.by_id.get(ev['listing_id'])) is not None]
|
||||
band_counts = {}
|
||||
for b, a in band_rows:
|
||||
if b:
|
||||
rec = band_counts.setdefault(b, {'nopes': 0, 'n': 0})
|
||||
rec['n'] += 1
|
||||
rec['nopes'] += 1 if a == 'nope' else 0
|
||||
band_stretch = sorted(
|
||||
(b for b, r in band_counts.items() if r['nopes'] == 0),
|
||||
key=lambda b: band_counts[b]['n'])[:3]
|
||||
|
||||
wildcards = {'families': fam_stretch, 'price_bands': band_stretch}
|
||||
|
||||
return {'lead': lead, 'leanings': leanings, 'changes': changes,
|
||||
'chips': chips, 'adjustments': adjustments,
|
||||
'wildcards': wildcards}
|
||||
|
||||
|
||||
# --- validation --------------------------------------------------------------
|
||||
|
||||
def validate_payload(payload):
|
||||
"""Raise ValueError on schema violations."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError('payload must be an object')
|
||||
for key in ('lead', 'leanings', 'changes', 'chips', 'adjustments',
|
||||
'wildcards'):
|
||||
if key not in payload:
|
||||
raise ValueError('missing key: %s' % key)
|
||||
if not isinstance(payload['lead'], str):
|
||||
raise ValueError('lead must be a string')
|
||||
for key in ('leanings', 'changes', 'chips'):
|
||||
if (not isinstance(payload[key], list)
|
||||
or not all(isinstance(x, str) for x in payload[key])):
|
||||
raise ValueError('%s must be a list of strings' % key)
|
||||
for adj in payload['adjustments']:
|
||||
if not isinstance(adj, dict):
|
||||
raise ValueError('adjustment must be an object')
|
||||
if adj.get('feature') not in FEATURE_DIMS:
|
||||
raise ValueError('bad adjustment feature: %r' % adj.get('feature'))
|
||||
if not isinstance(adj.get('delta'), (int, float)):
|
||||
raise ValueError('adjustment delta must be numeric')
|
||||
wc = payload['wildcards']
|
||||
if not isinstance(wc, dict):
|
||||
raise ValueError('wildcards must be an object')
|
||||
if (not isinstance(wc.get('families', []), list)
|
||||
or not isinstance(wc.get('price_bands', []), list)):
|
||||
raise ValueError('wildcard hints must be lists')
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_json(text):
|
||||
"""Pull the first JSON object out of a model reply (fence-tolerant)."""
|
||||
text = (text or '').strip()
|
||||
if text.startswith('```'):
|
||||
lines = text.splitlines()
|
||||
while lines and lines[0].startswith('```'):
|
||||
lines = lines[1:]
|
||||
while lines and lines[-1].startswith('```'):
|
||||
lines = lines[:-1]
|
||||
text = '\n'.join(lines).strip()
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError('no JSON object in reply')
|
||||
return json.loads(text[start:end + 1])
|
||||
|
||||
|
||||
# --- the pass -----------------------------------------------------------------
|
||||
|
||||
def run_pass(db_path, source='mock', model=None, dry_run=False,
|
||||
print_summary=False, out=sys.stdout):
|
||||
"""Run one agent pass. Returns a result dict; never raises for live
|
||||
failures (falls back to a deterministic profile, source='fallback')."""
|
||||
started = now_iso()
|
||||
conn = db.connect(db_path)
|
||||
db.init_db(conn)
|
||||
try:
|
||||
now = _now_dt()
|
||||
summary = build_summary(conn, now=now)
|
||||
if print_summary:
|
||||
out.write(summary + '\n')
|
||||
model = model or os.environ.get('BT_AGENT_MODEL') or DEFAULT_MODEL
|
||||
if source == 'mock':
|
||||
payload = synthesize_payload(conn, now=now)
|
||||
profile_source = 'mock'
|
||||
error = None
|
||||
cost_note = None
|
||||
else: # live
|
||||
key = os.environ.get('OPENROUTER_API_KEY')
|
||||
if not key:
|
||||
payload = synthesize_payload(conn, now=now)
|
||||
profile_source = 'fallback'
|
||||
error = 'OPENROUTER_API_KEY not configured'
|
||||
cost_note = None
|
||||
else:
|
||||
payload, profile_source, error, cost_note = _live_call(
|
||||
model, summary)
|
||||
if payload is None:
|
||||
payload = synthesize_payload(conn, now=now)
|
||||
profile_source = 'fallback'
|
||||
validate_payload(payload)
|
||||
|
||||
if dry_run:
|
||||
return {'ok': True, 'dry_run': True, 'source': profile_source,
|
||||
'model': model, 'payload': payload, 'summary': summary,
|
||||
'error': error}
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO taste_profile (created_at, source, payload)
|
||||
VALUES (?, ?, ?)""",
|
||||
(started, profile_source, json.dumps(payload)))
|
||||
version = cur.lastrowid
|
||||
finished = now_iso()
|
||||
conn.execute(
|
||||
"""INSERT INTO agent_runs (started_at, finished_at, ok, source,
|
||||
model, cost_note, error,
|
||||
profile_version)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(started, finished, 1 if error is None else 0, profile_source,
|
||||
model, cost_note, (error or '')[:500], version))
|
||||
conn.commit()
|
||||
return {'ok': error is None, 'source': profile_source,
|
||||
'profile_version': version, 'model': model,
|
||||
'payload': payload, 'error': error, 'cost_note': cost_note}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _live_call(model, summary):
|
||||
"""OpenRouter call with one retry; returns (payload|None, source,
|
||||
error, cost_note). Never leaks the key into error text or logs."""
|
||||
import requests
|
||||
key = os.environ['OPENROUTER_API_KEY']
|
||||
body = {
|
||||
'model': model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': SYSTEM_PROMPT},
|
||||
{'role': 'user', 'content': summary},
|
||||
],
|
||||
'temperature': 0,
|
||||
}
|
||||
headers = {'Authorization': 'Bearer %s' % key,
|
||||
'Content-Type': 'application/json'}
|
||||
last_err = None
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
r = requests.post(API_URL, headers=headers, json=body,
|
||||
timeout=TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
text = (data.get('choices') or [{}])[0].get(
|
||||
'message', {}).get('content', '')
|
||||
try:
|
||||
payload = validate_payload(_extract_json(text))
|
||||
cost_note = _cost_note(data)
|
||||
return payload, 'agent', None, cost_note
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
return None, None, 'malformed reply: %s' % str(e)[:300], None
|
||||
last_err = 'HTTP %d: %s' % (r.status_code, r.text[:200])
|
||||
except requests.exceptions.RequestException as e:
|
||||
last_err = 'network error: %s' % type(e).__name__
|
||||
return None, None, (last_err or 'live call failed')[:500], None
|
||||
|
||||
|
||||
def _cost_note(data):
|
||||
usage = data.get('usage') or {}
|
||||
cost = usage.get('cost')
|
||||
note = 'tokens_in=%s tokens_out=%s' % (
|
||||
usage.get('prompt_tokens'), usage.get('completion_tokens'))
|
||||
if cost is not None:
|
||||
note += ' cost=$%s' % cost
|
||||
return note
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description='Bike-Tinder P3 agent pass')
|
||||
ap.add_argument('--db', default='data/bike-tinder.sqlite3')
|
||||
src = ap.add_mutually_exclusive_group()
|
||||
src.add_argument('--mock', action='store_true',
|
||||
help='deterministic offline pass (default)')
|
||||
src.add_argument('--live', action='store_true',
|
||||
help='OpenRouter pass (needs OPENROUTER_API_KEY)')
|
||||
ap.add_argument('--model', default=None,
|
||||
help='model override (env BT_AGENT_MODEL, else default)')
|
||||
ap.add_argument('--print-summary', action='store_true',
|
||||
help='print exactly the text the live pass would send')
|
||||
ap.add_argument('--dry-run', action='store_true',
|
||||
help='validate + print, write nothing')
|
||||
args = ap.parse_args(argv)
|
||||
result = run_pass(args.db, source='live' if args.live else 'mock',
|
||||
model=args.model, dry_run=args.dry_run,
|
||||
print_summary=args.print_summary)
|
||||
if args.dry_run:
|
||||
print(json.dumps(result['payload'], indent=2))
|
||||
print('dry-run ok; would write source=%s (model=%s)' % (
|
||||
result['source'], result['model']))
|
||||
if result.get('error'):
|
||||
print('note: %s' % result['error'])
|
||||
return 0
|
||||
if result['ok']:
|
||||
print('pass ok: source=%s version=%d model=%s%s' % (
|
||||
result['source'], result['profile_version'], result['model'],
|
||||
' (%s)' % result['cost_note'] if result.get('cost_note') else ''))
|
||||
return 0
|
||||
print('pass fell back: source=fallback version=%d error=%s' % (
|
||||
result['profile_version'], result['error']))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
273
app/api.py
273
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}')
|
||||
|
||||
105
app/db.py
105
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)
|
||||
488
app/sections.py
Normal file
488
app/sections.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""Bike-Tinder P3 section engine — compose / read / exhaust / forge.
|
||||
|
||||
The deck is one finite SECTION at a time (default 25 cards):
|
||||
2 wildcards + <=5 returns (<=2 nopes, "surprising" only) + remainder new,
|
||||
per docs/RANKING.md + docs/DATA-MODEL.md "P3 additions".
|
||||
|
||||
Pinned order within a section (position 1 first):
|
||||
new (points DESC) -> returns (maybe > like > nope, points DESC within
|
||||
role) -> wildcards (wildcard score DESC). "The best suggestions lead"
|
||||
(docs/PLAN.md); returns form their own sub-sequence in role order;
|
||||
wildcard stretches sit at the back. (Wildcard placement inside the deck
|
||||
is not pinned anywhere in the frozen docs — noted in the P3 report.)
|
||||
|
||||
Constraints: <=4 per non-NULL family per section; >=3 price bands when the
|
||||
pool allows (soft repair swaps new-slot items within the final list).
|
||||
A re-nope (round>=2 nope event) rests an item: no more second looks.
|
||||
Rounds cap at 2 for influence (taste.py); returns are offered once.
|
||||
|
||||
Modes: 'ondemand' (default) — forge runs the agent pass then composes;
|
||||
'strict' — forge refuses (409) unless a taste_profile strictly newer than
|
||||
the current section's profile_version exists.
|
||||
"""
|
||||
import json
|
||||
|
||||
from . import taste as tastemod
|
||||
from .taste import NOPE
|
||||
|
||||
FAMILY_CAP = 4
|
||||
MAX_RETURNS = 5
|
||||
MAX_RETURN_NOPES = 2
|
||||
WILDCARDS = 2
|
||||
DEFAULT_SIZE = 25
|
||||
MIN_BANDS = 3
|
||||
|
||||
LAST_TWO_SQL = """
|
||||
SELECT si.listing_id FROM section_items si
|
||||
WHERE si.section_id IN (SELECT id FROM sections ORDER BY id DESC LIMIT 2)
|
||||
"""
|
||||
|
||||
NEW_POOL_SQL = """
|
||||
SELECT l.* FROM listings l
|
||||
WHERE l.status = 'active'
|
||||
AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id)
|
||||
"""
|
||||
|
||||
RETURNS_SQL = """
|
||||
SELECT l.*, s.action AS verdict FROM listings l
|
||||
JOIN swipes s ON s.listing_id = l.id
|
||||
WHERE l.status = 'active'
|
||||
AND NOT EXISTS (SELECT 1 FROM swipe_events e
|
||||
WHERE e.listing_id = l.id AND e.round >= 2
|
||||
AND e.action = 'nope')
|
||||
AND NOT EXISTS (SELECT 1 FROM section_items si
|
||||
WHERE si.listing_id = l.id AND si.slot = 'return')
|
||||
"""
|
||||
|
||||
WILDCARD_SQL = """
|
||||
SELECT l.* FROM listings l
|
||||
WHERE l.status = 'active'
|
||||
AND NOT EXISTS (SELECT 1 FROM swipe_events e WHERE e.listing_id = l.id)
|
||||
"""
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
def _iso(now):
|
||||
return now.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
def last_two_listing_ids(conn):
|
||||
return {r['listing_id'] for r in conn.execute(LAST_TWO_SQL)}
|
||||
|
||||
|
||||
def get_current_section(conn):
|
||||
"""The newest open/report section, or None."""
|
||||
return conn.execute(
|
||||
"SELECT * FROM sections WHERE status IN ('open','report')"
|
||||
' ORDER BY id DESC LIMIT 1').fetchone()
|
||||
|
||||
|
||||
def latest_profile(conn):
|
||||
return conn.execute(
|
||||
'SELECT * FROM taste_profile ORDER BY version DESC LIMIT 1'
|
||||
).fetchone()
|
||||
|
||||
|
||||
def build_engine(conn, profile=None, now=None):
|
||||
"""TasteEngine over active listings + every listing an event touches,
|
||||
with the given profile payload (default: the latest version)."""
|
||||
rows = [r for r in conn.execute(
|
||||
"SELECT * FROM listings WHERE status='active'")]
|
||||
ev_ids = {r['listing_id'] for r in conn.execute(
|
||||
'SELECT DISTINCT listing_id FROM swipe_events')}
|
||||
missing = ev_ids - {r['id'] for r in rows}
|
||||
if missing:
|
||||
qmarks = ','.join('?' * len(missing))
|
||||
rows += [r for r in conn.execute(
|
||||
'SELECT * FROM listings WHERE id IN (%s)' % qmarks,
|
||||
tuple(missing))]
|
||||
events = conn.execute('SELECT * FROM swipe_events ORDER BY id').fetchall()
|
||||
payload = None
|
||||
if profile is None:
|
||||
profile = latest_profile(conn)
|
||||
if profile is not None and profile['payload']:
|
||||
try:
|
||||
payload = json.loads(profile['payload'])
|
||||
except ValueError:
|
||||
payload = None
|
||||
return tastemod.TasteEngine(rows, events, payload, now=now)
|
||||
|
||||
|
||||
def fresh_profile_exists(conn, section):
|
||||
"""Strict-mode freshness: a profile version newer than the section's."""
|
||||
cur = section['profile_version'] if section else None
|
||||
if latest_profile(conn) is None:
|
||||
return False
|
||||
if cur is None:
|
||||
return True
|
||||
return latest_profile(conn)['version'] > cur
|
||||
|
||||
|
||||
# --- wildcard novelty --------------------------------------------------------
|
||||
|
||||
def _cosine(a, b):
|
||||
dot = sum(a.get(k, 0.0) * b.get(k, 0.0) for k in (a.keys() | b.keys()))
|
||||
na = sum(x * x for x in a.values()) ** 0.5
|
||||
nb = sum(x * x for x in b.values()) ** 0.5
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def novelty(rows_by_id, liked_ids, candidate_ids):
|
||||
"""0..1 distance from the liked-profile centroid (1 = very novel)."""
|
||||
if not liked_ids:
|
||||
return {lid: 0.5 for lid in candidate_ids}
|
||||
centroid = {}
|
||||
liked = [l for l in liked_ids if l in rows_by_id]
|
||||
for lid in liked:
|
||||
for d, v in tastemod.extract_features(rows_by_id[lid]):
|
||||
centroid[(d, v)] = centroid.get((d, v), 0.0) + 1.0
|
||||
n = len(liked) or 1
|
||||
centroid = {k: v / n for k, v in centroid.items()}
|
||||
out = {}
|
||||
for lid in candidate_ids:
|
||||
vec = {(d, v): 1.0 for d, v in tastemod.extract_features(rows_by_id[lid])}
|
||||
out[lid] = 1.0 - _cosine(vec, centroid)
|
||||
return out
|
||||
|
||||
|
||||
# --- compose -----------------------------------------------------------------
|
||||
|
||||
def _points_key(engine, row):
|
||||
"""Deterministic sort key: points, rubric score, first_seen, id.
|
||||
Use with reverse=True for descending order."""
|
||||
return (engine.points(row['id']), row['score'] or 0,
|
||||
row['first_seen'] or '', row['id'])
|
||||
|
||||
|
||||
def pick_new(engine, candidates, want, fam_count, picked_ids):
|
||||
"""Greedy new picks by points DESC honoring the family cap."""
|
||||
out = []
|
||||
ordered = sorted(candidates, key=lambda r: _points_key(engine, r),
|
||||
reverse=True)
|
||||
for cand in ordered:
|
||||
if len(out) >= want:
|
||||
break
|
||||
lid = cand['id']
|
||||
if lid in picked_ids:
|
||||
continue
|
||||
fam = cand['family']
|
||||
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
|
||||
continue
|
||||
out.append(cand)
|
||||
picked_ids.add(lid)
|
||||
if fam:
|
||||
fam_count[fam] = fam_count.get(fam, 0) + 1
|
||||
return out
|
||||
|
||||
|
||||
def repair_bands(engine, picked, candidates, fam_count, picked_ids):
|
||||
"""Swap new-slot items to reach >=3 price bands when the pool allows.
|
||||
|
||||
Only swaps INTO slots of new items in over-represented bands; returns
|
||||
and wildcard slots are never touched. Deterministic; bounded passes.
|
||||
"""
|
||||
if len(picked) < MIN_BANDS:
|
||||
return picked
|
||||
bands_of = {r['id']: tastemod.price_band(r['price_num']) for r in candidates}
|
||||
bands_of.update({r['id']: tastemod.price_band(r['price_num'])
|
||||
for r in picked})
|
||||
cur_bands = {bands_of[r['id']] for r in picked
|
||||
if bands_of[r['id']] is not None}
|
||||
if len(cur_bands) >= MIN_BANDS:
|
||||
return picked
|
||||
pool_bands = {bands_of[r['id']] for r in candidates
|
||||
if bands_of[r['id']] is not None}
|
||||
if len(pool_bands) < MIN_BANDS:
|
||||
return picked # the pool does not allow 3 bands
|
||||
picked = list(picked)
|
||||
attempts = len(candidates)
|
||||
for _ in range(attempts):
|
||||
cur_bands = {bands_of[r['id']] for r in picked
|
||||
if bands_of[r['id']] is not None}
|
||||
if len(cur_bands) >= MIN_BANDS:
|
||||
break
|
||||
missing = [b for b in pool_bands if b not in cur_bands]
|
||||
if not missing:
|
||||
break
|
||||
want_band = missing[0]
|
||||
best = None
|
||||
best_key = None
|
||||
for cand in candidates:
|
||||
if cand['id'] in picked_ids:
|
||||
continue
|
||||
if bands_of[cand['id']] != want_band:
|
||||
continue
|
||||
fam = cand['family']
|
||||
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
|
||||
continue
|
||||
key = _points_key(engine, cand)
|
||||
if best_key is None or key > best_key:
|
||||
best, best_key = cand, key
|
||||
if best is None:
|
||||
pool_bands = pool_bands - {want_band}
|
||||
if len(pool_bands) < MIN_BANDS:
|
||||
break
|
||||
continue
|
||||
# victim: lowest-points new-slot item from a band with >=2 reps
|
||||
counts = {}
|
||||
for r in picked:
|
||||
b = bands_of[r['id']]
|
||||
if b is not None:
|
||||
counts[b] = counts.get(b, 0) + 1
|
||||
victim = None
|
||||
worst_first = sorted(picked, key=lambda r: _points_key(engine, r),
|
||||
reverse=True)
|
||||
for band in sorted(counts, key=lambda b: -counts[b]):
|
||||
if counts[band] < 2:
|
||||
continue
|
||||
in_band = [r for r in worst_first if bands_of[r['id']] == band]
|
||||
if in_band:
|
||||
victim = in_band[-1]
|
||||
break
|
||||
if victim is None:
|
||||
break
|
||||
victim_fam = victim['family']
|
||||
idx = next(i for i, r in enumerate(picked) if r['id'] == victim['id'])
|
||||
picked[idx] = best
|
||||
if victim_fam and victim_fam in fam_count:
|
||||
fam_count[victim_fam] = max(0, fam_count[victim_fam] - 1)
|
||||
if best['family']:
|
||||
fam_count[best['family']] = fam_count.get(best['family'], 0) + 1
|
||||
picked_ids.remove(victim['id'])
|
||||
picked_ids.add(best['id'])
|
||||
return picked
|
||||
|
||||
|
||||
def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
|
||||
note='', now=None):
|
||||
"""Compose and persist a new open section; returns the section row.
|
||||
|
||||
Caller closes/reports any prior section first. `engine` scores the
|
||||
candidates (a TasteEngine over active listings + events + profile).
|
||||
"""
|
||||
import datetime
|
||||
now = now or datetime.datetime.now(datetime.timezone.utc).replace(
|
||||
tzinfo=None)
|
||||
size = int(size)
|
||||
rows_by_id = {r['id']: r for r in engine.listings}
|
||||
last_two = last_two_listing_ids(conn)
|
||||
|
||||
new_cands = [r for r in conn.execute(NEW_POOL_SQL)
|
||||
if r['id'] not in last_two]
|
||||
ret_cands = [r for r in conn.execute(RETURNS_SQL)
|
||||
if r['id'] not in last_two]
|
||||
wild_cands = [r for r in conn.execute(WILDCARD_SQL)
|
||||
if r['id'] not in last_two]
|
||||
|
||||
# wildcard family exclusions: >=3 cumulative nopes AND net-negative.
|
||||
# Stats are computed for every family the engine knows (active + event
|
||||
# listings), not just current candidates — a hard-noped family is
|
||||
# excluded even when its rows are gone/cleared.
|
||||
fam_stats = {f: engine.family_net(f)
|
||||
for f in {r['family'] for r in engine.listings if r['family']}}
|
||||
excluded_fams = {f for f, (net, nopes) in fam_stats.items()
|
||||
if nopes >= 3 and net < 0}
|
||||
wild_cands = [r for r in wild_cands
|
||||
if not (r['family'] and r['family'] in excluded_fams)]
|
||||
|
||||
fam_count = {}
|
||||
picked_ids = set()
|
||||
|
||||
# 1) preliminary top-3 new (uncommitted) — keys the wildcard preference
|
||||
top3 = pick_new(engine, new_cands, 3, {}, set())
|
||||
top_fams = {r['family'] for r in top3 if r['family']}
|
||||
top_bands = {tastemod.price_band(r['price_num']) for r in top3
|
||||
if tastemod.price_band(r['price_num']) is not None}
|
||||
|
||||
# 2) returns: maybe > like > nope, points DESC within role; nopes must be
|
||||
# "surprising" (points >=55 or net-positive family); <=5, <=2 nopes.
|
||||
# Budget: wildcards keep their 2 slots; returns never push the
|
||||
# section past `size`.
|
||||
wanted_rets = min(MAX_RETURNS, max(0, size - min(WILDCARDS, size)))
|
||||
role_order = {'maybe': 0, 'like': 1, 'nope': 2}
|
||||
rets = []
|
||||
nope_count = 0
|
||||
for cand in sorted(ret_cands, reverse=True,
|
||||
key=lambda r: (-role_order.get(r['verdict'], 9),
|
||||
_points_key(engine, r))):
|
||||
if len(rets) >= wanted_rets:
|
||||
break
|
||||
fam = cand['family']
|
||||
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
|
||||
continue
|
||||
if cand['verdict'] == NOPE:
|
||||
if nope_count >= MAX_RETURN_NOPES:
|
||||
continue
|
||||
net, _nopes = fam_stats.get(fam, (0.0, 0))
|
||||
if engine.points(cand['id']) < 55 and not (fam and net > 0):
|
||||
continue
|
||||
nope_count += 1
|
||||
rets.append(cand)
|
||||
picked_ids.add(cand['id'])
|
||||
if fam:
|
||||
fam_count[fam] = fam_count.get(fam, 0) + 1
|
||||
|
||||
# 3) wildcards: min(2, size), preferred different family AND price band
|
||||
liked_ids = [r['listing_id'] for r in conn.execute(
|
||||
"SELECT listing_id FROM swipes WHERE action='like'")]
|
||||
nov = novelty(rows_by_id, liked_ids,
|
||||
[r['id'] for r in wild_cands])
|
||||
wild_key = lambda r: (
|
||||
-(0.5 * engine.points(r['id']) + 50.0 * nov[r['id']]), r['id'])
|
||||
|
||||
def _wild_preferred(rs):
|
||||
return [r for r in rs
|
||||
if (not r['family'] or r['family'] not in top_fams)
|
||||
and (tastemod.price_band(r['price_num']) is None
|
||||
or tastemod.price_band(r['price_num']) not in top_bands)]
|
||||
|
||||
n_wilds = min(WILDCARDS, size)
|
||||
wilds = []
|
||||
avail = sorted([r for r in wild_cands if r['id'] not in picked_ids],
|
||||
key=wild_key)
|
||||
preferred = sorted(_wild_preferred(avail), key=wild_key)
|
||||
pref_ids = {r['id'] for r in preferred}
|
||||
for cand in preferred + [r for r in avail if r['id'] not in pref_ids]:
|
||||
if len(wilds) >= n_wilds:
|
||||
break
|
||||
fam = cand['family']
|
||||
if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
|
||||
continue
|
||||
wilds.append(cand)
|
||||
picked_ids.add(cand['id'])
|
||||
if fam:
|
||||
fam_count[fam] = fam_count.get(fam, 0) + 1
|
||||
n_wilds = len(wilds) # pool shortfall reverts to new
|
||||
|
||||
# 4) new fills the remainder of the section (size - returns - wildcards)
|
||||
want_new = max(0, size - len(rets) - n_wilds)
|
||||
picked_new = pick_new(engine, new_cands, want_new, fam_count, picked_ids)
|
||||
|
||||
# final pinned order: new -> returns -> wildcards
|
||||
ordered = list(picked_new) + rets + wilds
|
||||
|
||||
# price-band repair (new slots only — ordered[:len(picked_new)])
|
||||
n_new = len(picked_new)
|
||||
before = len({tastemod.price_band(r['price_num']) for r in ordered
|
||||
if tastemod.price_band(r['price_num']) is not None})
|
||||
if n_new:
|
||||
ordered[:n_new] = repair_bands(engine, ordered[:n_new], new_cands,
|
||||
fam_count, picked_ids)
|
||||
after = len({tastemod.price_band(r['price_num']) for r in ordered
|
||||
if tastemod.price_band(r['price_num']) is not None})
|
||||
|
||||
recipe = {
|
||||
'new': len(picked_new),
|
||||
'return': len(rets),
|
||||
'wildcard': len(wilds),
|
||||
'bands': before if before == after else after,
|
||||
'caps_applied': {f: fam_count[f] for f in fam_count
|
||||
if fam_count[f] >= FAMILY_CAP},
|
||||
'note': note or ('bands %d->%d repaired'
|
||||
% (before, after) if before != after else ''),
|
||||
}
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO sections (status, size, profile_version, recipe,
|
||||
created_at)
|
||||
VALUES ('open', ?, ?, ?, ?)""",
|
||||
(size, profile_version, json.dumps(recipe), _iso(now)))
|
||||
section_id = cur.lastrowid
|
||||
conn.executemany(
|
||||
"""INSERT INTO section_items (section_id, listing_id, slot, position)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
[(section_id, r['id'], slot, pos)
|
||||
for pos, (r, slot) in enumerate(
|
||||
[(r, 'new') for r in ordered[:n_new]]
|
||||
+ [(r, 'return') for r in rets]
|
||||
+ [(r, 'wildcard') for r in wilds], 1)])
|
||||
conn.commit()
|
||||
return conn.execute('SELECT * FROM sections WHERE id=?',
|
||||
(section_id,)).fetchone()
|
||||
|
||||
|
||||
# --- read / exhaust ----------------------------------------------------------
|
||||
|
||||
def close_section(conn, section, now, status='closed',
|
||||
created_at_col='closed_at'):
|
||||
conn.execute(
|
||||
'UPDATE sections SET status=?, %s=? WHERE id=?' % created_at_col,
|
||||
(status, _iso(now), section['id']))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def section_items(conn, section):
|
||||
return conn.execute(
|
||||
"""SELECT l.*, s.action AS decision, si.slot, si.position
|
||||
FROM section_items si
|
||||
JOIN listings l ON l.id = si.listing_id
|
||||
LEFT JOIN swipes s ON s.listing_id = l.id
|
||||
WHERE si.section_id = ?
|
||||
ORDER BY si.position ASC""", (section['id'],)).fetchall()
|
||||
|
||||
|
||||
def section_counts(conn, section):
|
||||
"""Per-action counts + second-look / wildcard engagement for this
|
||||
section (events stamped with this section_id only)."""
|
||||
counts = {'likes': 0, 'maybes': 0, 'nopes': 0}
|
||||
for item in section_items(conn, section):
|
||||
if item['decision'] == 'like':
|
||||
counts['likes'] += 1
|
||||
elif item['decision'] == 'maybe':
|
||||
counts['maybes'] += 1
|
||||
elif item['decision'] == 'nope':
|
||||
counts['nopes'] += 1
|
||||
restruck = conn.execute(
|
||||
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
|
||||
JOIN section_items si
|
||||
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
|
||||
WHERE e.section_id = ? AND si.slot = 'return'""",
|
||||
(section['id'],)).fetchone()['n']
|
||||
wild_hits = conn.execute(
|
||||
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
|
||||
JOIN section_items si
|
||||
ON si.section_id = e.section_id AND si.listing_id = e.listing_id
|
||||
WHERE e.section_id = ? AND si.slot = 'wildcard'
|
||||
AND e.action IN ('like','maybe')""",
|
||||
(section['id'],)).fetchone()['n']
|
||||
counts['restruck'] = restruck
|
||||
counts['wildcard_hits'] = wild_hits
|
||||
return counts
|
||||
|
||||
|
||||
def maybe_exhaust(conn, section, now):
|
||||
"""Flip an all-decided open section to 'report' (returns the fresh row)."""
|
||||
if section['status'] != 'open':
|
||||
return section
|
||||
items = section_items(conn, section)
|
||||
if all(it['decision'] is not None for it in items):
|
||||
close_section(conn, section, now, status='report')
|
||||
return conn.execute('SELECT * FROM sections WHERE id=?',
|
||||
(section['id'],)).fetchone()
|
||||
return section
|
||||
|
||||
|
||||
def read_section(conn, section, now):
|
||||
"""Meta + items + progress + exhausted for GET /api/section."""
|
||||
section = maybe_exhaust(conn, section, now)
|
||||
items = section_items(conn, section)
|
||||
decided = sum(1 for it in items if it['decision'] is not None)
|
||||
return {
|
||||
'section': {
|
||||
'id': section['id'],
|
||||
'status': section['status'],
|
||||
'size': section['size'],
|
||||
'profile_version': section['profile_version'],
|
||||
'recipe': json.loads(section['recipe']) if section['recipe']
|
||||
else {},
|
||||
'created_at': section['created_at'],
|
||||
'closed_at': section['closed_at'],
|
||||
},
|
||||
'items': items,
|
||||
'progress': {'decided': decided, 'undecided': len(items) - decided,
|
||||
'total': len(items)},
|
||||
'exhausted': section['status'] != 'open',
|
||||
}
|
||||
421
app/selfcheck.py
421
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),
|
||||
|
||||
@@ -133,17 +133,80 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
|
||||
.search::placeholder{color:#6f6f7a}
|
||||
.toast{position:fixed;left:50%;bottom:150px;transform:translateX(-50%);background:#1d1d24;border:1px solid #2a2a33;color:#fff;padding:10px 16px;border-radius:12px;font-size:13px;z-index:80;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;max-width:92vw;overflow:hidden;text-overflow:ellipsis}
|
||||
.toast.on{opacity:1}
|
||||
/* P3 additions */
|
||||
.sect{margin-left:4px;font-size:12px;font-weight:650;color:var(--mut);white-space:nowrap}
|
||||
.sect.hot{color:var(--accent1)}
|
||||
.band{position:absolute;top:12px;left:12px;z-index:4;max-width:78%;font-size:11px;font-weight:650;letter-spacing:.4px;border-radius:999px;padding:4px 9px;pointer-events:none;line-height:1.35;user-select:none}
|
||||
.band.wild{color:#ffb43a;background:rgba(255,180,58,.14);border:1px solid rgba(255,180,58,.35)}
|
||||
.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)}
|
||||
.report{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px;z-index:3}
|
||||
.report.on{display:flex}
|
||||
.report b{color:#fff;font-size:16px}
|
||||
.rforge{margin-top:10px;border:0;border-radius:14px;background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;font-size:15px;font-weight:700;padding:12px 22px;cursor:pointer;box-shadow:0 8px 22px rgba(253,38,125,.3)}
|
||||
.rforge:disabled{background:#23232b;color:#6f6f7a;box-shadow:none;cursor:default}
|
||||
.rstatus{font-size:12.5px;min-height:16px}
|
||||
.rmode{display:flex;align-items:center;gap:7px;font-size:12px;margin-top:6px;flex-wrap:wrap;justify-content:center}
|
||||
.rmode .ron{color:#fff;border-bottom:1px solid #fff}
|
||||
.rmode span{cursor:pointer;color:var(--mut)}
|
||||
.rmode .sep{color:#4a4a55;cursor:default}
|
||||
.rcap{flex-basis:100%;color:var(--mut);font-size:12px}
|
||||
/* tank gauge (non-numeric) */
|
||||
.tank{width:18px;height:22px;flex:none;display:block}
|
||||
.tank.big{width:28px;height:34px}
|
||||
.tank .tf{transition:y .25s ease,height .25s ease}
|
||||
.rpt{display:flex;align-items:center;gap:7px}
|
||||
/* dossier why-this-rank */
|
||||
.drank{display:flex;gap:12px;align-items:flex-start;margin-top:10px;padding:10px;background:#15151b;border-radius:12px}
|
||||
.drank .tankwrap{flex:none;padding-top:2px}
|
||||
.dpts{font-size:17px;font-weight:700}
|
||||
.dlines{font-size:12.5px;color:var(--mut);line-height:1.55}
|
||||
.dlines .pos{color:#9ee8a8}
|
||||
.dlines .neg{color:#f0a5ab}
|
||||
.dfoot{margin-top:6px;font-size:11.5px;color:#7b7b88}
|
||||
/* trends screen */
|
||||
.trends{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px;overflow-y:auto}
|
||||
.trends.on{display:flex}
|
||||
.tsec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:16px 18px 6px}
|
||||
.tlead{font-size:18px;font-weight:700;margin:0 18px;line-height:1.35}
|
||||
.tsub{font-size:12.5px;color:var(--mut);margin:4px 18px 0}
|
||||
.tbullets{margin:0 18px;font-size:14px;color:#d7d7dd;line-height:1.6}
|
||||
.tminis{display:flex;gap:10px;margin:0 18px}
|
||||
.tmini{flex:1;background:#141419;border:1px solid #1f1f27;border-radius:12px;padding:12px 10px;text-align:center}
|
||||
.tmv{font-size:19px;font-weight:800}
|
||||
.tmk{font-size:10.5px;color:var(--mut);margin-top:4px;letter-spacing:.3px;text-transform:uppercase}
|
||||
.tacts{margin:8px 18px 0;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.tstatus{font-size:12.5px;color:var(--mut)}
|
||||
.trow{display:flex;align-items:center;gap:8px;margin:12px 18px 0;font-size:13px;color:var(--mut)}
|
||||
.trow .on{color:#fff;border-bottom:1px solid #fff}
|
||||
.trow span{cursor:pointer}
|
||||
.trow i{color:#4a4a55;font-style:normal;cursor:default}
|
||||
.tmuted{font-size:12px;color:#6f6f7a;margin:10px 18px 20px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<div class="brand"><span class="flame"></span>Bike-Tinder</div>
|
||||
<div class="pill" id="pill">—</div>
|
||||
<div style="display:flex;align-items:center">
|
||||
<div class="pill" id="pill">—</div>
|
||||
<div class="sect" id="sect"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stage" id="stage">
|
||||
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
|
||||
<div class="report" id="report">
|
||||
<b>Section complete.</b>
|
||||
<div id="repCounts"></div>
|
||||
<div id="repSub">The file is digesting your strikes.</div>
|
||||
<div id="repLine2"></div>
|
||||
<button class="rforge" id="btnForge">Forge next 25</button>
|
||||
<div class="rstatus" id="forgeStatus"></div>
|
||||
<div class="rmode" id="modeRow">
|
||||
<span id="modeOnd" class="on">on demand</span><i class="sep">⇄</i><span id="modeStr">strict</span>
|
||||
<div class="rcap" id="strictCap" style="display:none">Waiting for the nightly read — next section after the agent runs.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="actions">
|
||||
@@ -169,6 +232,14 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
|
||||
<div class="dtitle" id="dtitle"></div>
|
||||
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
|
||||
<div class="dchips" id="dchips"></div>
|
||||
<div class="drank" id="drank" style="display:none">
|
||||
<div class="tankwrap" id="drankTank"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="dpts" id="dpts"></div>
|
||||
<div class="dlines" id="dlines"></div>
|
||||
<div class="dfoot" id="dfoot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dsec">The machine</div>
|
||||
<div class="specs" id="dspecs"></div>
|
||||
<div class="dsec">Seller's notes</div>
|
||||
@@ -203,7 +274,30 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
|
||||
<div class="mempty" id="mempty">No likes yet — swipe right on the deck.</div>
|
||||
</div>
|
||||
|
||||
<div class="stub" id="stubTrends"><b>Trends — Phase 3</b>The deck learns your taste as you swipe.</div>
|
||||
<div class="trends" id="trendsScreen">
|
||||
<div class="lhead"><h1>Trends</h1></div>
|
||||
<div class="tsec">Your read</div>
|
||||
<div class="tlead" id="tLead">—</div>
|
||||
<div class="tsub" id="tReadAt"></div>
|
||||
<div class="tsub" id="tCold" style="display:none">Swipe ~10 more and the read sharpens.</div>
|
||||
<div class="tsec" id="tLeanSec" style="display:none">Leanings</div>
|
||||
<div class="tbullets" id="tLeanings"></div>
|
||||
<div class="tsec">Stats</div>
|
||||
<div class="tminis">
|
||||
<div class="tmini"><div class="tmv" id="tmHit">—</div><div class="tmk">Hit rate</div></div>
|
||||
<div class="tmini"><div class="tmv" id="tmRet">—</div><div class="tmk">Returns re-struck</div></div>
|
||||
<div class="tmini"><div class="tmv" id="tmWild">—</div><div class="tmk">Wildcards hit</div></div>
|
||||
</div>
|
||||
<div class="tsec" id="tSinceSec" style="display:none">Since last read</div>
|
||||
<div class="tsub" id="tSince" style="display:none"></div>
|
||||
<div class="tsec">Actions</div>
|
||||
<div class="tacts">
|
||||
<button class="rforge" id="btnRunAgent" style="margin-top:0">Run agent now</button>
|
||||
<div class="tstatus" id="runStatus"></div>
|
||||
</div>
|
||||
<div class="trow"><span>Forge:</span><span id="tOnd" class="on">on demand</span><i>⇄</i><span id="tStr">strict</span></div>
|
||||
<div class="tmuted" id="tLastRun"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
@@ -216,8 +310,11 @@ const SC=.026, TY=16, DIM=.08; // per-depth step: scale, y-offset, dimming
|
||||
let pile=[]; // top-first [{d,el}] — every card its own swipable object
|
||||
let hist=[]; // undo history: full payloads
|
||||
let dragEl=null, sx=0, sy=0, dx=0, dy=0, moved=false;
|
||||
let buffer=[]; // deck feed from GET /api/deck (payloads)
|
||||
let buffer=[]; // deck feed (payloads, pinned section order in P3)
|
||||
let deckCursor=null, deckLoaded=false, deckLoading=false;
|
||||
let section=null; // P3: latest GET /api/section body
|
||||
let legacyMode=false; // true when the API has no /api/section (P2 API)
|
||||
let forgeBusy=false, runBusy=false;
|
||||
|
||||
/* ---------- production wiring: API + retry queue + toast ---------- */
|
||||
let toastTimer=null;
|
||||
@@ -235,10 +332,10 @@ async function getJSON(url){
|
||||
/* swipe persistence: optimistic, never waits; failures retry 2s/5s/10s then
|
||||
every 15s while the tab lives. Last write per listing wins; the queue runs
|
||||
serially so reorderings can't land out of sequence. */
|
||||
const pend=new Map(); // listing_id -> {act, fails, warned}
|
||||
const pend=new Map(); // listing_id -> {act, secId, fails, warned}
|
||||
let qRunning=false, qTimer=null, qWarned=false;
|
||||
function enqueue(listingId, act){ // act: 'like'|'nope'|'maybe'|null (delete)
|
||||
pend.set(listingId,{act,fails:0});
|
||||
function enqueue(listingId, act, secId=null){ // act: 'like'|'nope'|'maybe'|null (delete)
|
||||
pend.set(listingId,{act,secId,fails:0});
|
||||
scheduleQueue();
|
||||
}
|
||||
function queDelay(op){ return op.fails===1?2000:op.fails===2?5000:op.fails===3?10000:15000; }
|
||||
@@ -258,7 +355,7 @@ async function runQueue(){
|
||||
try{
|
||||
const res= p.act==null
|
||||
? await fetch('/api/swipe/'+id,{method:'DELETE'})
|
||||
: await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({listing_id:id,action:p.act})});
|
||||
: await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p.secId?{listing_id:id,action:p.act,section_id:p.secId}:{listing_id:id,action:p.act})});
|
||||
if(res.ok){ pend.delete(id); }
|
||||
else { p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); } minDelay=Math.max(minDelay,queDelay(p)); }
|
||||
}catch(e){
|
||||
@@ -270,9 +367,9 @@ async function runQueue(){
|
||||
if(pend.size){ qTimer=setTimeout(runQueue,Math.max(minDelay||15000,1000)); }
|
||||
}
|
||||
/* optimistic decision set for list/matches + deck commits share this */
|
||||
function setDecision(id, act){
|
||||
function setDecision(id, act, secId=null){
|
||||
updateRowDecision(id, act); // optimistic paint
|
||||
enqueue(id, act);
|
||||
enqueue(id, act, secId);
|
||||
}
|
||||
|
||||
/* ---------- payload -> display helpers ---------- */
|
||||
@@ -305,6 +402,31 @@ function relSeen(fs){
|
||||
const days=Math.floor((now-d)/86400000);
|
||||
return days<=0?'Today':(days===1?'1 day ago':days+' days ago');
|
||||
}
|
||||
function relTime(ts){
|
||||
if(!ts) return '—';
|
||||
const d=new Date(ts), now=new Date();
|
||||
if(isNaN(d)) return '—';
|
||||
const sec=Math.max(0,Math.floor((now-d)/1000));
|
||||
if(sec<60) return 'just now';
|
||||
const min=Math.floor(sec/60);
|
||||
if(min<60) return min+'m ago';
|
||||
const hr=Math.floor(min/60);
|
||||
if(hr<24) return hr+'h ago';
|
||||
const days=Math.floor(hr/24);
|
||||
return days===1?'1 day ago':days+' days ago';
|
||||
}
|
||||
/* P3: non-numeric tank gauge (filled per points, colored per band) */
|
||||
const TANK_COLORS={like:'var(--like)',maybe:'var(--maybe)',nope:'var(--nope)'};
|
||||
function tankSVG(tank,big){
|
||||
const v= tank&&tank.value!=null?Math.max(0,Math.min(100,tank.value)):0;
|
||||
const color= TANK_COLORS[tank&&tank.band]||'var(--maybe)';
|
||||
const fillH=13.5*v/100, y=18-fillH;
|
||||
return `<svg class="tank${big?' big':''}" viewBox="0 0 18 22" aria-hidden="true">`+
|
||||
`<rect x="5" y="4.5" width="8" height="13.5" rx="4" fill="rgba(255,255,255,.12)"/>`+
|
||||
`<rect x="7.4" y="5.6" width="3.2" height="2.2" rx="1.1" fill="rgba(255,255,255,.28)"/>`+
|
||||
`<rect class="tf" x="6.4" y="${y}" width="5.2" height="${fillH}" rx="2.2" fill="${color}"/>`+
|
||||
`</svg>`;
|
||||
}
|
||||
function ccFrom(it){
|
||||
const m=((it.title||'')+' '+((it.desc||'').split(' [also listed:')[0])).match(/(\d[\d,]*)\s*cc/i);
|
||||
return m?(fmtNum(m[1].replace(/,/g,''))+' cc'):null;
|
||||
@@ -323,11 +445,20 @@ function setStamps(el,a,b,c){
|
||||
el.querySelector('.stamp.nope').style.opacity=b;
|
||||
el.querySelector('.stamp.maybe').style.opacity=c;
|
||||
}
|
||||
function bandHTML(item){ // P3 system bands — the ONLY card-face additions
|
||||
if(item.slot==='wildcard') return `<div class="band wild">WILDCARD · maybe you’ll like this one instead</div>`;
|
||||
if(item.slot==='return'){
|
||||
const verb= item.decision==='like'?'liked':item.decision==='maybe'?'maybed':item.decision==='nope'?'noped':'seen';
|
||||
return `<div class="band sl">SECOND LOOK — you ${verb} this</div>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function cardNode(item){
|
||||
const el=document.createElement('div');
|
||||
el.className='card';
|
||||
el.innerHTML=`${item.img?`<img class="photo" src="${item.img}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}
|
||||
<div class="grad"></div>
|
||||
${bandHTML(item)}
|
||||
<div class="stamp like">LIKE</div>
|
||||
<div class="stamp nope">NOPE</div>
|
||||
<div class="stamp maybe">MAYBE</div>
|
||||
@@ -376,8 +507,8 @@ function fillPile(){
|
||||
}
|
||||
relayer();
|
||||
}
|
||||
/* deck feed: cursor pages of 40; refill whenever the buffer thins */
|
||||
async function loadDeck(){
|
||||
/* deck feed: P3 sections (pinned order) with a legacy /api/deck fallback */
|
||||
async function loadDeck(){ // legacy cursor feed (pre-P3 API)
|
||||
if(deckLoading||deckLoaded) return;
|
||||
deckLoading=true;
|
||||
try{
|
||||
@@ -390,10 +521,48 @@ async function loadDeck(){
|
||||
deckCursor=res.next_cursor||null;
|
||||
if(!deckCursor) deckLoaded=true;
|
||||
}finally{ deckLoading=false; }
|
||||
maybeRefill();
|
||||
}
|
||||
async function loadSection(){
|
||||
if(deckLoading) return;
|
||||
deckLoading=true;
|
||||
try{
|
||||
const res=await fetch('/api/section');
|
||||
if(res.status===404){ // pre-P3 server: fall back to the deck feed
|
||||
legacyMode=true;
|
||||
deckLoading=false;
|
||||
loadDeck();
|
||||
return;
|
||||
}
|
||||
if(!res.ok){ setTimeout(()=>loadSection(),15000); return; }
|
||||
const body=await res.json();
|
||||
section=body;
|
||||
const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id),...buffer.map(b=>b.id)]);
|
||||
const fresh=(body.items||[]).filter(it=>it.decision===null&&!live.has(it.id));
|
||||
buffer.push(...fresh);
|
||||
fillPile();
|
||||
updateSectionUI(body);
|
||||
}catch(e){ setTimeout(()=>loadSection(),15000); }
|
||||
finally{ deckLoading=false; }
|
||||
maybeRefill();
|
||||
}
|
||||
function maybeRefill(){
|
||||
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
|
||||
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('on');
|
||||
if(legacyMode){
|
||||
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
|
||||
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('on');
|
||||
return;
|
||||
}
|
||||
if(!pile.length&&!buffer.length&&!deckLoading){
|
||||
if(section&&(section.exhausted||(section.progress&§ion.progress.undecided===0))){
|
||||
$('report').classList.add('on');
|
||||
$('empty').classList.remove('on');
|
||||
}else if(section){
|
||||
$('empty').classList.remove('on'); $('report').classList.remove('on');
|
||||
}
|
||||
}else{
|
||||
$('empty').classList.remove('on');
|
||||
$('report').classList.remove('on');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- docs/mockup core: commit / fly / undo — no lock ---------- */
|
||||
@@ -401,7 +570,10 @@ function commit(el,dir){
|
||||
if(!isTop(el)) return;
|
||||
const t=pile.shift();
|
||||
hist.push(t.d);
|
||||
setDecision(t.d.id, dir==='maybe'?'maybe':dir); // optimistic persist
|
||||
const act= dir==='maybe'?'maybe':dir;
|
||||
setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null);
|
||||
sectDecided++;
|
||||
paintSect();
|
||||
flyOut(el,dir);
|
||||
fillPile(); relayer();
|
||||
maybeRefill();
|
||||
@@ -422,6 +594,8 @@ function undo(){
|
||||
if(!hist.length) return;
|
||||
const item=hist.pop();
|
||||
setDecision(item.id, null); // DELETE through the queue
|
||||
sectDecided=Math.max(0,sectDecided-1);
|
||||
paintSect();
|
||||
const entry=cardNode(item);
|
||||
entry.el.style.transition='none';
|
||||
entry.el.style.transform='translateY(-46px) scale(1.02)';
|
||||
@@ -432,6 +606,7 @@ function undo(){
|
||||
void entry.el.offsetWidth;
|
||||
relayer();
|
||||
$('empty').classList.remove('on');
|
||||
$('report').classList.remove('on');
|
||||
}
|
||||
$('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); };
|
||||
$('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); };
|
||||
@@ -493,9 +668,35 @@ function openDetail(data,ctx){
|
||||
$('dspecs').innerHTML=rows.map(r=>`<div class="spec"><span class="k">${r[0]}</span><span class="v${(!r[1]||r[1]==='—')?' z':''}">${r[1]||'—'}</span></div>`).join('');
|
||||
$('dnotes').textContent=(data.desc||'').split(' [also listed:')[0]||'';
|
||||
$('dopen').href=data.url||'#';
|
||||
fillRank(data); // P3 "Why this rank" (may be progressive)
|
||||
$('detail').classList.add('on'); $('dback').classList.add('on');
|
||||
$('dbody').scrollTop=0;
|
||||
}
|
||||
/* P3 dossier rank block: gauge + points + strongest ± lines + audit footer */
|
||||
function fillRank(data){
|
||||
if(!data||data.tank==null||data.tank.value==null){
|
||||
$('drank').style.display='none';
|
||||
return;
|
||||
}
|
||||
$('drank').style.display='flex';
|
||||
$('drankTank').innerHTML=tankSVG(data.tank,true);
|
||||
$('dpts').textContent=data.tank.value+'/100';
|
||||
const lines=(data.points_breakdown||[]).slice()
|
||||
.sort((a,b)=>Math.abs(b.value)-Math.abs(a.value)).slice(0,4);
|
||||
$('dlines').innerHTML=lines.map(l=>{
|
||||
const lbl= l.source==='agent'?('agent: '+l.label):l.label;
|
||||
const n= l.value>=0?(' +'+l.value):(' '+l.value);
|
||||
return `<div class="${l.value>=0?'pos':'neg'}">· ${lbl}${n}</div>`;
|
||||
}).join('');
|
||||
const ri= section&§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){
|
||||
<div class="row" data-i="${it.id}">
|
||||
${thumbFor(it)}
|
||||
<div class="rmain"><div class="rtitle"></div><div class="rsub"></div></div>
|
||||
<div class="rright"><div class="rprice"></div>
|
||||
<div class="rright"><div class="rpt">${it.tank?tankSVG(it.tank):''}<div class="rprice"></div></div>
|
||||
<div class="rc" data-i="${it.id}">
|
||||
<button class="rbtn like${on('like')?' on':''}" data-s="like">♥</button>
|
||||
<button class="rbtn maybe${on('maybe')?' on':''}" data-s="maybe">★</button>
|
||||
@@ -700,15 +901,122 @@ async function loadStats(){
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- P3 section UI: progress pill + report + forge + modes ---------- */
|
||||
let sectDecided=0, sectTotal=0;
|
||||
function paintSect(){
|
||||
const t=$('sect');
|
||||
if(legacyMode||!section){ t.textContent=''; return; }
|
||||
t.textContent=`${sectDecided}/${sectTotal}`;
|
||||
t.classList.toggle('hot',sectDecided>=sectTotal);
|
||||
}
|
||||
function paintMode(){
|
||||
const m= section?section.mode:'ondemand';
|
||||
$('modeOnd').classList.toggle('on',m==='ondemand');
|
||||
$('modeStr').classList.toggle('on',m==='strict');
|
||||
$('tOnd').classList.toggle('on',m==='ondemand');
|
||||
$('tStr').classList.toggle('on',m==='strict');
|
||||
$('strictCap').style.display= m==='strict'?'':'none';
|
||||
const btn=$('btnForge');
|
||||
if(m==='strict'){ btn.disabled=true; }
|
||||
else if(!forgeBusy) btn.disabled=false;
|
||||
}
|
||||
function updateSectionUI(body){
|
||||
sectDecided=body.progress.decided; sectTotal=body.progress.total;
|
||||
paintSect();
|
||||
paintMode();
|
||||
const c=body.counts||{};
|
||||
$('repCounts').innerHTML=`♥ ${c.likes||0} · ★ ${c.maybes||0} · ✕ ${c.nopes||0}`;
|
||||
const hits=[];
|
||||
if(c.restruck>0) hits.push('Second looks re-struck: '+c.restruck);
|
||||
if(c.wildcard_hits>0) hits.push('Wildcards that hit: '+c.wildcard_hits);
|
||||
$('repLine2').textContent=hits.join(' · ');
|
||||
}
|
||||
async function setMode(m){
|
||||
const res=await fetch('/api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:'forge_mode',value:m})}).catch(()=>null);
|
||||
if(res&&res.ok){ if(section) section.mode=m; paintMode(); }
|
||||
}
|
||||
$('modeOnd').onclick=()=>setMode('ondemand');
|
||||
$('modeStr').onclick=()=>setMode('strict');
|
||||
$('btnForge').onclick=async()=>{
|
||||
if(forgeBusy||!section) return;
|
||||
forgeBusy=true;
|
||||
const btn=$('btnForge'); btn.disabled=true;
|
||||
$('forgeStatus').textContent='The agent is reading your strikes…';
|
||||
try{
|
||||
const res=await fetch('/api/section/forge',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
||||
const body=await res.json();
|
||||
if(res.ok){
|
||||
$('forgeStatus').textContent= body.note&&body.note.indexOf('fallback')>=0
|
||||
?'Agent offline — forging on the last read.':'';
|
||||
section=body;
|
||||
buffer.length=0;
|
||||
const fresh=(body.items||[]).filter(it=>it.decision===null);
|
||||
buffer.push(...fresh);
|
||||
fillPile();
|
||||
updateSectionUI(body);
|
||||
if(pile.length){ $('report').classList.remove('on'); }
|
||||
}else{
|
||||
$('forgeStatus').textContent= body.detail||'Forge failed';
|
||||
paintMode();
|
||||
}
|
||||
}catch(e){ $('forgeStatus').textContent='Offline — retrying'; }
|
||||
forgeBusy=false;
|
||||
paintMode();
|
||||
};
|
||||
|
||||
/* ---------- P3 trends tab ---------- */
|
||||
function fmtPct(f){ return f==null?'—':((Math.round(f*1000)/10)+'%'); }
|
||||
async function fetchTrends(){
|
||||
const t=await getJSON('/api/trends');
|
||||
if(!t) return;
|
||||
const p=t.profile;
|
||||
$('tLead').textContent= p?p.lead:'—';
|
||||
$('tReadAt').textContent= p?('updated '+relTime(p.created_at)):'';
|
||||
$('tCold').style.display= p?'none':'';
|
||||
$('tLeanSec').style.display= p&&p.leanings&&p.leanings.length?'':'none';
|
||||
$('tLeanings').innerHTML= ((p&&p.leanings)||[]).map(x=>`<div>· ${x}</div>`).join('');
|
||||
const s=t.stats||{};
|
||||
$('tmHit').textContent= fmtPct(s.hit_rate);
|
||||
$('tmRet').textContent= s.returns_re_struck??'0';
|
||||
$('tmWild').textContent= s.wildcards_hit??'0';
|
||||
const since= (p&&p.changes&&p.changes[0])||null;
|
||||
$('tSinceSec').style.display= since?'':'none';
|
||||
$('tSince').style.display= since?'':'none';
|
||||
$('tSince').textContent= since||'';
|
||||
const lr=t.last_run;
|
||||
$('tLastRun').textContent= lr&&lr.ok
|
||||
?`Last read ${relTime(lr.finished_at)} · ${lr.model||'—'}`
|
||||
:(lr?`Last run failed (${lr.source||'?'}) — will retry`:'Last read —');
|
||||
if(section) section.mode=t.forge_mode;
|
||||
paintMode();
|
||||
}
|
||||
$('btnRunAgent').onclick=async()=>{
|
||||
if(runBusy) return;
|
||||
runBusy=true;
|
||||
$('runStatus').textContent='Reading your strikes…';
|
||||
const res=await fetch('/api/agent/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>null);
|
||||
if(res&&res.ok){
|
||||
const b=await res.json();
|
||||
$('runStatus').textContent= b.source==='fallback'?'Agent offline — ran on the last read':'Done — the next read uses it.';
|
||||
}else{
|
||||
$('runStatus').textContent="Couldn't run — see the read above.";
|
||||
}
|
||||
runBusy=false;
|
||||
fetchTrends();
|
||||
};
|
||||
$('tOnd').onclick=()=>setMode('ondemand');
|
||||
$('tStr').onclick=()=>setMode('strict');
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
|
||||
const name=t.dataset.tab;
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x===t));
|
||||
$('listScreen').classList.toggle('on',name==='list');
|
||||
$('matchesScreen').classList.toggle('on',name==='matches');
|
||||
$('stubTrends').classList.toggle('on',name==='trends');
|
||||
$('trendsScreen').classList.toggle('on',name==='trends');
|
||||
if(name==='list') fetchList(); // fresh decisions every visit
|
||||
else if(name==='matches') fetchMatches();
|
||||
else if(name==='trends') fetchTrends();
|
||||
});
|
||||
|
||||
/* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */
|
||||
@@ -722,7 +1030,7 @@ document.addEventListener('keydown',e=>{
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
loadStats();
|
||||
loadDeck();
|
||||
loadSection();
|
||||
|
||||
/* PWA */
|
||||
if('serviceWorker' in navigator && (location.protocol==='https:'||location.hostname==='localhost'||location.hostname==='127.0.0.1')){
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
offline once seen).
|
||||
- Everything else (shell/navigation): network-first, fall back to cache.
|
||||
*/
|
||||
const VERSION = 'bike-tinder-v1';
|
||||
const VERSION = 'bike-tinder-v2';
|
||||
const SHELL = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
|
||||
398
app/taste.py
Normal file
398
app/taste.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Bike-Tinder P3 taste engine — deterministic, pure, stdlib-only.
|
||||
|
||||
Implements docs/RANKING.md "Stage 2 — P3 taste engine":
|
||||
|
||||
- event weights: action (like +1.0 / maybe +0.4 / nope -1.0) x round
|
||||
multiplier (round 1 x1, round >=2 x2) x recency (0.5^(age_days/21)).
|
||||
- feature weights per (feature, value): raw = sum of event weights,
|
||||
shrunk = raw * n/(n+3) with n the number of events in that cell.
|
||||
- taste_raw = sum of shrunk weights over the listing's feature values.
|
||||
- taste_norm = percentile rank of taste_raw among active listings (0-100).
|
||||
- ramp w = min(0.85, N/60) with N = total events; zero events => w = 0.
|
||||
- points = clip(round((1-w)*rubric + w*taste_norm + agent_adj), 0, 100)
|
||||
with agent_adj = sum of matching profile adjustments clamped to +/-10.
|
||||
- band() maps points to a non-numeric tank band: <30 nope / 30-70 maybe / >70 like.
|
||||
|
||||
No randomness, no network. Everything is a pure function of
|
||||
(now, listings, events, profile_payload).
|
||||
"""
|
||||
import bisect
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
LIKE, MAYBE, NOPE = 'like', 'maybe', 'nope'
|
||||
ACTIONS = (LIKE, MAYBE, NOPE)
|
||||
|
||||
ACTION_WEIGHT = {LIKE: 1.0, MAYBE: 0.4, NOPE: -1.0}
|
||||
ROUND_CAP = 2 # engine caps round influence at 2
|
||||
HALF_LIFE_DAYS = 21.0
|
||||
RAMP_MAX = 0.85
|
||||
RAMP_DENOM = 60.0
|
||||
ADJ_CLAMP = 10.0
|
||||
|
||||
# --- feature extraction ------------------------------------------------------
|
||||
|
||||
PRICE_BANDS = [
|
||||
(None, 3000, '<3k'),
|
||||
(3000, 5000, '3-5k'),
|
||||
(5000, 8000, '5-8k'),
|
||||
(8000, 12000, '8-12k'),
|
||||
(12000, None, '12k+'),
|
||||
]
|
||||
|
||||
DISP_BANDS = [
|
||||
(None, 400, '<400'),
|
||||
(400, 650, '400-649'),
|
||||
(650, 900, '650-899'),
|
||||
(900, 1250, '900-1249'),
|
||||
(1250, None, '1250+'),
|
||||
]
|
||||
|
||||
YEAR_BANDS = [
|
||||
(None, 2000, '<2000'),
|
||||
(2000, 2010, '2000-2009'),
|
||||
(2010, 2020, '2010-2019'),
|
||||
(2020, None, '2020+'),
|
||||
]
|
||||
|
||||
KM_BANDS = [
|
||||
(None, 10000, '<10k'),
|
||||
(10000, 25000, '10-25k'),
|
||||
(25000, 60000, '25-60k'),
|
||||
(60000, None, '60k+'),
|
||||
]
|
||||
|
||||
SILHOUETTES = ['bobber', 'custom', 'classic', 'cruiser']
|
||||
|
||||
COLORS = [
|
||||
('red', 'red'),
|
||||
('black', 'black'),
|
||||
('white', 'white'),
|
||||
('blue', 'blue'),
|
||||
('green', 'green'),
|
||||
('silver|grey|gray', 'silver'),
|
||||
('orange', 'orange'),
|
||||
('yellow', 'yellow'),
|
||||
('brown|maroon', 'brown'),
|
||||
('tan', 'tan'),
|
||||
('gold', 'gold'),
|
||||
]
|
||||
|
||||
_CC_RE = re.compile(r'(\d[\d,]*)\s*cc\b', re.IGNORECASE)
|
||||
|
||||
|
||||
def band_for(sorted_edges, value):
|
||||
"""Find the containing band (edges [(lo, hi, label)]); value None -> None."""
|
||||
if value is None:
|
||||
return None
|
||||
for lo, hi, label in sorted_edges:
|
||||
if (lo is None or value >= lo) and (hi is None or value < hi):
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def price_band(price_num):
|
||||
return band_for(PRICE_BANDS, price_num)
|
||||
|
||||
|
||||
def disp_cc(title, desc):
|
||||
m = _CC_RE.search('%s %s' % (title or '', desc or ''))
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1).replace(',', ''))
|
||||
|
||||
|
||||
def disp_band(cc):
|
||||
return band_for(DISP_BANDS, cc)
|
||||
|
||||
|
||||
def year_band(year):
|
||||
return band_for(YEAR_BANDS, year)
|
||||
|
||||
|
||||
def km_band(km):
|
||||
return band_for(KM_BANDS, km)
|
||||
|
||||
|
||||
def extract_features(row):
|
||||
"""Return the listing's list of (dimension, value) feature cells.
|
||||
|
||||
Missing values yield no cell (neutral per RANKING.md).
|
||||
"""
|
||||
title = row['title'] or ''
|
||||
desc = (row['desc'] or '').split(' [also listed:')[0]
|
||||
text = (title + ' ' + desc).lower()
|
||||
feats = []
|
||||
|
||||
if row['family']:
|
||||
feats.append(('family', row['family']))
|
||||
|
||||
pb = price_band(row['price_num'])
|
||||
if pb:
|
||||
feats.append(('price_band', pb))
|
||||
|
||||
db_ = disp_band(disp_cc(title, desc))
|
||||
if db_:
|
||||
feats.append(('displacement', db_))
|
||||
|
||||
yb = year_band(row['year'])
|
||||
if yb:
|
||||
feats.append(('year', yb))
|
||||
|
||||
kb = km_band(row['km'])
|
||||
if kb:
|
||||
feats.append(('km', kb))
|
||||
|
||||
extras = row['extras']
|
||||
if isinstance(extras, str):
|
||||
try:
|
||||
extras = json.loads(extras)
|
||||
except ValueError:
|
||||
extras = []
|
||||
for label in extras or []:
|
||||
feats.append(('extras', label))
|
||||
|
||||
if row['seller_type'] in ('dealer', 'private'):
|
||||
feats.append(('seller', row['seller_type']))
|
||||
|
||||
for pat in SILHOUETTES:
|
||||
if re.search(r'\b%s\b' % pat, text):
|
||||
feats.append(('silhouette', pat))
|
||||
|
||||
for pat, label in COLORS:
|
||||
if re.search(r'\b%s\b' % pat, text):
|
||||
feats.append(('color', label))
|
||||
|
||||
if row['region'] in ('ns', 'nb'):
|
||||
feats.append(('region', row['region']))
|
||||
|
||||
return feats
|
||||
|
||||
|
||||
FEATURE_DIMS = ('family', 'price_band', 'displacement', 'year', 'km',
|
||||
'extras', 'seller', 'silhouette', 'color', 'region')
|
||||
|
||||
|
||||
def family_short(family):
|
||||
"""Compact family label for display (' — ' suffix trimmed)."""
|
||||
if not family:
|
||||
return family
|
||||
return family.split(' — ')[0].strip()
|
||||
|
||||
|
||||
# --- datetime handling -------------------------------------------------------
|
||||
|
||||
def parse_ts(value, now):
|
||||
"""Parse an event created_at ('...T...Z' ISO or 'YYYY-MM-DD HH:MM:SS'
|
||||
sqlite default, both UTC) into a naive-UTC datetime; failures -> None."""
|
||||
del now # kept in the signature for call sites
|
||||
base = (value or '').strip()
|
||||
if not base:
|
||||
return None
|
||||
if base.endswith('Z'):
|
||||
base = base[:-1]
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(base)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def event_weight(action, round_, created_at, now):
|
||||
"""Weight of one swipe event: action x round x recency."""
|
||||
w = ACTION_WEIGHT.get(action, 0.0)
|
||||
r = ROUND_CAP if (round_ or 1) >= ROUND_CAP else (round_ or 1)
|
||||
if r >= 2:
|
||||
w *= 2.0
|
||||
ts = parse_ts(created_at, now)
|
||||
if ts is not None:
|
||||
age_days = max(0.0, (now - ts).total_seconds() / 86400.0)
|
||||
w *= math.pow(0.5, age_days / HALF_LIFE_DAYS)
|
||||
return w
|
||||
|
||||
|
||||
# --- the engine --------------------------------------------------------------
|
||||
|
||||
class TasteEngine:
|
||||
"""Compute points / breakdown / tank for each listing from events.
|
||||
|
||||
listings: rows (sqlite.Row or dict) with keys id, title, desc, family,
|
||||
score, price_num, km, year, seller_type, extras, region.
|
||||
events: rows with keys listing_id, action, round, created_at.
|
||||
profile: taste_profile payload dict (adjustments key used) or None.
|
||||
now: naive-UTC datetime for recency (default: datetime.utcnow()).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
listings: List[Dict[str, Any]],
|
||||
events: List[Dict[str, Any]],
|
||||
profile: Optional[Dict[str, Any]] = None,
|
||||
now: Optional[datetime.datetime] = None,
|
||||
) -> None:
|
||||
self.now = now or datetime.datetime.now(datetime.timezone.utc).replace(
|
||||
tzinfo=None)
|
||||
self.listings: List[Dict[str, Any]] = listings
|
||||
self.profile: Dict[str, Any] = profile or {}
|
||||
self.adjustments: List[Dict[str, Any]] = (
|
||||
self.profile.get('adjustments') or [])
|
||||
# listing_id -> row (also covers listings reachable only via events)
|
||||
self.by_id: Dict[int, Dict[str, Any]] = {
|
||||
r['id']: r for r in self.listings}
|
||||
# feature cells per listing id
|
||||
self.cells: Dict[int, List[Tuple[str, str]]] = {
|
||||
r['id']: extract_features(r) for r in self.listings}
|
||||
# events -> weights, aggregated per (dim, value)
|
||||
self.events = list(events)
|
||||
cells_raw = {}
|
||||
cells_n = {}
|
||||
for ev in self.events:
|
||||
lid = ev['listing_id']
|
||||
w = event_weight(ev['action'], ev['round'], ev['created_at'],
|
||||
self.now)
|
||||
for dim, val in self.cells.get(lid, []):
|
||||
key = (dim, val)
|
||||
cells_raw[key] = cells_raw.get(key, 0.0) + w
|
||||
cells_n[key] = cells_n.get(key, 0) + 1
|
||||
self.cells_raw = cells_raw
|
||||
self.cells_n = cells_n
|
||||
self.event_counts = Counter(ev['listing_id'] for ev in self.events)
|
||||
self._norm_cache = None
|
||||
|
||||
def round_next(self, listing_id):
|
||||
"""Round the NEXT event on this listing would get, capped at 2."""
|
||||
return min(self.event_counts.get(listing_id, 0) + 1, 2)
|
||||
|
||||
# -- aggregates -----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def n_events(self):
|
||||
return len(self.events)
|
||||
|
||||
@property
|
||||
def w(self):
|
||||
"""Taste share of the blend (ramp)."""
|
||||
return min(RAMP_MAX, self.n_events / RAMP_DENOM)
|
||||
|
||||
def shrunk(self, dim, val):
|
||||
raw = self.cells_raw.get((dim, val), 0.0)
|
||||
n = self.cells_n.get((dim, val), 0)
|
||||
return raw * (n / (n + 3.0)) if n else 0.0
|
||||
|
||||
def family_net(self, family):
|
||||
"""Net raw weight + nope count for a family label (all events)."""
|
||||
raw = 0.0
|
||||
nopes = 0
|
||||
for ev in self.events:
|
||||
row = self.by_id.get(ev['listing_id'])
|
||||
if row and row['family'] == family and family:
|
||||
raw += event_weight(ev['action'], ev['round'],
|
||||
ev['created_at'], self.now)
|
||||
if ev['action'] == NOPE:
|
||||
nopes += 1
|
||||
return raw, nopes
|
||||
|
||||
# -- per-listing scores ---------------------------------------------------
|
||||
|
||||
def taste_raw(self, listing_id):
|
||||
return sum(self.shrunk(d, v) for d, v in self.cells.get(listing_id, []))
|
||||
|
||||
def taste_norm(self, listing_id):
|
||||
"""Median-rank percentile (0-100) among active listings."""
|
||||
if self._norm_cache is None:
|
||||
raws = {}
|
||||
for lid, cells in self.cells.items():
|
||||
raws[lid] = sum(self.shrunk(d, v) for d, v in cells)
|
||||
ranked = sorted(raws.values())
|
||||
n = len(ranked)
|
||||
self._norm_cache = {}
|
||||
for lid, r in raws.items():
|
||||
less = bisect.bisect_left(ranked, r)
|
||||
eq = bisect.bisect_right(ranked, r) - less
|
||||
pct = 100.0 * (less + 0.5 * eq) / n if n else 50.0
|
||||
self._norm_cache[lid] = pct
|
||||
return self._norm_cache.get(listing_id, 50.0)
|
||||
|
||||
def agent_adj(self, listing_id):
|
||||
"""Sum of matching profile adjustments, clamped to +/-10."""
|
||||
total = 0.0
|
||||
cells = {d: set() for d, _ in self.cells.get(listing_id, [])}
|
||||
for d, v in self.cells.get(listing_id, []):
|
||||
cells[d].add(v)
|
||||
for adj in self.adjustments:
|
||||
dim = adj.get('feature')
|
||||
val = adj.get('value')
|
||||
delta = adj.get('delta')
|
||||
if dim not in FEATURE_DIMS:
|
||||
continue
|
||||
if not isinstance(delta, (int, float)):
|
||||
continue
|
||||
if val in cells.get(dim, set()):
|
||||
total += float(delta)
|
||||
return max(-ADJ_CLAMP, min(ADJ_CLAMP, total))
|
||||
|
||||
def points(self, listing_id):
|
||||
row = self.by_id.get(listing_id)
|
||||
if row is None:
|
||||
return None
|
||||
pts = (1.0 - self.w) * (row['score'] or 0)
|
||||
pts += self.w * self.taste_norm(listing_id)
|
||||
pts += self.agent_adj(listing_id)
|
||||
return int(max(0, min(100, round(pts))))
|
||||
|
||||
@staticmethod
|
||||
def band(points):
|
||||
if points is None:
|
||||
return None
|
||||
if points < 30:
|
||||
return NOPE
|
||||
if points <= 70:
|
||||
return MAYBE
|
||||
return LIKE
|
||||
|
||||
def tank(self, listing_id):
|
||||
pts = self.points(listing_id)
|
||||
band = self.band(pts)
|
||||
return {'value': pts, 'band': band}
|
||||
|
||||
# -- explainability -------------------------------------------------------
|
||||
|
||||
def contributions(self, listing_id):
|
||||
"""[(display_label, shrunk_weight, dim, val)] sorted by |w| DESC."""
|
||||
out = []
|
||||
for d, v in self.cells.get(listing_id, []):
|
||||
s = self.shrunk(d, v)
|
||||
if not s:
|
||||
continue
|
||||
label = ('%s %s' % (d, v))
|
||||
if d == 'family':
|
||||
label = family_short(v) + ' family'
|
||||
out.append((label, s, d, v))
|
||||
out.sort(key=lambda t: abs(t[1]), reverse=True)
|
||||
return out
|
||||
|
||||
def breakdown(self, listing_id):
|
||||
"""Source-labeled breakdown lines for the dossier."""
|
||||
row = self.by_id.get(listing_id)
|
||||
lines = []
|
||||
lines.append({'source': 'rubric', 'label': 'rubric fit',
|
||||
'value': int(row['score'] or 0)})
|
||||
for label, s, _d, _v in self.contributions(listing_id)[:3]:
|
||||
lines.append({'source': 'taste', 'label': label,
|
||||
'value': round(s, 1)})
|
||||
for adj in self.adjustments:
|
||||
dim = adj.get('feature')
|
||||
val = adj.get('value')
|
||||
delta = adj.get('delta')
|
||||
if dim not in FEATURE_DIMS or not isinstance(delta, (int, float)):
|
||||
continue
|
||||
if val in {v for d, v in self.cells.get(listing_id, [])
|
||||
if d == dim}:
|
||||
why = adj.get('why') or ('%s %s' % (dim, val))
|
||||
lines.append({'source': 'agent', 'label': why,
|
||||
'value': round(float(delta), 1)})
|
||||
return lines
|
||||
Reference in New Issue
Block a user