307 lines
9.7 KiB
Python
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)
|