feat: drawer hide-from-view toggle + turn_hidden manual_edit branch (T98.3)
This commit is contained in:
@@ -30,6 +30,14 @@ T72.3 adds a per-flag witness toggle:
|
|||||||
``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value``
|
``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value``
|
||||||
mirrors the same shape so an inverse edit can restore the flag.
|
mirrors the same shape so an inverse edit can restore the flag.
|
||||||
|
|
||||||
|
T98.3 adds a hide-from-view toggle:
|
||||||
|
- ``turn_hidden`` — flip ``event_log.hidden`` on a single turn row.
|
||||||
|
Hidden turns are filtered by ``read_recent_dialogue`` (see
|
||||||
|
:mod:`chat.services.turn_common`) so they vanish from the prompt
|
||||||
|
without being deleted from the log. ``target_id`` is the integer
|
||||||
|
``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}``
|
||||||
|
and ``prior_value`` mirrors the shape so an inverse edit restores it.
|
||||||
|
|
||||||
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.
|
||||||
@@ -138,5 +146,16 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
|
|||||||
f"UPDATE memories SET witness_{flag} = ? WHERE id = ?",
|
f"UPDATE memories SET witness_{flag} = ? WHERE id = ?",
|
||||||
(1 if int(new_value["value"]) else 0, int(target_id)),
|
(1 if int(new_value["value"]) else 0, int(target_id)),
|
||||||
)
|
)
|
||||||
|
elif kind == "turn_hidden":
|
||||||
|
# T98.3: hide-from-view toggle on a turn (event_log row). Sets
|
||||||
|
# ``event_log.hidden`` so :func:`read_recent_dialogue` (which
|
||||||
|
# filters ``hidden = 0``) drops the row from the prompt window
|
||||||
|
# without deleting it from the log. ``new_value`` is
|
||||||
|
# ``{"hidden": 0|1}``.
|
||||||
|
hidden_int = 1 if int(new_value.get("hidden", 0)) else 0
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE event_log SET hidden = ? WHERE id = ?",
|
||||||
|
(hidden_int, 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.
|
||||||
|
|||||||
@@ -456,6 +456,33 @@
|
|||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Recent turns</h3>
|
||||||
|
{% if recent_turns %}
|
||||||
|
<ul class="recent-turns-list">
|
||||||
|
{% for t in recent_turns %}
|
||||||
|
<li class="turn-row{% if t.hidden %} turn-hidden{% endif %}">
|
||||||
|
<span class="muted">#{{ t.event_id }} {{ t.kind }}</span>
|
||||||
|
<strong>{{ t.speaker }}:</strong>
|
||||||
|
{{ t.excerpt }}{% if t.excerpt|length >= 120 %}…{% endif %}
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/turn/hide/{{ t.event_id }}"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="hidden" value="{{ 0 if t.hidden else 1 }}">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {% if t.hidden %}checked{% endif %}
|
||||||
|
onchange="this.form.requestSubmit()">
|
||||||
|
hide from view
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No turns yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section">
|
<section class="drawer-section">
|
||||||
<h3>Significance review</h3>
|
<h3>Significance review</h3>
|
||||||
{% set total_mem = significance_distribution.values()|sum %}
|
{% set total_mem = significance_distribution.values()|sum %}
|
||||||
|
|||||||
@@ -176,6 +176,43 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
active_events = list_active_events(conn, chat_id)
|
active_events = list_active_events(conn, chat_id)
|
||||||
open_threads = list_open_threads(conn, chat_id)
|
open_threads = list_open_threads(conn, chat_id)
|
||||||
|
|
||||||
|
# T98.3: recent turns (user_turn / assistant_turn) for the hide-from-view
|
||||||
|
# panel. Includes ``hidden`` rows so the user can un-hide them — the
|
||||||
|
# filter on the read side (read_recent_dialogue) is what drops hidden
|
||||||
|
# rows from the prompt; the drawer panel always shows everything.
|
||||||
|
turn_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, kind, payload_json, hidden
|
||||||
|
FROM event_log
|
||||||
|
WHERE kind IN ('user_turn', 'assistant_turn', 'user_turn_edit')
|
||||||
|
AND superseded_by IS NULL
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(RECENT_LIMIT,),
|
||||||
|
).fetchall()
|
||||||
|
recent_turns: list[dict] = []
|
||||||
|
for row in turn_rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(row[2]) if row[2] else {}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
payload = {}
|
||||||
|
if payload.get("chat_id") != chat_id:
|
||||||
|
continue
|
||||||
|
text = payload.get("prose") or payload.get("text") or ""
|
||||||
|
speaker = payload.get("speaker_id") or (
|
||||||
|
"you" if row[1].startswith("user") else "?"
|
||||||
|
)
|
||||||
|
recent_turns.append(
|
||||||
|
{
|
||||||
|
"event_id": int(row[0]),
|
||||||
|
"kind": row[1],
|
||||||
|
"speaker": speaker,
|
||||||
|
"excerpt": (text or "").replace("\n", " ")[:120],
|
||||||
|
"hidden": bool(row[3]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# T98.1: branch metadata (every chat sees the global branch list — branches
|
# T98.1: branch metadata (every chat sees the global branch list — branches
|
||||||
# may be chat-scoped or global, so :func:`list_branches_with_metadata`
|
# may be chat-scoped or global, so :func:`list_branches_with_metadata`
|
||||||
# returns both flavours and the template highlights the active one).
|
# returns both flavours and the template highlights the active one).
|
||||||
@@ -225,6 +262,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
"open_threads": open_threads,
|
"open_threads": open_threads,
|
||||||
"branches": branches,
|
"branches": branches,
|
||||||
"significance_distribution": significance_distribution,
|
"significance_distribution": significance_distribution,
|
||||||
|
"recent_turns": recent_turns,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1173,6 +1211,66 @@ async def switch_branch(
|
|||||||
return await drawer(chat_id, request, conn)
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/turn/hide/{event_id}",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def hide_turn(
|
||||||
|
chat_id: str,
|
||||||
|
event_id: int,
|
||||||
|
request: Request,
|
||||||
|
hidden: int = Form(...),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
"""Toggle ``event_log.hidden`` on a turn via the ``turn_hidden``
|
||||||
|
``manual_edit`` projector branch.
|
||||||
|
|
||||||
|
The route validates the target is an actual turn-shaped row in this
|
||||||
|
chat (so a stray click on the chat panel can't hide a system event)
|
||||||
|
and snapshots the prior ``hidden`` value for §6.4 reversibility.
|
||||||
|
"""
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT kind, payload_json, hidden FROM event_log WHERE id = ?",
|
||||||
|
(int(event_id),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"event not found: {event_id}"
|
||||||
|
)
|
||||||
|
if row[0] not in ("user_turn", "assistant_turn", "user_turn_edit"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"event {event_id} is not a turn (kind={row[0]})",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = json.loads(row[1]) if row[1] else {}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
payload = {}
|
||||||
|
if payload.get("chat_id") != chat_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"event {event_id} not in chat {chat_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
prior_hidden = 1 if int(row[2]) else 0
|
||||||
|
new_hidden = 1 if int(hidden) else 0
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="manual_edit",
|
||||||
|
payload={
|
||||||
|
"target_kind": "turn_hidden",
|
||||||
|
"target_id": int(event_id),
|
||||||
|
"prior_value": {"hidden": prior_hidden},
|
||||||
|
"new_value": {"hidden": new_hidden},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
|
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
|
|||||||
@@ -280,3 +280,106 @@ def test_t98_2_edit_significance_via_existing_route_lands_manual_edit(
|
|||||||
assert int(payload["target_id"]) == target_id
|
assert int(payload["target_id"]) == target_id
|
||||||
assert payload["prior_value"] == 0
|
assert payload["prior_value"] == 0
|
||||||
assert payload["new_value"] == 3
|
assert payload["new_value"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T98.3 — hide-from-view toggle.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_turns(db: Path) -> tuple[int, int]:
|
||||||
|
"""Append one user_turn + one assistant_turn; return their event ids."""
|
||||||
|
with open_db(db) as conn:
|
||||||
|
user_id = append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="user_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"prose": "How are you doing today?",
|
||||||
|
"segments": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bot_id = append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="assistant_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"speaker_id": "bot_a",
|
||||||
|
"text": "Quite well, thanks for asking!",
|
||||||
|
"truncated": False,
|
||||||
|
"user_turn_id": user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return user_id, bot_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_t98_3_hide_turn_flips_event_log_hidden_via_manual_edit(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
_seed_chat(db)
|
||||||
|
user_id, bot_id = _seed_turns(db)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/chats/chat_bot_a/drawer/turn/hide/{user_id}",
|
||||||
|
data={"hidden": "1"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(db) as conn:
|
||||||
|
# event_log.hidden flipped to 1.
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT hidden FROM event_log WHERE id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert int(row[0]) == 1
|
||||||
|
|
||||||
|
# manual_edit landed with the prior snapshot.
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
log = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log "
|
||||||
|
"WHERE kind = 'manual_edit' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
payload = _json.loads(log[0])
|
||||||
|
assert payload["target_kind"] == "turn_hidden"
|
||||||
|
assert int(payload["target_id"]) == user_id
|
||||||
|
assert payload["prior_value"] == {"hidden": 0}
|
||||||
|
assert payload["new_value"] == {"hidden": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_t98_3_hidden_turn_disappears_from_read_recent_dialogue(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
"""Hiding a turn must drop it from the prompt-window read.
|
||||||
|
|
||||||
|
``read_recent_dialogue`` (chat.services.turn_common) filters
|
||||||
|
``hidden = 0`` server-side, so flipping the flag via the drawer
|
||||||
|
route must surface immediately.
|
||||||
|
"""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
_seed_chat(db)
|
||||||
|
user_id, bot_id = _seed_turns(db)
|
||||||
|
|
||||||
|
# Sanity baseline — both turns visible before the hide.
|
||||||
|
with open_db(db) as conn:
|
||||||
|
from chat.services.turn_common import read_recent_dialogue
|
||||||
|
|
||||||
|
before = read_recent_dialogue(conn, "chat_bot_a", limit=10)
|
||||||
|
before_ids = [t["event_id"] for t in before]
|
||||||
|
assert user_id in before_ids
|
||||||
|
assert bot_id in before_ids
|
||||||
|
|
||||||
|
# Hide the user turn via the drawer route.
|
||||||
|
response = client.post(
|
||||||
|
f"/chats/chat_bot_a/drawer/turn/hide/{user_id}",
|
||||||
|
data={"hidden": "1"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(db) as conn:
|
||||||
|
from chat.services.turn_common import read_recent_dialogue
|
||||||
|
|
||||||
|
after = read_recent_dialogue(conn, "chat_bot_a", limit=10)
|
||||||
|
after_ids = [t["event_id"] for t in after]
|
||||||
|
assert user_id not in after_ids
|
||||||
|
assert bot_id in after_ids # the unhidden bot turn still surfaces
|
||||||
|
|||||||
Reference in New Issue
Block a user