21404a373b
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.
748 lines
24 KiB
Python
748 lines
24 KiB
Python
"""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
|
|
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).
|
|
|
|
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).
|
|
"""
|
|
|
|
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.relationship_seed import seed_inter_bot_edges
|
|
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, list_bots
|
|
from chat.state.group_node import get_group_node
|
|
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
|
|
|
|
# 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)):
|
|
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"])
|
|
|
|
# T42: guest + group context. Empty defaults keep the template happy
|
|
# when no guest is present (the relevant sections render conditionally).
|
|
guest_bot = None
|
|
guest_activity = None
|
|
edge_h2g = None
|
|
edge_g2h = None
|
|
edge_y2g = None
|
|
edge_g2y = None
|
|
available_guests: list[dict] = []
|
|
group_node = None
|
|
if chat.get("guest_bot_id"):
|
|
guest_bot_id = chat["guest_bot_id"]
|
|
guest_bot = get_bot(conn, guest_bot_id)
|
|
guest_activity = get_activity(conn, guest_bot_id)
|
|
edge_h2g = get_edge(conn, chat["host_bot_id"], guest_bot_id)
|
|
edge_g2h = get_edge(conn, guest_bot_id, chat["host_bot_id"])
|
|
edge_y2g = get_edge(conn, "you", guest_bot_id)
|
|
edge_g2y = get_edge(conn, guest_bot_id, "you")
|
|
else:
|
|
# Candidates for the "Add guest" dropdown — every authored bot
|
|
# except the host (and "you", which is implicit, never a bot row).
|
|
available_guests = [
|
|
b for b in list_bots(conn) if b["id"] != chat["host_bot_id"]
|
|
]
|
|
group_node = get_group_node(conn, chat_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,
|
|
"guest_bot": guest_bot,
|
|
"guest_activity": guest_activity,
|
|
"edge_h2g": edge_h2g,
|
|
"edge_g2h": edge_g2h,
|
|
"edge_y2g": edge_y2g,
|
|
"edge_g2y": edge_g2y,
|
|
"available_guests": available_guests,
|
|
"group_node": group_node,
|
|
"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)
|
|
|
|
|
|
# --- 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
|
|
# ``chats.guest_bot_id``, two ``edge_update`` events seeded from the
|
|
# user-supplied prose (skipped when the prose is empty / the seed comes back
|
|
# default), and a ``group_node_initialized`` if no row exists yet — three
|
|
# entities now share the chat so the §8.4 group node becomes meaningful.
|
|
#
|
|
# Removing a guest first emits ``scene_closed`` for the active scene (so any
|
|
# host -> you scene closes cleanly with the guest still in scope) before
|
|
# clearing the guest_bot_id; per spec the next user message implicitly opens
|
|
# a fresh you+host scene via Phase 1's mid-chat reset behavior.
|
|
|
|
|
|
def _seed_is_default(seed) -> bool:
|
|
"""Treat a seed as a no-op when both summaries are empty AND both
|
|
delta pairs are zero AND both fact lists are empty.
|
|
"""
|
|
return (
|
|
not seed.a_to_b_summary
|
|
and not seed.b_to_a_summary
|
|
and seed.a_to_b_affinity_delta == 0
|
|
and seed.a_to_b_trust_delta == 0
|
|
and seed.b_to_a_affinity_delta == 0
|
|
and seed.b_to_a_trust_delta == 0
|
|
and not seed.a_to_b_knowledge_facts
|
|
and not seed.b_to_a_knowledge_facts
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/guest/add",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def add_guest(
|
|
chat_id: str,
|
|
request: Request,
|
|
guest_bot_id: str = Form(...),
|
|
relationship_prose: str = Form(""),
|
|
conn=Depends(get_conn),
|
|
client=Depends(get_llm_client),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if chat.get("guest_bot_id") is not None:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="a guest is already present in this chat",
|
|
)
|
|
|
|
if guest_bot_id == chat["host_bot_id"]:
|
|
raise HTTPException(
|
|
status_code=400, detail="guest must differ from host"
|
|
)
|
|
|
|
guest_bot = get_bot(conn, guest_bot_id)
|
|
if guest_bot is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"guest bot not found: {guest_bot_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']}",
|
|
)
|
|
|
|
settings = request.app.state.settings
|
|
seed = await seed_inter_bot_edges(
|
|
client,
|
|
classifier_model=settings.classifier_model,
|
|
bot_a_id=chat["host_bot_id"],
|
|
bot_a_name=host_bot["name"],
|
|
bot_b_id=guest_bot_id,
|
|
bot_b_name=guest_bot["name"],
|
|
relationship_prose=relationship_prose,
|
|
timeout_s=settings.classifier_timeout_s,
|
|
)
|
|
|
|
append_and_apply(
|
|
conn,
|
|
kind="guest_added",
|
|
payload={"chat_id": chat_id, "guest_bot_id": guest_bot_id},
|
|
)
|
|
|
|
# Emit edge_update only when the seed carries content. Empty prose
|
|
# short-circuits inside ``seed_inter_bot_edges`` to a default seed,
|
|
# so this skips the two extra log entries on the no-prose path.
|
|
# NOTE: ``_apply_edge_update`` does not accept a ``summary`` field —
|
|
# per-direction summary is set via the per-pov scene-close path
|
|
# (T27), not direct edge_update. We therefore drop seed.*_summary
|
|
# here; the deltas + knowledge_facts are what materializes.
|
|
if not _seed_is_default(seed):
|
|
append_and_apply(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": chat["host_bot_id"],
|
|
"target_id": guest_bot_id,
|
|
"chat_id": chat_id,
|
|
"affinity_delta": seed.a_to_b_affinity_delta,
|
|
"trust_delta": seed.a_to_b_trust_delta,
|
|
"knowledge_facts": seed.a_to_b_knowledge_facts,
|
|
"last_interaction_at": chat.get("time"),
|
|
"last_interaction_chat_id": chat_id,
|
|
},
|
|
)
|
|
append_and_apply(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": guest_bot_id,
|
|
"target_id": chat["host_bot_id"],
|
|
"chat_id": chat_id,
|
|
"affinity_delta": seed.b_to_a_affinity_delta,
|
|
"trust_delta": seed.b_to_a_trust_delta,
|
|
"knowledge_facts": seed.b_to_a_knowledge_facts,
|
|
"last_interaction_at": chat.get("time"),
|
|
"last_interaction_chat_id": chat_id,
|
|
},
|
|
)
|
|
|
|
# Three entities now share the chat (you, host, guest) — initialize
|
|
# the group node row if Wave 1's reader doesn't see one yet.
|
|
if get_group_node(conn, chat_id) is None:
|
|
append_and_apply(
|
|
conn,
|
|
kind="group_node_initialized",
|
|
payload={
|
|
"chat_id": chat_id,
|
|
"members": ["you", chat["host_bot_id"], guest_bot_id],
|
|
"summary": "",
|
|
"dynamic": "",
|
|
"threads": [],
|
|
},
|
|
)
|
|
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/guest/remove",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def remove_guest(
|
|
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}")
|
|
|
|
if chat.get("guest_bot_id") is None:
|
|
raise HTTPException(
|
|
status_code=400, detail="no guest present in this chat"
|
|
)
|
|
|
|
# Close the active scene (if any) before flipping guest_bot_id so
|
|
# the scene record carries the guest as a participant.
|
|
scene = active_scene(conn, chat_id)
|
|
if scene is not None:
|
|
append_and_apply(
|
|
conn,
|
|
kind="scene_closed",
|
|
payload={
|
|
"scene_id": scene["id"],
|
|
"ended_at": chat.get("time"),
|
|
"significance": 0,
|
|
},
|
|
)
|
|
|
|
append_and_apply(
|
|
conn,
|
|
kind="guest_removed",
|
|
payload={"chat_id": chat_id},
|
|
)
|
|
|
|
return await drawer(chat_id, request, conn)
|