From beeace231de17b73526fabd80b730e6ee8cbbf64 Mon Sep 17 00:00:00 2001 From: Alexander Rozhenko Date: Sun, 15 Nov 2026 11:06:51 -0400 Subject: [PATCH] fix #3: guard breakdown() so a delisted mid-batch card cannot 500 the deck A section can outlive an ad: when a card's listing left the active pool after compose and has no swipe history, it is absent from the engine's by_id map. breakdown() indexed the missing row without a guard, so one such card 500'd GET /api/section (and /api/listing/{id}) - live incident 2026-11-15: section 57 + delisted listing 298. Degrade to no-signal lines instead. Regression test added; before/after verified on a copy of the live DB (section 500 -> 200; forge rebuild 200). --- app/selfcheck.py | 39 +++++++++++++++++++++++++++++++++++++++ app/taste.py | 9 +++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/selfcheck.py b/app/selfcheck.py index 70a3b4b..a80a363 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -934,6 +934,10 @@ def test_p3_points_and_band(): br = eng.breakdown(1) assert {b['source'] for b in br} <= {'rubric', 'taste', 'agent'} assert br[0]['source'] == 'rubric' + # regression (#3): a listing OUTSIDE the engine's pool (delisted + # mid-batch, never swiped) must degrade quietly, not crash the render + assert eng.points(9999) is None + assert eng.breakdown(9999) == [] def test_p3_payload_additions(): @@ -959,6 +963,39 @@ def test_p3_payload_additions(): shutil.rmtree(d, ignore_errors=True) +def test_issue3_section_survives_midsection_delist(): + """#3 regression: one card delisted after compose must not 500 the deck. + + The section render asks the taste engine for a breakdown of every + card. A listing that left the active pool mid-batch AND has no swipe + history is not in the engine's by_id map; the unguarded lookup used + to raise, killing GET /api/section and GET /api/listing/{id} with a + 500 (live incident 2026-11-15: section 57 + delisted listing 298). + """ + d, c, db_path = p3_env() + try: + sec = c.get('/api/section') + assert sec.status_code == 200, sec.status_code + body = sec.json() + conn = _sq_conn(db_path) + ev_ids = {r['listing_id'] for r in conn.execute( + 'SELECT DISTINCT listing_id FROM swipe_events')} + it = next((x for x in body['items'] if x['id'] not in ev_ids), None) + assert it is not None, 'fixture section has no unswiped card' + conn.execute("UPDATE listings SET status='gone' WHERE id=?", + (it['id'],)) + conn.commit() + conn.close() + r = c.get('/api/section') + assert r.status_code == 200, r.status_code + assert it['id'] in [x['id'] for x in r.json()['items']] + one = c.get('/api/listing/%d' % it['id']) + assert one.status_code == 200, one.status_code + assert one.json()['points_breakdown'] == [] + finally: + shutil.rmtree(d, ignore_errors=True) + + # --- B1 checks (photo experience v2, UI-SPEC ยง11 / DATA-MODEL B1) ----------- def make_b1_item(title, imgs=None, img=None, price='$5,000', km='8000'): @@ -1751,6 +1788,8 @@ P3_API_TESTS = [ ('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), + ('p3: mid-section delist keeps the deck up (#3)', + test_issue3_section_survives_midsection_delist), ] diff --git a/app/taste.py b/app/taste.py index 2bf665d..b52fad4 100644 --- a/app/taste.py +++ b/app/taste.py @@ -379,8 +379,13 @@ class TasteEngine: """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)}) + # issue #3: a section can outlive an ad. A listing that left the + # active pool mid-batch (and has no swipe history) is not in + # by_id; its breakdown degrades to no-signal lines instead of a + # TypeError that would 500 the whole section render. + if row is not None: + 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)})