feat: drawer edits for edge_trust / edge_summary / memory_pov_summary / knowledge_facts (T72.1)
Adds the four POST routes whose state-layer support was already dispatched by the manual_edit projector (edge_trust, edge_summary, memory_pov_summary) plus a new edge_knowledge_fact dispatch branch for add/remove fact list manipulation. Drawer template gains editable textareas, sliders, and add/remove fact controls. Remove semantics on knowledge_fact match by string (not index) so concurrent edge_update events appending facts between drawer renders don't desync the form.
This commit is contained in:
+228
-7
@@ -1,4 +1,4 @@
|
||||
"""Chat drawer — read view (T24) and inline edits (T25).
|
||||
"""Chat drawer — read view (T24) and inline edits (T25, T72).
|
||||
|
||||
The GET endpoint renders an HTML partial showing the current scene +
|
||||
container, per-entity activity, host <-> you edges, pinned memories with
|
||||
@@ -13,14 +13,16 @@ returning the refreshed drawer partial so HTMX can swap it in:
|
||||
* pin toggle on a memory (emits ``memory_pin_changed`` with
|
||||
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
|
||||
|
||||
T72 (Phase 2.5) extends the inline-edit set to cover the remaining
|
||||
§6.4 editable fields whose state-layer support already lands in the
|
||||
``manual_edit`` projector: edge trust slider, edge summary textarea,
|
||||
memory POV summary textarea, and per-edge knowledge-fact add/remove. It
|
||||
also exposes a witness-flag toggle (``you/host/guest``) per memory row
|
||||
and a "first-meeting gate" on the Add-guest form so an existing edge
|
||||
isn't quietly overwritten by a re-seed.
|
||||
|
||||
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
|
||||
@@ -55,6 +57,13 @@ PIN_CAP = 8
|
||||
# Recent-memories list is bounded to keep the drawer cheap to render.
|
||||
RECENT_LIMIT = 10
|
||||
|
||||
# T72.1 caps on free-form textarea edits. Edge summaries and per-POV
|
||||
# memory summaries are drawer-driven prose — bound them so a stray paste
|
||||
# can't blow up the projected row size or the SSE drawer refresh payload.
|
||||
EDGE_SUMMARY_MAX = 2000
|
||||
MEMORY_POV_SUMMARY_MAX = 2000
|
||||
KNOWLEDGE_FACT_MAX = 500
|
||||
|
||||
|
||||
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
|
||||
async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
@@ -342,6 +351,218 @@ async def toggle_memory_pin(
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T72.1 deferred v1 drawer edits --------------------------------------
|
||||
#
|
||||
# These four endpoints round out the §6.4 editable surface — the
|
||||
# ``manual_edit`` projector already dispatches ``edge_trust``,
|
||||
# ``edge_summary``, and ``memory_pov_summary`` (T25); ``edge_knowledge_fact``
|
||||
# is a new dispatch branch added alongside this commit. Each route follows
|
||||
# the T25 pattern: snapshot the prior value, append + apply ``manual_edit``,
|
||||
# then re-render the drawer partial.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/edge/trust",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_edge_trust(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
source_id: str = Form(...),
|
||||
target_id: str = Form(...),
|
||||
new_value: 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}")
|
||||
|
||||
if not 0 <= int(new_value) <= 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"trust must be in [0, 100], got {new_value}",
|
||||
)
|
||||
|
||||
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["trust"])
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "edge_trust",
|
||||
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||
"prior_value": prior,
|
||||
"new_value": int(new_value),
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/edge/summary",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_edge_summary(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
source_id: str = Form(...),
|
||||
target_id: str = Form(...),
|
||||
new_summary: str = 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}")
|
||||
|
||||
if len(new_summary) > EDGE_SUMMARY_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"edge summary exceeds {EDGE_SUMMARY_MAX} chars "
|
||||
f"(got {len(new_summary)})"
|
||||
),
|
||||
)
|
||||
|
||||
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 = edge.get("summary") or ""
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "edge_summary",
|
||||
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||
"prior_value": prior,
|
||||
"new_value": new_summary,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/memory/pov-summary",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_memory_pov_summary(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
memory_id: int = Form(...),
|
||||
new_summary: str = 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}")
|
||||
|
||||
if len(new_summary) > MEMORY_POV_SUMMARY_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"memory pov_summary exceeds {MEMORY_POV_SUMMARY_MAX} chars "
|
||||
f"(got {len(new_summary)})"
|
||||
),
|
||||
)
|
||||
|
||||
# 404 when the memory either doesn't exist or belongs to a different
|
||||
# chat — the drawer never surfaces cross-chat memories so editing one
|
||||
# would be a path-traversal-style mistake.
|
||||
row = conn.execute(
|
||||
"SELECT pov_summary FROM memories WHERE id = ? AND chat_id = ?",
|
||||
(int(memory_id), chat_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"memory not found in chat: {memory_id}",
|
||||
)
|
||||
|
||||
prior = row[0] or ""
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "memory_pov_summary",
|
||||
"target_id": int(memory_id),
|
||||
"prior_value": prior,
|
||||
"new_value": new_summary,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/edge/knowledge-facts",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_edge_knowledge_facts(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
source_id: str = Form(...),
|
||||
target_id: str = Form(...),
|
||||
action: str = Form(...),
|
||||
fact: str = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Add or remove a single knowledge_fact on an edge.
|
||||
|
||||
Remove semantics are by string match (first occurrence) — the drawer
|
||||
re-renders after every edit so threading a stable index through is
|
||||
fragile when concurrent ``edge_update`` events can append more facts
|
||||
between renders. The projector is a no-op when the fact isn't found,
|
||||
keeping the route idempotent for stale form submissions.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
if action not in ("add", "remove"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"action must be 'add' or 'remove', got {action!r}",
|
||||
)
|
||||
|
||||
if len(fact) > KNOWLEDGE_FACT_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"fact exceeds {KNOWLEDGE_FACT_MAX} chars (got {len(fact)})"
|
||||
),
|
||||
)
|
||||
if not fact.strip():
|
||||
raise HTTPException(status_code=400, detail="fact must not be empty")
|
||||
|
||||
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 = list(edge.get("knowledge") or [])
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "edge_knowledge_fact",
|
||||
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||
"prior_value": prior,
|
||||
"new_value": {"action": action, "fact": fact},
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T42 guest add/remove -------------------------------------------------
|
||||
#
|
||||
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
|
||||
|
||||
Reference in New Issue
Block a user