From 2ab8fcbdf0d46c4b53637c925cd3b3894cadfdf7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 05:14:59 -0400 Subject: [PATCH] feat: drawer bulk significance re-rate per chat (T110.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawer's Significance review panel previously only supported per-memory edits. Adds a bulk control: pick ``level_from`` and ``level_to``, and every memory in the chat at ``level_from`` is moved to ``level_to``. Implementation emits one ``manual_edit`` event per matching memory (not a single bulk event) so the §6.4 per-row audit trail stays intact — each affected memory carries its own ``prior_value -> new_value`` snapshot, so an inverse edit can restore an individual row without needing to inspect a bulk payload's member list. Reuses the existing ``memory_significance`` ``manual_edit`` projector branch (T25), so no state-layer changes are required. The route rejects no-op submissions (``level_from == level_to``) with 400 to avoid padding the event log with empty edits, and clamps both levels to 0..3 (matching ``edit_memory_significance``). UI: a small ``
`` block in the Significance review section with two number inputs and a submit button. Test: tests/test_drawer_phase4.py::test_bulk_significance_re_rate_emits_manual_edit_per_memory. --- chat/templates/_drawer.html | 19 ++++++++ chat/web/drawer.py | 58 ++++++++++++++++++++++++ tests/test_drawer_phase4.py | 89 +++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index 8cfdd5f..6bbfeeb 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -547,6 +547,25 @@
{% endif %} + {# T110.4: bulk significance re-rate. Move every memory in this chat + at level_from to level_to with one manual_edit event per row, so + the audit trail stays per-memory. #} +
+ Bulk re-rate significance +
+ + + +
+
diff --git a/chat/web/drawer.py b/chat/web/drawer.py index b965e7a..5f94957 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -411,6 +411,64 @@ async def edit_memory_significance( return await drawer(chat_id, request, conn) +@router.post( + "/chats/{chat_id}/drawer/memory/significance/bulk", + response_class=HTMLResponse, +) +async def bulk_re_rate_significance( + chat_id: str, + request: Request, + level_from: int = Form(...), + level_to: int = Form(...), + conn=Depends(get_conn), +): + """T110.4: bulk re-rate every memory in this chat at ``level_from`` + to ``level_to``. + + Fans out into one ``manual_edit`` event per matching memory rather + than a single bulk event so the §6.4 audit trail stays per-row — + each affected memory carries its own ``prior_value -> new_value`` + snapshot, so an inverse edit can restore an individual row without + needing to inspect a bulk payload's member list. The drawer's + significance-distribution panel surfaces the new buckets on the + refreshed partial. + + Both levels are clamped to 0..3 (matching ``edit_memory_significance``) + and a no-op (``level_from == level_to``) is rejected with 400 so a + misclick can't pad the event log with empty edits. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + lf = max(0, min(3, int(level_from))) + lt = max(0, min(3, int(level_to))) + if lf == lt: + raise HTTPException( + status_code=400, + detail=f"level_from and level_to must differ (both = {lf})", + ) + + rows = conn.execute( + "SELECT id FROM memories WHERE chat_id = ? AND significance = ? " + "ORDER BY id ASC", + (chat_id, lf), + ).fetchall() + for row in rows: + memory_id = int(row[0]) + append_and_apply( + conn, + kind="manual_edit", + payload={ + "target_kind": "memory_significance", + "target_id": memory_id, + "prior_value": lf, + "new_value": lt, + }, + ) + return await drawer(chat_id, request, conn) + + @router.post( "/chats/{chat_id}/drawer/memory/{memory_id}/pin", response_class=HTMLResponse, diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py index 20b428e..be4d854 100644 --- a/tests/test_drawer_phase4.py +++ b/tests/test_drawer_phase4.py @@ -523,6 +523,95 @@ def test_delete_impact_modal_escapes_user_controllable_strings(client, tmp_path) assert "<script>alert" in body +def test_bulk_significance_re_rate_emits_manual_edit_per_memory(client, tmp_path): + """T110.4: bulk significance re-rate fans out into one + ``manual_edit`` event per matching memory — preserving the per-row + audit trail (and reversibility) instead of collapsing everything + into a single bulk event. + + Seed five memories at significance 0, bulk re-rate 0 -> 2, and + verify five new ``memory_significance`` ``manual_edit`` rows landed + AND every memory now sits at significance 2. + """ + db = tmp_path / "test.db" + _seed_chat(db) + + # Five memories at significance 0. + with open_db(db) as conn: + for i in range(5): + append_and_apply( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "pov_summary": f"low-sig memory {i}", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "significance": 0, + }, + ) + # Plus one memory at significance 1 to verify the re-rate is + # scoped to ``level_from`` and doesn't sweep the whole chat. + append_and_apply( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "pov_summary": "already-rated memory", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "significance": 1, + }, + ) + prior_manual_edits = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'manual_edit'" + ).fetchone()[0] + + response = client.post( + "/chats/chat_bot_a/drawer/memory/significance/bulk", + data={"level_from": "0", "level_to": "2"}, + ) + assert response.status_code == 200 + + with open_db(db) as conn: + # Five new manual_edit rows, one per matching memory. + new_manual_edits = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'manual_edit'" + ).fetchone()[0] + assert new_manual_edits - prior_manual_edits == 5 + + # Every emitted edit is a memory_significance edit with prior=0 + # and new=2. + import json as _json + + rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'manual_edit' " + "ORDER BY id DESC LIMIT 5" + ).fetchall() + for r in rows: + payload = _json.loads(r[0]) + assert payload["target_kind"] == "memory_significance" + assert payload["prior_value"] == 0 + assert payload["new_value"] == 2 + + # Projection caught up — five memories at sig=2, the untouched + # one stays at sig=1, none remain at sig=0. + dist = dict( + conn.execute( + "SELECT significance, COUNT(*) FROM memories " + "WHERE chat_id = 'chat_bot_a' GROUP BY significance" + ).fetchall() + ) + assert dist.get(0, 0) == 0 + assert dist.get(1, 0) == 1 + assert dist.get(2, 0) == 5 + + def test_delete_turn_with_event_id_zero_returns_400(client, tmp_path): """T110.1: ``event_id <= 0`` is an obvious client error and must NOT silently rewind the entire log via ``after_event_id = -1``. The route