feat: drawer witness flag inline-edit (T72.3)
Memories grow per-flag witness checkboxes (you / host / guest) that
auto-submit on change via HTMX. The new POST route emits a manual_edit
event with target_kind=memory_witness and a {flag, value} payload;
prior_value mirrors the same shape so an inverse edit restores the
flag. The drawer's recent-memories query now selects the three
witness columns alongside the existing fields so the template can
render checkbox state without a second query per row.
This commit is contained in:
@@ -24,6 +24,12 @@ T72.1 (Phase 2.5) adds one list-shaped edit:
|
|||||||
by string equality so the route handler doesn't have to track fact
|
by string equality so the route handler doesn't have to track fact
|
||||||
indices across re-renders.
|
indices across re-renders.
|
||||||
|
|
||||||
|
T72.3 adds a per-flag witness toggle:
|
||||||
|
- ``memory_witness`` — flip one of ``witness_you`` / ``witness_host`` /
|
||||||
|
``witness_guest`` on a memory row. Payload's ``new_value`` is a dict
|
||||||
|
``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value``
|
||||||
|
mirrors the same shape so an inverse edit can restore the flag.
|
||||||
|
|
||||||
Pin toggles intentionally use the existing ``memory_pin_changed`` event
|
Pin toggles intentionally use the existing ``memory_pin_changed`` event
|
||||||
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
|
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
|
||||||
the projection writes both ``pinned`` and ``auto_pinned`` atomically.
|
the projection writes both ``pinned`` and ``auto_pinned`` atomically.
|
||||||
@@ -37,6 +43,8 @@ from sqlite3 import Connection
|
|||||||
from chat.eventlog.log import Event
|
from chat.eventlog.log import Event
|
||||||
from chat.eventlog.projector import on
|
from chat.eventlog.projector import on
|
||||||
|
|
||||||
|
_VALID_WITNESS_FLAGS = {"you", "host", "guest"}
|
||||||
|
|
||||||
|
|
||||||
def _clamp(value: int, lo: int, hi: int) -> int:
|
def _clamp(value: int, lo: int, hi: int) -> int:
|
||||||
return max(lo, min(hi, value))
|
return max(lo, min(hi, value))
|
||||||
@@ -120,5 +128,15 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
|
|||||||
target_id["target_id"],
|
target_id["target_id"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
elif kind == "memory_witness":
|
||||||
|
# T72.3: toggle one of the three witness flags on a memory row.
|
||||||
|
# ``new_value`` is the dict ``{"flag", "value"}``; ``prior_value``
|
||||||
|
# mirrors the same shape so an inverse edit restores the flag.
|
||||||
|
flag = new_value["flag"]
|
||||||
|
if flag in _VALID_WITNESS_FLAGS:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE memories SET witness_{flag} = ? WHERE id = ?",
|
||||||
|
(1 if int(new_value["value"]) else 0, int(target_id)),
|
||||||
|
)
|
||||||
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
||||||
# fields, etc.) extend the dispatch above.
|
# fields, etc.) extend the dispatch above.
|
||||||
|
|||||||
@@ -347,6 +347,23 @@
|
|||||||
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
|
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
|
||||||
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
|
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="witness-row">
|
||||||
|
{% for flag in ['you', 'host', 'guest'] %}
|
||||||
|
{% set witnessed = m['witness_' ~ flag] %}
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/memory/witness"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="memory_id" value="{{ m.id }}">
|
||||||
|
<input type="hidden" name="flag" value="{{ flag }}">
|
||||||
|
<input type="hidden" name="new_value" value="{{ 0 if witnessed else 1 }}">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {% if witnessed %}checked{% endif %}
|
||||||
|
onchange="this.form.requestSubmit()">
|
||||||
|
{{ flag }}
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
<details>
|
<details>
|
||||||
<summary>Edit POV summary</summary>
|
<summary>Edit POV summary</summary>
|
||||||
<form class="inline-edit"
|
<form class="inline-edit"
|
||||||
|
|||||||
+71
-2
@@ -125,10 +125,13 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
|
|
||||||
# Recent memories from host's POV (witness_host = 1), most recent first.
|
# Recent memories from host's POV (witness_host = 1), most recent first.
|
||||||
# Raw query keeps this read self-contained — no projector helper exposes
|
# Raw query keeps this read self-contained — no projector helper exposes
|
||||||
# "latest N for an owner" yet and the drawer is the only consumer.
|
# "latest N for an owner" yet and the drawer is the only consumer. The
|
||||||
|
# three witness flags ride along so T72.3's per-row checkboxes can
|
||||||
|
# render the current state without a second query per memory.
|
||||||
recent_rows = conn.execute(
|
recent_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, pov_summary, significance, pinned, created_at
|
SELECT id, pov_summary, significance, pinned, created_at,
|
||||||
|
witness_you, witness_host, witness_guest
|
||||||
FROM memories
|
FROM memories
|
||||||
WHERE owner_id = ? AND witness_host = 1
|
WHERE owner_id = ? AND witness_host = 1
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
@@ -143,6 +146,9 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
"significance": r[2],
|
"significance": r[2],
|
||||||
"pinned": r[3],
|
"pinned": r[3],
|
||||||
"created_at": r[4],
|
"created_at": r[4],
|
||||||
|
"witness_you": r[5],
|
||||||
|
"witness_host": r[6],
|
||||||
|
"witness_guest": r[7],
|
||||||
}
|
}
|
||||||
for r in recent_rows
|
for r in recent_rows
|
||||||
]
|
]
|
||||||
@@ -572,6 +578,69 @@ async def edit_edge_knowledge_facts(
|
|||||||
return await drawer(chat_id, request, conn)
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
# --- T72.3 witness flag inline-edit --------------------------------------
|
||||||
|
#
|
||||||
|
# Witness flags decide which entities can recall a memory (§7 retrieval).
|
||||||
|
# Editing them is rare but high-impact — flipping ``witness_guest`` from 0
|
||||||
|
# to 1 makes the memory available to the guest's prompt context. The route
|
||||||
|
# follows the T25 / T72.1 pattern: snapshot prior, append + apply
|
||||||
|
# ``manual_edit`` with a ``{flag, value}`` payload, refresh the partial.
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_WITNESS_FLAGS = ("you", "host", "guest")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/memory/witness",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def edit_memory_witness(
|
||||||
|
chat_id: str,
|
||||||
|
request: Request,
|
||||||
|
memory_id: int = Form(...),
|
||||||
|
flag: str = Form(...),
|
||||||
|
new_value: int = Form(...),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
if flag not in _VALID_WITNESS_FLAGS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"flag must be one of {list(_VALID_WITNESS_FLAGS)}, "
|
||||||
|
f"got {flag!r}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT witness_{flag} FROM memories "
|
||||||
|
"WHERE id = ? AND chat_id = ?",
|
||||||
|
(int(memory_id), chat_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"memory not found in chat: {memory_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
prior_int = int(row[0])
|
||||||
|
new_int = 1 if int(new_value) else 0
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="manual_edit",
|
||||||
|
payload={
|
||||||
|
"target_kind": "memory_witness",
|
||||||
|
"target_id": int(memory_id),
|
||||||
|
"prior_value": {"flag": flag, "value": prior_int},
|
||||||
|
"new_value": {"flag": flag, "value": new_int},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
# --- T42 guest add/remove -------------------------------------------------
|
# --- T42 guest add/remove -------------------------------------------------
|
||||||
#
|
#
|
||||||
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
|
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""T72.1: deferred v1 drawer edits.
|
"""T72: deferred v1 drawer edits + witness flag inline-edit.
|
||||||
|
|
||||||
T25 shipped affinity / significance / pin. T72.1 fills in the rest of the
|
T25 shipped affinity / significance / pin. T72.1 fills in the rest of the
|
||||||
§6.4 editable surface whose ``manual_edit`` projector dispatch was already
|
§6.4 editable surface whose ``manual_edit`` projector dispatch was already
|
||||||
@@ -9,11 +9,14 @@ in place (or, for ``edge_knowledge_fact``, added alongside the route):
|
|||||||
* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped.
|
* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped.
|
||||||
* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact.
|
* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact.
|
||||||
|
|
||||||
|
T72.3 layers a witness-flag toggle on top:
|
||||||
|
|
||||||
|
* ``POST /chats/{chat_id}/drawer/memory/witness`` — ``manual_edit`` with
|
||||||
|
``target_kind`` = ``memory_witness`` and a ``{flag, value}`` payload.
|
||||||
|
|
||||||
Each test asserts (a) the ``manual_edit`` event lands in the log,
|
Each test asserts (a) the ``manual_edit`` event lands in the log,
|
||||||
(b) the projected table reflects the new value, and (c) the response is
|
(b) the projected table reflects the new value, and (c) the response is
|
||||||
the refreshed drawer partial.
|
the refreshed drawer partial.
|
||||||
|
|
||||||
T72.3's witness-flag tests extend this file with the inline-edit pair.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -325,3 +328,76 @@ def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- T72.3 tests (witness flag inline-edit) -------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_witness_flag_toggle_updates_memory_row(client, tmp_path):
|
||||||
|
"""Memory seeded with witness [you=1, host=1, guest=0]; toggling
|
||||||
|
``guest`` to 1 lands as ``witness_guest = 1`` after projection.
|
||||||
|
"""
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/witness",
|
||||||
|
data={
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"flag": "guest",
|
||||||
|
"new_value": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT witness_you, witness_host, witness_guest "
|
||||||
|
"FROM memories WHERE id = ?",
|
||||||
|
(memory_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row == (1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_witness_flag_toggle_emits_manual_edit_event(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/witness",
|
||||||
|
data={
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"flag": "guest",
|
||||||
|
"new_value": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
payload = json.loads(rows[0][0])
|
||||||
|
assert payload["target_kind"] == "memory_witness"
|
||||||
|
assert payload["target_id"] == memory_id
|
||||||
|
assert payload["prior_value"] == {"flag": "guest", "value": 0}
|
||||||
|
assert payload["new_value"] == {"flag": "guest", "value": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_witness_flag_toggle_400_on_bad_flag(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/witness",
|
||||||
|
data={
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"flag": "narrator",
|
||||||
|
"new_value": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user