feat: drawer surgical delete with cascade preview (T98.4)
This commit is contained in:
@@ -1211,6 +1211,93 @@ async def switch_branch(
|
|||||||
return await drawer(chat_id, request, conn)
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/chats/{chat_id}/drawer/turn/delete-preview/{event_id}",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def delete_preview(
|
||||||
|
chat_id: str,
|
||||||
|
event_id: int,
|
||||||
|
request: Request,
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
"""Render an :class:`ImpactReport` for ``event_id`` as a small modal.
|
||||||
|
|
||||||
|
Read-only — :func:`compute_delete_impact` does not mutate the
|
||||||
|
database. The modal contains a confirmation form posting to
|
||||||
|
:func:`delete_turn` below; HTMX swaps the fragment into a modal
|
||||||
|
target on the chat page.
|
||||||
|
"""
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
report = compute_delete_impact(conn, target_event_id=int(event_id))
|
||||||
|
|
||||||
|
# Build the modal HTML directly — the impact report is small and
|
||||||
|
# reusing the drawer template would require a fragment include just
|
||||||
|
# for this surface. Mirrors the rewind-preview style in
|
||||||
|
# :func:`chat.web.turns.rewind_preview`.
|
||||||
|
items_html = "".join(
|
||||||
|
f"<li><strong>{item.kind}</strong>: {item.description}</li>"
|
||||||
|
for item in report.cascading
|
||||||
|
)
|
||||||
|
notes_html = "".join(f"<li>{note}</li>" for note in report.notes)
|
||||||
|
body = (
|
||||||
|
"<div class='delete-impact-modal'>"
|
||||||
|
f"<h3>Delete event {report.target_event_id}?</h3>"
|
||||||
|
f"<p>This will discard {len(report.cascading)} events. Cascade:</p>"
|
||||||
|
f"<ul class='delete-impact-cascade'>{items_html or '<li>none</li>'}</ul>"
|
||||||
|
f"<ul class='delete-impact-notes'>{notes_html}</ul>"
|
||||||
|
f"<form hx-post='/chats/{chat_id}/drawer/turn/delete/{report.target_event_id}' "
|
||||||
|
"hx-target='#drawer' hx-swap='innerHTML'>"
|
||||||
|
"<button type='submit'>Confirm delete</button>"
|
||||||
|
"</form>"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
return HTMLResponse(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/turn/delete/{event_id}",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def delete_turn(
|
||||||
|
chat_id: str,
|
||||||
|
event_id: int,
|
||||||
|
request: Request,
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
"""Delete a turn (and everything after) by invoking the existing rewind path.
|
||||||
|
|
||||||
|
The :func:`chat.services.rewind.execute_rewind` API takes
|
||||||
|
``after_event_id``: it removes events with id strictly greater than
|
||||||
|
that argument. To make ``event_id`` itself disappear we pass
|
||||||
|
``after_event_id = event_id - 1`` — a thin adapter, not a
|
||||||
|
re-implementation of rewind.
|
||||||
|
|
||||||
|
A snapshot is taken before truncation (inside ``execute_rewind``)
|
||||||
|
so the user can recover via the snapshot index.
|
||||||
|
"""
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
execute_rewind(
|
||||||
|
db_path=settings.db_path,
|
||||||
|
data_dir=settings.data_dir,
|
||||||
|
after_event_id=int(event_id) - 1,
|
||||||
|
)
|
||||||
|
# ``conn`` is now stale (the rewind opened its own connection and
|
||||||
|
# truncated/reprojected). Re-render the drawer through a fresh open
|
||||||
|
# so the partial reflects the truncated state.
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
|
||||||
|
with open_db(settings.db_path) as fresh:
|
||||||
|
return await drawer(chat_id, request, fresh)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/chats/{chat_id}/drawer/turn/hide/{event_id}",
|
"/chats/{chat_id}/drawer/turn/hide/{event_id}",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
|
|||||||
@@ -383,3 +383,76 @@ def test_t98_3_hidden_turn_disappears_from_read_recent_dialogue(
|
|||||||
after_ids = [t["event_id"] for t in after]
|
after_ids = [t["event_id"] for t in after]
|
||||||
assert user_id not in after_ids
|
assert user_id not in after_ids
|
||||||
assert bot_id in after_ids # the unhidden bot turn still surfaces
|
assert bot_id in after_ids # the unhidden bot turn still surfaces
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T98.4 — surgical delete with cascade preview.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_t98_4_delete_preview_returns_impact_report_html(client, tmp_path):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
_seed_chat(db)
|
||||||
|
user_id, bot_id = _seed_turns(db)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/chats/chat_bot_a/drawer/turn/delete-preview/{user_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
|
||||||
|
# Modal markup with the event id and the cascade list.
|
||||||
|
assert "delete-impact-modal" in body
|
||||||
|
assert f"Delete event {user_id}?" in body
|
||||||
|
assert "delete-impact-cascade" in body
|
||||||
|
# Both turns ride along in the cascade — user_turn at user_id, then
|
||||||
|
# the assistant_turn at bot_id (>= user_id).
|
||||||
|
assert "user_turn" in body
|
||||||
|
assert "assistant_turn" in body
|
||||||
|
# Confirm-form posts to the delete route.
|
||||||
|
assert f"/drawer/turn/delete/{user_id}" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
_seed_chat(db)
|
||||||
|
user_id, bot_id = _seed_turns(db)
|
||||||
|
|
||||||
|
# Append a third turn after the assistant_turn so we can verify the
|
||||||
|
# cascade catches everything from user_id forward.
|
||||||
|
with open_db(db) as conn:
|
||||||
|
extra_id = append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="user_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"prose": "follow-up",
|
||||||
|
"segments": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sanity: all three turn rows exist.
|
||||||
|
with open_db(db) as conn:
|
||||||
|
turn_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_log "
|
||||||
|
"WHERE kind IN ('user_turn', 'assistant_turn')"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert turn_count == 3
|
||||||
|
|
||||||
|
# Delete from user_id forward.
|
||||||
|
response = client.post(f"/chats/chat_bot_a/drawer/turn/delete/{user_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# All three turns are gone — the rewind truncated the log past
|
||||||
|
# user_id - 1, removing user_id, bot_id, and extra_id.
|
||||||
|
with open_db(db) as conn:
|
||||||
|
turn_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_log "
|
||||||
|
"WHERE kind IN ('user_turn', 'assistant_turn')"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert turn_count == 0
|
||||||
|
for ev_id in (user_id, bot_id, extra_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM event_log WHERE id = ?", (ev_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row is None, f"event {ev_id} should have been deleted"
|
||||||
|
|||||||
Reference in New Issue
Block a user