739 lines
29 KiB
Python
739 lines
29 KiB
Python
"""Bike-Tinder API (Phase 2) — FastAPI app factory.
|
|
|
|
create_app(db_path, img_dir, state_dir=None) -> FastAPI
|
|
|
|
The factory keeps every path injectable so the selfcheck can run the real
|
|
router against a temp copy of the DB (`fastapi.testclient.TestClient`).
|
|
Serve via app/serve.py (uvicorn) — see docs/DESIGN.md for the surface.
|
|
|
|
Payload contract (deck/list/listing items — exact field names the FE relies
|
|
on; frozen in the task brief):
|
|
{id, title, price_display, price_num, km, location, region ('ns'|'nb'),
|
|
url, img ("/img/{id}" if img_url or cached file exists else null), desc,
|
|
family, score, year, seller_type, extras (array of strings),
|
|
decision (null|'like'|'maybe'|'nope'), first_seen}
|
|
B1 additions (UI-SPEC §11 / DATA-MODEL "B1 additions"):
|
|
n_photos (int), last_photo_idx (int|null); GET /img/{id}?i=n serves
|
|
gallery photo n.
|
|
"""
|
|
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')
|
|
|
|
# --- SQL ------------------------------------------------------------------
|
|
|
|
DECK_WHERE = (
|
|
"l.status = 'active'"
|
|
" AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id"
|
|
" AND s.action = 'nope')"
|
|
" AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id"
|
|
" AND s.action = 'like')"
|
|
" AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id"
|
|
" AND s.action = 'maybe'"
|
|
" AND COALESCE(s.updated_at, s.created_at)"
|
|
" > datetime('now', '-7 days'))"
|
|
)
|
|
|
|
DECK_SELECT = (
|
|
"SELECT l.*, s.action AS decision, s.last_photo_idx AS last_photo_idx"
|
|
" FROM listings l LEFT JOIN swipes s ON s.listing_id = l.id"
|
|
" WHERE " + DECK_WHERE
|
|
)
|
|
|
|
DECK_ORDER = " ORDER BY l.score DESC, COALESCE(l.first_seen,'') DESC, l.id DESC"
|
|
|
|
LIST_SORTS = {
|
|
'score': 'l.score DESC, COALESCE(l.first_seen,\'\') DESC, l.id DESC',
|
|
'price_asc': 'l.price_num IS NULL ASC, l.price_num ASC, l.id DESC',
|
|
'price_desc': 'l.price_num IS NULL ASC, l.price_num DESC, l.id DESC',
|
|
'newest': 'COALESCE(l.first_seen,\'\') DESC, l.id DESC',
|
|
'km': 'l.km IS NULL ASC, l.km ASC, l.id DESC',
|
|
}
|
|
|
|
LIST_DECISIONS = {
|
|
'all': '1=1',
|
|
'undecided': 's.action IS NULL',
|
|
'like': "s.action = 'like'",
|
|
'maybe': "s.action = 'maybe'",
|
|
'nope': "s.action = 'nope'",
|
|
}
|
|
|
|
REGION_NAMES = {'ns': 'NS', 'nb': 'NB'}
|
|
|
|
|
|
# --- helpers --------------------------------------------------------------
|
|
|
|
def get_conn(app):
|
|
conn = dbmod.connect(app.state.db_path)
|
|
dbmod.init_db(conn)
|
|
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 (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
|
|
|
|
|
def get_pref(conn, key, default=None):
|
|
row = conn.execute('SELECT value FROM prefs WHERE key=?', (key,)).fetchone()
|
|
return row['value'] if row else default
|
|
|
|
|
|
def parse_cursor(cursor):
|
|
"""Opaque '<score>|<first_seen>|<id>' -> (int, str, int) or None."""
|
|
if not cursor:
|
|
return None
|
|
try:
|
|
score, first_seen, listing_id = cursor.split('|', 2)
|
|
return int(score), first_seen, int(listing_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail='bad cursor')
|
|
|
|
|
|
def cursor_clause():
|
|
"""Strictly-after in (score, first_seen, id) DESC tuple order."""
|
|
return (" AND (l.score < ? OR"
|
|
" (l.score = ? AND (COALESCE(l.first_seen,'') < ? OR"
|
|
" (COALESCE(l.first_seen,'') = ? AND l.id < ?))))")
|
|
|
|
|
|
def count_deck_after(app, score, first_seen, listing_id):
|
|
conn = get_conn(app)
|
|
try:
|
|
n = conn.execute(
|
|
'SELECT COUNT(*) AS n FROM listings l'
|
|
' LEFT JOIN swipes s ON s.listing_id = l.id'
|
|
' WHERE ' + DECK_WHERE + cursor_clause(),
|
|
(score, score, first_seen, first_seen, listing_id)).fetchone()['n']
|
|
return n
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def build_payload(img_dir):
|
|
def _payload(conn, row):
|
|
leg = os.path.join(img_dir, '%d.webp' % row['id'])
|
|
b1 = os.path.join(img_dir, '%d_0.webp' % row['id'])
|
|
has_photo = (bool(row['img_url']) or os.path.exists(leg)
|
|
or os.path.exists(b1))
|
|
img = '/img/%d' % row['id'] if has_photo else None
|
|
# B1: gallery size + swipe-time photo index (UI-SPEC §11 / DATA-MODEL B1)
|
|
imgs = []
|
|
if isinstance(row['imgs'], str) and row['imgs']:
|
|
try:
|
|
imgs = json.loads(row['imgs'])
|
|
except ValueError:
|
|
imgs = []
|
|
if not isinstance(imgs, list):
|
|
imgs = []
|
|
n_photos = len(imgs) if imgs else (1 if has_photo else 0)
|
|
extras = row['extras']
|
|
if isinstance(extras, str):
|
|
try:
|
|
extras = json.loads(extras)
|
|
except ValueError:
|
|
extras = []
|
|
return {
|
|
'id': row['id'],
|
|
'title': row['title'],
|
|
'price_display': row['price_display'],
|
|
'price_num': row['price_num'],
|
|
'km': row['km'],
|
|
'location': row['location'],
|
|
'region': row['region'],
|
|
'url': row['url'],
|
|
'img': img,
|
|
'n_photos': n_photos,
|
|
'last_photo_idx': (row['last_photo_idx']
|
|
if 'last_photo_idx' in row.keys() else None),
|
|
'desc': row['desc'],
|
|
'family': row['family'],
|
|
'score': row['score'],
|
|
'year': row['year'],
|
|
'seller_type': row['seller_type'],
|
|
'extras': extras or [],
|
|
'decision': row['decision'] if 'decision' in row.keys() else None,
|
|
'first_seen': row['first_seen'],
|
|
}
|
|
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):
|
|
if state_dir is None:
|
|
state_dir = os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d')
|
|
os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
|
|
os.makedirs(os.path.abspath(img_dir), exist_ok=True)
|
|
static_dir = os.path.join(os.path.dirname(__file__), 'static')
|
|
icons_dir = os.path.join(static_dir, 'icons')
|
|
|
|
app = FastAPI(title='Bike-Tinder')
|
|
app.state.db_path = os.path.abspath(db_path)
|
|
app.state.img_dir = os.path.abspath(img_dir)
|
|
app.state.state_dir = state_dir
|
|
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 -----------------------------------------------------------
|
|
|
|
@app.get('/api/health')
|
|
def health():
|
|
conn = get_conn(app)
|
|
try:
|
|
counts = {}
|
|
for status in ('active', 'gone', 'dup'):
|
|
counts[status] = conn.execute(
|
|
'SELECT COUNT(*) AS n FROM listings WHERE status=?',
|
|
(status,)).fetchone()['n']
|
|
counts['total'] = sum(counts.values())
|
|
return {'ok': True,
|
|
'db': os.path.basename(app.state.db_path),
|
|
'counts': counts}
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.get('/api/stats')
|
|
def stats():
|
|
conn = get_conn(app)
|
|
try:
|
|
live = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM listings WHERE status='active'"
|
|
).fetchone()['n']
|
|
badge = get_pref(conn, 'badge_seen_at') or ''
|
|
new = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM listings"
|
|
" WHERE status='active' AND COALESCE(first_seen,'') > ?",
|
|
(badge,)).fetchone()['n']
|
|
per = {}
|
|
for action in ('like', 'maybe', 'nope'):
|
|
per[action] = conn.execute(
|
|
'SELECT COUNT(*) AS n FROM swipes WHERE action=?',
|
|
(action,)).fetchone()['n']
|
|
last = conn.execute(
|
|
'SELECT started_at, ok FROM import_runs'
|
|
' ORDER BY id DESC LIMIT 1').fetchone()
|
|
return {
|
|
'live': live,
|
|
'new': new,
|
|
'likes': per['like'],
|
|
'maybes': per['maybe'],
|
|
'nopes': per['nope'],
|
|
'last_import': {'at': last['started_at'], 'ok': bool(last['ok'])}
|
|
if last else None,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
# -- deck -----------------------------------------------------------
|
|
|
|
@app.get('/api/deck')
|
|
def deck(limit: int = Query(40, ge=1, le=100),
|
|
cursor: str = Query(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()
|
|
else:
|
|
rows = conn.execute(
|
|
DECK_SELECT + cursor_clause() + DECK_ORDER + ' LIMIT ?',
|
|
(cur[0], cur[0], cur[1], cur[1], cur[2], limit)).fetchall()
|
|
items = [p3_payload(conn, engine, r) for r in rows]
|
|
next_cursor = None
|
|
remaining = 0
|
|
if rows:
|
|
last = rows[-1]
|
|
if len(rows) == limit:
|
|
nc = (last['score'], last['first_seen'] or '',
|
|
last['id'])
|
|
next_cursor = '%s|%s|%s' % nc
|
|
remaining = count_deck_after(app, *nc)
|
|
return {'items': items, 'next_cursor': next_cursor,
|
|
'remaining': remaining}
|
|
finally:
|
|
conn.close()
|
|
|
|
# -- swipes ---------------------------------------------------------
|
|
|
|
@app.post('/api/swipe')
|
|
def swipe(body: dict):
|
|
listing_id = body.get('listing_id')
|
|
action = body.get('action')
|
|
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
|
|
# B1: optional photo index on screen at swipe time (§11.5 rotation).
|
|
has_photo_idx = 'photo_idx' in body
|
|
photo_idx = body.get('photo_idx')
|
|
if has_photo_idx and photo_idx is not None:
|
|
if (isinstance(photo_idx, bool) or not isinstance(photo_idx, int)
|
|
or photo_idx < 0):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail='photo_idx must be a non-negative int (or null)')
|
|
conn = get_conn(app)
|
|
try:
|
|
row = conn.execute('SELECT id FROM listings WHERE id=?',
|
|
(listing_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail='unknown listing')
|
|
now = imp.now_iso()
|
|
if has_photo_idx:
|
|
# explicit photo_idx (int or null) overwrites; an absent key
|
|
# preserves the stored index (list edits etc.)
|
|
conn.execute(
|
|
'''INSERT INTO swipes (listing_id, action, created_at,
|
|
updated_at, last_photo_idx)
|
|
VALUES (?,?,?,?,?)
|
|
ON CONFLICT(listing_id) DO UPDATE SET
|
|
action=excluded.action, updated_at=excluded.updated_at,
|
|
last_photo_idx=excluded.last_photo_idx''',
|
|
(listing_id, action, now, now, photo_idx))
|
|
else:
|
|
conn.execute(
|
|
'''INSERT INTO swipes (listing_id, action, created_at, updated_at)
|
|
VALUES (?,?,?,?)
|
|
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,'
|
|
' last_photo_idx FROM swipes WHERE listing_id=?',
|
|
(listing_id,)).fetchone()
|
|
result = dict(stored)
|
|
result['round'] = round_
|
|
result['section_id'] = section_id
|
|
return result
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.delete('/api/swipe/{listing_id}')
|
|
def swipe_clear(listing_id: int):
|
|
conn = get_conn(app)
|
|
try:
|
|
cur = conn.execute('DELETE FROM swipes WHERE listing_id=?',
|
|
(listing_id,))
|
|
conn.commit()
|
|
return {'listing_id': listing_id, 'cleared': cur.rowcount > 0}
|
|
finally:
|
|
conn.close()
|
|
|
|
# -- list + listing -------------------------------------------------
|
|
|
|
@app.get('/api/list')
|
|
def list_(decision: str = Query('all'), sort: str = Query('score'),
|
|
q: str = Query(None), limit: int = Query(100, ge=1, le=500),
|
|
offset: int = Query(0, ge=0)):
|
|
if decision not in LIST_DECISIONS:
|
|
raise HTTPException(status_code=400, detail='bad decision filter')
|
|
if sort not in LIST_SORTS:
|
|
raise HTTPException(status_code=400, detail='bad sort')
|
|
where = ("l.status = 'active' AND " + LIST_DECISIONS[decision])
|
|
params = []
|
|
if q:
|
|
escaped = (q.replace('\\', '\\\\').replace('%', '\\%')
|
|
.replace('_', '\\_'))
|
|
where += " AND l.title LIKE ? ESCAPE '\\'"
|
|
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'
|
|
' WHERE ' + where, params).fetchone()['n']
|
|
rows = conn.execute(
|
|
'SELECT l.*, s.action AS decision,'
|
|
' s.last_photo_idx AS last_photo_idx FROM listings l'
|
|
' 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': [p3_payload(conn, engine, r) for r in rows],
|
|
'total': total}
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.get('/api/listing/{listing_id}')
|
|
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,'
|
|
' s.last_photo_idx AS last_photo_idx 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 p3_payload(conn, engine, row, include_breakdown=True)
|
|
finally:
|
|
conn.close()
|
|
|
|
# -- import ---------------------------------------------------------
|
|
|
|
@app.post('/api/import')
|
|
def run_import_endpoint():
|
|
try:
|
|
result = imp.run_import(app.state.state_dir, app.state.db_path)
|
|
except Exception as e: # noqa: BLE001 — surface anything to the caller
|
|
result = {'ok': False, 'errors': {'exception': str(e)}}
|
|
return JSONResponse(status_code=200, content=result)
|
|
|
|
# -- badge ----------------------------------------------------------
|
|
|
|
@app.post('/api/badge/seen')
|
|
def badge_seen():
|
|
conn = get_conn(app)
|
|
try:
|
|
now = imp.now_iso()
|
|
set_pref(conn, 'badge_seen_at', now)
|
|
conn.commit()
|
|
return {'badge_seen_at': now}
|
|
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') if isinstance(body, dict) else None
|
|
if source is None:
|
|
source = 'live' if live_configured() 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}')
|
|
def img(listing_id: int, i: int = Query(0, ge=0)):
|
|
"""Photo n of a gallery (B1 §11.1): mirrored file -> CDN 302 -> 404.
|
|
|
|
i=0 keeps the legacy chain ({id}.webp, with {id}_0.webp accepted as
|
|
the gallery-mirror name for the primary); i>0 serves {id}_{n}.webp
|
|
with the CDN fallback taken from imgs[n].
|
|
"""
|
|
fpath = os.path.join(
|
|
app.state.img_dir,
|
|
'%d.webp' % listing_id if i == 0 else
|
|
'%d_%d.webp' % (listing_id, i))
|
|
if i == 0 and not os.path.exists(fpath):
|
|
alt = os.path.join(app.state.img_dir, '%d_0.webp' % listing_id)
|
|
if os.path.exists(alt):
|
|
fpath = alt
|
|
if os.path.exists(fpath):
|
|
return FileResponse(fpath, media_type='image/webp')
|
|
conn = get_conn(app)
|
|
try:
|
|
row = conn.execute(
|
|
'SELECT img_url, imgs FROM listings WHERE id=?',
|
|
(listing_id,)).fetchone()
|
|
finally:
|
|
conn.close()
|
|
url = None
|
|
if row:
|
|
if i == 0 and row['img_url']:
|
|
url = row['img_url']
|
|
elif row['imgs']:
|
|
try:
|
|
arr = json.loads(row['imgs'])
|
|
except ValueError:
|
|
arr = []
|
|
if isinstance(arr, list) and len(arr) > i and arr[i]:
|
|
url = arr[i]
|
|
if url:
|
|
return RedirectResponse(url=url, status_code=302)
|
|
raise HTTPException(status_code=404, detail='no image')
|
|
|
|
@app.get('/')
|
|
def index():
|
|
return FileResponse(
|
|
os.path.join(static_dir, 'index.html'),
|
|
headers={'Cache-Control': 'no-cache'})
|
|
|
|
@app.get('/manifest.json')
|
|
def manifest():
|
|
return FileResponse(os.path.join(static_dir, 'manifest.json'))
|
|
|
|
@app.get('/apple-touch-icon.png')
|
|
def apple_touch():
|
|
return FileResponse(os.path.join(icons_dir, 'apple-touch-icon.png'))
|
|
|
|
@app.get('/sw.js')
|
|
def sw():
|
|
return FileResponse(os.path.join(static_dir, 'sw.js'),
|
|
headers={'Cache-Control': 'no-cache'})
|
|
|
|
@app.get('/icons/{name}')
|
|
def icon(name: str):
|
|
if '/' in name or '..' in name:
|
|
raise HTTPException(status_code=404, detail='no such icon')
|
|
fpath = os.path.join(icons_dir, name)
|
|
if not os.path.exists(fpath):
|
|
raise HTTPException(status_code=404, detail='no such icon')
|
|
return FileResponse(fpath)
|
|
|
|
return app
|