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).
This commit is contained in:
Alexander Rozhenko
2026-11-15 11:06:51 -04:00
parent 79e7a4f61f
commit beeace231d
2 changed files with 46 additions and 2 deletions

View File

@@ -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),
]

View File

@@ -379,6 +379,11 @@ class TasteEngine:
"""Source-labeled breakdown lines for the dossier."""
row = self.by_id.get(listing_id)
lines = []
# 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]: