Files
chat/chat/web/drawer.py
T

307 lines
9.7 KiB
Python

"""Chat drawer — read view (T24) and inline edits (T25).
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.
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, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply
from chat.services.scene_summarize import apply_scene_close_summary
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
from chat.state.memory import get_pinned
from chat.state.world import active_scene, get_activity, get_chat, get_container
from chat.web.bots import get_conn
from chat.web.kickoff import get_llm_client
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
# Soft cap on pinned memories per owner (§8.5). Surfaced in the drawer header
# as `pinned|length / pin_cap`; eviction logic itself lives in T22.
PIN_CAP = 8
# Recent-memories list is bounded to keep the drawer cheap to render.
RECENT_LIMIT = 10
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
async def drawer(chat_id: str, request: Request, 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}")
host_bot = get_bot(conn, chat["host_bot_id"])
if host_bot is None:
raise HTTPException(
status_code=404, detail=f"host bot not found: {chat['host_bot_id']}"
)
you_entity = get_you(conn) or {"name": "you", "pronouns": "", "persona": ""}
scene = active_scene(conn, chat_id)
container = None
if scene and scene.get("container_id") is not None:
container = get_container(conn, scene["container_id"])
you_activity = get_activity(conn, "you")
bot_activity = get_activity(conn, chat["host_bot_id"])
edge_b2y = get_edge(conn, chat["host_bot_id"], "you")
edge_y2b = get_edge(conn, "you", chat["host_bot_id"])
# Recent memories from host's POV (witness_host = 1), most recent first.
# Raw query keeps this read self-contained — no projector helper exposes
# "latest N for an owner" yet and the drawer is the only consumer.
recent_rows = conn.execute(
"""
SELECT id, pov_summary, significance, pinned, created_at
FROM memories
WHERE owner_id = ? AND witness_host = 1
ORDER BY id DESC
LIMIT ?
""",
(chat["host_bot_id"], RECENT_LIMIT),
).fetchall()
recent_memories = [
{
"id": r[0],
"pov_summary": r[1],
"significance": r[2],
"pinned": r[3],
"created_at": r[4],
}
for r in recent_rows
]
pinned = get_pinned(conn, chat["host_bot_id"])
return TEMPLATES.TemplateResponse(
request,
"_drawer.html",
{
"chat": chat,
"host_bot": host_bot,
"you_entity": you_entity,
"scene": scene,
"container": container,
"you_activity": you_activity,
"bot_activity": bot_activity,
"edge_b2y": edge_b2y,
"edge_y2b": edge_y2b,
"recent_memories": recent_memories,
"pinned": pinned,
"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/scene/close",
response_class=HTMLResponse,
)
async def close_scene_manual(
chat_id: str,
request: Request,
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
"""Manual scene close from the drawer button.
Always available when there's an active scene; mirrors the auto-close
path in the turn flow but bypasses the hard-signal classifier. After
emitting ``scene_closed`` we run the T27 per-POV summary pipeline
(one classifier call) so the manual path produces the same memory /
edge updates as the auto path. Returns the refreshed drawer partial
so HTMX swaps it in. ``400`` when no scene is active — the button is
hidden in that state but a stale tab might still POST.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
scene = active_scene(conn, chat_id)
if scene is None:
raise HTTPException(
status_code=400, detail="no active scene to close"
)
append_and_apply(
conn,
kind="scene_closed",
payload={
"scene_id": scene["id"],
"ended_at": chat.get("time"),
# Significance defaults to 0; T22's significance worker
# operates on memories, not scenes.
"significance": 0,
},
)
settings = request.app.state.settings
await apply_scene_close_summary(
conn,
client,
classifier_model=settings.classifier_model,
chat_id=chat_id,
scene_id=scene["id"],
host_bot_id=chat["host_bot_id"],
timeout_s=settings.classifier_timeout_s,
)
return await drawer(chat_id, request, conn)
@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)