From 98d345ceee9545827220e0ecde3b0968ccca2896 Mon Sep 17 00:00:00 2001 From: Torres Date: Fri, 13 Nov 2026 13:38:18 -0400 Subject: [PATCH] =?UTF-8?q?fix(v1.1):=20item=205=20=E2=80=94=20section=20c?= =?UTF-8?q?ounter=20keys=20off=20in-section=20events=20(2/2=20->=203/2=20b?= =?UTF-8?q?ug)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card is decided only when a swipe event carries THIS section's id; the lifetime swipes.action a second look wears into its section no longer paints it as pre-decided. progress/undecided, report counts (latest in-section event per listing) and maybe_exhaust all use the new section_decided_ids() set; read path only, no writes. Every section_state item carries decided_this_section; the FE counts server truth unless a local intent is in flight, and sectDecided is clamped to sectTotal everywhere (commit + paintSect) so the pill can never read past N/N. Semantics note: completing a section now means striking EVERY item in it (returns re-struck on their second look) — the exhaust selfcheck was updated to strike all items, matching the deck's real flow. Undo-on-a- return residual (event stays after the DELETE) is the accepted Q5 clamp- only behavior, covered by a dedicated selfcheck case. --- app/api.py | 7 ++++ app/sections.py | 42 ++++++++++++++++--- app/selfcheck.py | 93 ++++++++++++++++++++++++++++++++++++++++++- app/static/index.html | 9 +++-- 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/app/api.py b/app/api.py index 9fd8677..d52da43 100644 --- a/app/api.py +++ b/app/api.py @@ -451,11 +451,18 @@ def create_app(db_path, img_dir, state_dir=None): 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)) diff --git a/app/sections.py b/app/sections.py index a4f407a..a9e6139 100644 --- a/app/sections.py +++ b/app/sections.py @@ -425,16 +425,44 @@ def section_items(conn, section): ORDER BY si.position ASC""", (section['id'],)).fetchall() +def section_decided_ids(conn, section): + """Listing ids with a swipe event stamped in THIS section. + + The decided set for progress/report/exhaustion is always these ids — + never the lifetime `swipes.action` verdict (a second-look card enters + its section wearing an old decision; only an in-section event means it + was actually decided here). The join keeps out-of-section stray events + honest (same shape `section_counts` uses). + """ + return {r['listing_id'] for r in conn.execute( + """SELECT e.listing_id 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 = ?""", (section['id'],))} + + 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': + # one verdict per listing: the latest in-section event wins (an undo + + # re-swipe within a section must not double-count the report numbers) + latest = {} + for ev in conn.execute( + """SELECT e.listing_id, e.action 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 = ? + ORDER BY e.id ASC""", (section['id'],)): + latest[ev['listing_id']] = ev['action'] + for action in latest.values(): + if action == 'like': counts['likes'] += 1 - elif item['decision'] == 'maybe': + elif action == 'maybe': counts['maybes'] += 1 - elif item['decision'] == 'nope': + elif action == 'nope': counts['nopes'] += 1 restruck = conn.execute( """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e @@ -458,8 +486,9 @@ def maybe_exhaust(conn, section, now): """Flip an all-decided open section to 'report' (returns the fresh row).""" if section['status'] != 'open': return section + decided_ids = section_decided_ids(conn, section) items = section_items(conn, section) - if all(it['decision'] is not None for it in items): + if items and not [it for it in items if it['id'] not in decided_ids]: close_section(conn, section, now, status='report') return conn.execute('SELECT * FROM sections WHERE id=?', (section['id'],)).fetchone() @@ -470,7 +499,8 @@ 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) + decided_ids = section_decided_ids(conn, section) + decided = sum(1 for it in items if it['id'] in decided_ids) return { 'section': { 'id': section['id'], diff --git a/app/selfcheck.py b/app/selfcheck.py index 8d62382..8a240e6 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -739,7 +739,9 @@ def test_p3_exhaust_and_report(): 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]: + # v1.1 (item 5): EVERY item needs an in-section strike to complete + # the section — returns re-enter the deck and are re-struck too. + for it in f['items']: r = c.post('/api/swipe', json={'listing_id': it['id'], 'action': 'like', 'section_id': sid}) assert r.status_code == 200 @@ -1279,6 +1281,87 @@ def test_b1_swipe_photo_idx(): shutil.rmtree(d, ignore_errors=True) +# --- v1.1 checks (temp copies of the source DB only — see p3_env) ---------- + + +def test_v11_item5_section_counter(): + """Returns-only section must read 0/N decided until real in-section + events exist; report counts and exhaustion key off those events.""" + d, c, db_path = p3_env() + try: + conn = _sq_conn(db_path) + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO swipes (listing_id, action, created_at) + SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipes)""") + conn.execute( + """INSERT INTO swipe_events (listing_id, action, round, section_id) + SELECT id, 'maybe', 1, NULL FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipe_events)""") + conn.commit() + conn.close() + s = c.get('/api/section').json() # cold compose + total = s['progress']['total'] + assert total > 0, s + assert s['recipe']['new'] == 0, s['recipe'] + # every item wears a LIFETIME verdict but zero in-section decisions + for it in s['items']: + assert it['decision'] is not None, it['id'] + assert it['decided_this_section'] is False, it['id'] + assert s['progress']['decided'] == 0, s['progress'] + sec_id = s['section']['id'] + # list edit (no section_id) must not count as an in-section decision + lid0 = s['items'][0]['id'] + c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like'}) + assert c.get('/api/section').json()['progress']['decided'] == 0 + # first real in-section swipe -> 1/total + c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like', + 'section_id': sec_id}) + s2 = c.get('/api/section').json() + assert s2['progress']['decided'] == 1, s2['progress'] + # second -> 2/total; report counts reflect in-section verdicts only + lid1 = s2['items'][1]['id'] + c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe', + 'section_id': sec_id}) + s3 = c.get('/api/section').json() + assert s3['progress']['decided'] == 2, s3['progress'] + assert s3['counts']['likes'] == 1, s3['counts'] + assert s3['counts']['maybes'] == 1, s3['counts'] + # undo residual (Q5, accepted): DELETE clears the swipe row but the + # section-stamped event stays -> count holds (clamp-only behavior) + c.delete('/api/swipe/%d' % lid1) + s4 = c.get('/api/section').json() + assert s4['progress']['decided'] == 2, s4['progress'] + c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe', + 'section_id': sec_id}) + # strike the rest in-section -> report flips, counts match verdicts + for it in s4['items']: + if it['id'] not in (lid0, lid1): + c.post('/api/swipe', json={'listing_id': it['id'], + 'action': 'maybe', + 'section_id': sec_id}) + s5 = c.get('/api/section').json() + assert s5['exhausted'] is True + assert s5['section']['status'] == 'report' + assert s5['progress']['decided'] == total, s5['progress'] + assert s5['counts']['likes'] == 1, s5['counts'] + assert s5['counts']['maybes'] == total - 1, s5['counts'] + # a fresh returns-only section of the same shape composes cleanly + # (lifetime verdicts present, in-section decided == 0 again) + f = c.post('/api/section/forge', json={}).json() + assert f['recipe']['new'] == 0, f['recipe'] + assert f['progress']['decided'] == 0, f['progress'] + for it in f['items']: + assert it['decision'] is not None and not it['decided_this_section'] + 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), @@ -1295,6 +1378,14 @@ P3_API_TESTS = [ if API_DEPS: TESTS += P3_API_TESTS +V11_TESTS = [ + ('v1.1: item 5 — section counter keys off in-section events', + test_v11_item5_section_counter), +] + +if API_DEPS: + TESTS += V11_TESTS + B1_TESTS = [ ('b1: guarded migrations (imgs + last_photo_idx, idempotent)', test_b1_migration_guarded), diff --git a/app/static/index.html b/app/static/index.html index 2fbd725..bf370bb 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -686,7 +686,7 @@ function commit(el,dir){ const act= dir==='maybe'?'maybe':dir; setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null, nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike - sectDecided++; + sectDecided=Math.min(sectTotal, sectDecided+1); // clamp: never past the total paintSect(); flyOut(el,dir); fillPile(); relayer(); @@ -1175,7 +1175,7 @@ let sectDecided=0, sectTotal=0; function paintSect(){ const t=$('sect'); if(legacyMode||!section){ t.textContent=''; return; } - t.textContent=`${sectDecided}/${sectTotal}`; + t.textContent=`${Math.min(sectDecided,sectTotal)}/${sectTotal}`; // clamp: never paints past the total t.classList.toggle('hot',sectDecided>=sectTotal); } function paintMode(){ @@ -1194,7 +1194,10 @@ function updateSectionUI(body){ let decided=0; for(const it of (body.items||[])){ const local=localDecisions.get(it.id); - if(local===undefined? it.decision!=null : local!=null) decided++; + // item 5: server truth = decided DURING this section (an in-section + // event), never the lifetime decision a second look wears into it; + // local intent wins while a swipe's POST is in flight + if(local===undefined? !!it.decided_this_section : local!=null) decided++; } sectDecided=decided; paintSect();