feat: drawer bulk significance re-rate per chat (T110.4)

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 ``<details>`` 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.
This commit is contained in:
Joseph Doherty
2026-04-27 05:14:59 -04:00
parent 5d5c888acf
commit 2ab8fcbdf0
3 changed files with 166 additions and 0 deletions
+19
View File
@@ -547,6 +547,25 @@
</ul>
</details>
{% 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. #}
<details class="bulk-significance">
<summary>Bulk re-rate significance</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/significance/bulk"
hx-target="#drawer" hx-swap="innerHTML">
<label>
From:
<input type="number" name="level_from" min="0" max="3" value="0" required>
</label>
<label>
To:
<input type="number" name="level_to" min="0" max="3" value="1" required>
</label>
<button type="submit">Re-rate all</button>
</form>
</details>
</section>
<section class="drawer-section">
+58
View File
@@ -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,
+89
View File
@@ -523,6 +523,95 @@ def test_delete_impact_modal_escapes_user_controllable_strings(client, tmp_path)
assert "&lt;script&gt;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