feat: drawer edits with manual_edit event capture

This commit is contained in:
Joseph Doherty
2026-04-26 13:40:40 -04:00
parent 5fc5b8ac23
commit db3005fc17
5 changed files with 450 additions and 7 deletions
+150 -7
View File
@@ -1,21 +1,37 @@
"""Read-only chat drawer (T24).
"""Chat drawer — read view (T24) and inline edits (T25).
Returns an HTML partial rendered into the chat shell's `<aside id="drawer">`
on first reveal. Shows the current scene + container, per-entity activity,
host <-> you edges, pinned memories with an `n / cap` counter, and recent
witnessed memories from the host's POV with significance markers.
The GET endpoint renders an HTML partial showing the current scene +
container, per-entity activity, host <-> you edges, pinned memories with
an ``n / cap`` counter, and recent witnessed memories from the host's
POV with significance markers.
Edit affordances are added in T25; this endpoint is intentionally read-only.
T25 adds three POST endpoints for the most useful inline edits, each
returning the refreshed drawer partial so HTMX can swap it in:
* affinity slider on an edge (emits ``manual_edit``);
* significance dropdown on a memory (emits ``manual_edit``);
* pin toggle on a memory (emits ``memory_pin_changed`` with
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
Each ``manual_edit`` payload snapshots the prior value alongside the new
one so a later inverse edit can restore state (§6.4 final paragraph).
Other §6.4 editable fields (activity verb/attention/posture, edge_trust,
edge summary, knowledge_facts list, memory pov_summary) are deferred to
a Phase 1.5 follow-up — the dispatch in :mod:`chat.state.manual_edit`
already accepts more ``target_kind`` values, so adding their routes is a
mechanical extension.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
from chat.state.memory import get_pinned
@@ -104,3 +120,130 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
"pin_cap": PIN_CAP,
},
)
# --- T25 edit endpoints ---------------------------------------------------
#
# Each endpoint:
# 1. Loads the chat (404 if missing) and the target row (404 if missing).
# 2. Reads the prior value before mutating, so the event payload carries
# it for §6.4 reversibility.
# 3. Calls ``append_and_apply`` so the projected table updates atomically
# with the event log append; full reprojection would re-add deltas
# from earlier ``edge_update`` events.
# 4. Returns the refreshed drawer partial via ``await drawer(...)``, which
# HTMX swaps into ``#drawer``.
@router.post(
"/chats/{chat_id}/drawer/edge/{source_id}/{target_id}/affinity",
response_class=HTMLResponse,
)
async def edit_edge_affinity(
chat_id: str,
source_id: str,
target_id: str,
request: Request,
affinity: 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}")
edge = get_edge(conn, source_id, target_id)
if edge is None:
raise HTTPException(
status_code=404,
detail=f"edge not found: {source_id}->{target_id}",
)
prior = int(edge["affinity"])
new_value = max(0, min(100, int(affinity)))
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_affinity",
"target_id": {"source_id": source_id, "target_id": target_id},
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/memory/{memory_id}/significance",
response_class=HTMLResponse,
)
async def edit_memory_significance(
chat_id: str,
memory_id: int,
request: Request,
significance: 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}")
row = conn.execute(
"SELECT significance FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if row is None:
raise HTTPException(
status_code=404, detail=f"memory not found: {memory_id}"
)
prior = int(row[0])
new_value = max(0, min(3, int(significance)))
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_significance",
"target_id": int(memory_id),
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/memory/{memory_id}/pin",
response_class=HTMLResponse,
)
async def toggle_memory_pin(
chat_id: str,
memory_id: int,
request: Request,
pinned: 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}")
row = conn.execute(
"SELECT pinned FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if row is None:
raise HTTPException(
status_code=404, detail=f"memory not found: {memory_id}"
)
new_pinned = 1 if int(pinned) else 0
# Manual pin: ``auto_pinned=0`` so the §8.5 eviction query (which only
# touches auto-pinned rows) leaves this alone.
append_and_apply(
conn,
kind="memory_pin_changed",
payload={
"memory_id": int(memory_id),
"pinned": new_pinned,
"auto_pinned": 0,
},
)
return await drawer(chat_id, request, conn)