section_state now mirrors the composed recipe at body.recipe; the forge note appends 'catalog fully reviewed' when the batch has zero fresh cards. The FE shows the BACKLOG sentence in the report (dryCap) and in the forge status line whenever recipe.new===0 with cards served; a non-dry forge behaves exactly as before. No new fetch loops.
753 lines
30 KiB
Python
753 lines
30 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')
|
|
decided_ids = secmod.section_decided_ids(conn, sec)
|
|
items = [p3_payload(conn, engine, r, slot=r['slot'],
|
|
include_breakdown=True)
|
|
for r in data['items']]
|
|
for p in items:
|
|
# item 5: decided IN this section (real verdict), not the joined
|
|
# lifetime swipes decision — present on EVERY item so the FE's
|
|
# local-intent branch can't misfire
|
|
p['decided_this_section'] = p['id'] in decided_ids
|
|
data['items'] = items
|
|
data['mode'] = mode
|
|
data['recipe'] = (json.loads(sec['recipe']) if sec['recipe'] else {})
|
|
data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')}
|
|
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)
|
|
# v1.1 item 6: label the dry-catalog state on the forge note
|
|
recipe = (json.loads(newsec['recipe'])
|
|
if newsec['recipe'] else {})
|
|
if recipe.get('new') == 0:
|
|
note = '%s · catalog fully reviewed' % (note or '')
|
|
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'),
|
|
'rubric_debug': ('off', 'on')}
|
|
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
|