Files
chat/chat/web/drawer.py
T

1305 lines
43 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
import json
import uuid
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.branching import (
branch_from_event,
list_branches_with_metadata,
switch_active_branch,
)
from chat.services.delete_impact import compute_delete_impact
from chat.services.relationship_seed import seed_inter_bot_edges
from chat.services.rewind import execute_rewind
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.events import list_active_events
from chat.state.group_node import get_group_node
from chat.state.memory import get_pinned
from chat.state.threads import list_open_threads
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
from chat.web.skip import (
ChatNotFoundError,
_now_iso,
process_elision_skip,
process_jump_skip,
)
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"]
]
# T72.2 first-meeting gate: pre-compute whether a host->candidate edge
# already exists. Template renders the prose textarea disabled and the
# POST handler skips ``seed_inter_bot_edges`` (preserving the existing
# edge content) unless the user explicitly toggles "re-seed anyway".
existing_guest_edges = {
b["id"]: get_edge(conn, chat["host_bot_id"], b["id"]) is not None
for b in available_guests
}
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. The
# three witness flags ride along so T72.3's per-row checkboxes can
# render the current state without a second query per memory.
recent_rows = conn.execute(
"""
SELECT id, pov_summary, significance, pinned, created_at,
witness_you, witness_host, witness_guest
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],
"witness_you": r[5],
"witness_host": r[6],
"witness_guest": r[7],
}
for r in recent_rows
]
pinned = get_pinned(conn, chat["host_bot_id"])
# T59: active events + open threads for the new drawer sections.
active_events = list_active_events(conn, chat_id)
open_threads = list_open_threads(conn, chat_id)
# T98.3: recent turns (user_turn / assistant_turn) for the hide-from-view
# panel. Includes ``hidden`` rows so the user can un-hide them — the
# filter on the read side (read_recent_dialogue) is what drops hidden
# rows from the prompt; the drawer panel always shows everything.
turn_rows = conn.execute(
"""
SELECT id, kind, payload_json, hidden
FROM event_log
WHERE kind IN ('user_turn', 'assistant_turn', 'user_turn_edit')
AND superseded_by IS NULL
ORDER BY id DESC
LIMIT ?
""",
(RECENT_LIMIT,),
).fetchall()
recent_turns: list[dict] = []
for row in turn_rows:
try:
payload = json.loads(row[2]) if row[2] else {}
except (json.JSONDecodeError, TypeError):
payload = {}
if payload.get("chat_id") != chat_id:
continue
text = payload.get("prose") or payload.get("text") or ""
speaker = payload.get("speaker_id") or (
"you" if row[1].startswith("user") else "?"
)
recent_turns.append(
{
"event_id": int(row[0]),
"kind": row[1],
"speaker": speaker,
"excerpt": (text or "").replace("\n", " ")[:120],
"hidden": bool(row[3]),
}
)
# T98.1: branch metadata (every chat sees the global branch list — branches
# may be chat-scoped or global, so :func:`list_branches_with_metadata`
# returns both flavours and the template highlights the active one).
branches = list_branches_with_metadata(conn, chat_id)
# T98.2: significance distribution across this chat's memories. Powers
# the "Significance review" panel — a small histogram letting authors
# spot lopsided buckets (e.g. nothing significant=3 yet) and triage by
# editing individual memory significance values.
sig_rows = conn.execute(
"SELECT significance, COUNT(*) FROM memories "
"WHERE chat_id = ? GROUP BY significance ORDER BY significance",
(chat_id,),
).fetchall()
significance_distribution = {int(r[0]): int(r[1]) for r in sig_rows}
# Ensure every bucket 0..3 is present so the bar-chart template can
# render a stable axis even when a level has zero rows.
for level in (0, 1, 2, 3):
significance_distribution.setdefault(level, 0)
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,
"existing_guest_edges": existing_guest_edges,
"group_node": group_node,
"recent_memories": recent_memories,
"pinned": pinned,
"pin_cap": PIN_CAP,
"active_events": active_events,
"open_threads": open_threads,
"branches": branches,
"significance_distribution": significance_distribution,
"recent_turns": recent_turns,
},
)
# --- 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)
# --- T72.3 witness flag inline-edit --------------------------------------
#
# Witness flags decide which entities can recall a memory (§7 retrieval).
# Editing them is rare but high-impact — flipping ``witness_guest`` from 0
# to 1 makes the memory available to the guest's prompt context. The route
# follows the T25 / T72.1 pattern: snapshot prior, append + apply
# ``manual_edit`` with a ``{flag, value}`` payload, refresh the partial.
_VALID_WITNESS_FLAGS = ("you", "host", "guest")
@router.post(
"/chats/{chat_id}/drawer/memory/witness",
response_class=HTMLResponse,
)
async def edit_memory_witness(
chat_id: str,
request: Request,
memory_id: int = Form(...),
flag: 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 flag not in _VALID_WITNESS_FLAGS:
raise HTTPException(
status_code=400,
detail=(
f"flag must be one of {list(_VALID_WITNESS_FLAGS)}, "
f"got {flag!r}"
),
)
row = conn.execute(
f"SELECT witness_{flag} 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_int = int(row[0])
new_int = 1 if int(new_value) else 0
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_witness",
"target_id": int(memory_id),
"prior_value": {"flag": flag, "value": prior_int},
"new_value": {"flag": flag, "value": new_int},
},
)
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(""),
reseed: 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']}",
)
# T72.2 first-meeting gate: when an edge already exists from a prior
# chat, the textarea is rendered disabled. Submission without the
# explicit "re-seed anyway" toggle skips ``seed_inter_bot_edges``
# entirely so the existing edge content (affinity, trust, knowledge,
# summaries) survives. ``guest_added`` and ``group_node_initialized``
# still fire so the chat picks up the new participant.
existing_edge = (
get_edge(conn, chat["host_bot_id"], guest_bot_id) is not None
)
reseed_requested = reseed.lower() in ("1", "true", "on", "yes")
skip_seed = existing_edge and not reseed_requested
settings = request.app.state.settings
if skip_seed:
seed = None
else:
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 seed is not None and 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)
# --- T59 events / threads / skip controls --------------------------------
#
# Five drawer-driven endpoints that emit Phase 3 event-log entries:
#
# * ``event_planned`` / ``event_cancelled`` for the events panel — props
# arrive as a JSON-encoded form field so the user can author arbitrary
# structured side-info without a custom HTMX widget per kind.
# * ``time_skip_elision`` / ``time_skip_jump`` for the skip panel —
# each emits the projector event AND an ``assistant_turn`` carrying the
# narration prose from :mod:`chat.services.skip_narration`. Jump skips
# ALSO write per-bot synthesized memories from any user-supplied
# ``notable_prose`` via :func:`synthesize_memories` +
# :func:`record_turn_memory_for_present`.
# * ``thread_closed`` for the threads panel.
#
# Skip narration is appended via plain ``append_event`` (assistant_turn
# has no projector handler — it's a transcript-only kind, see
# :func:`chat.web.turns._read_recent_dialogue`). The user will see the
# new turn on the next chat-detail page load; we do NOT broadcast via
# ``publish`` here because the SSE channel is scoped to the chat-detail
# page and the drawer partial is the response body — adding cross-cutting
# SSE here would require dragging the publish import + chat-channel state
# into the drawer module without a meaningful UX gain (the drawer only
# rerenders itself on these submissions).
@router.post(
"/chats/{chat_id}/drawer/event/plan",
response_class=HTMLResponse,
)
async def plan_event(
chat_id: str,
request: Request,
kind: str = Form(...),
planned_for: str = Form(...),
props_json: str = Form("{}"),
conn=Depends(get_conn),
):
"""Append an ``event_planned`` row from the drawer's "Plan event" form.
``props_json`` is parsed into a dict before being attached to the
payload so the projector can treat it as structured data. Bad JSON
yields ``400`` — the form template renders an inline error in that
case so the user can fix-and-resubmit without losing their input.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
try:
props = json.loads(props_json) if props_json.strip() else {}
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=400, detail=f"props_json must be valid JSON: {exc}"
)
if not isinstance(props, dict):
raise HTTPException(
status_code=400, detail="props_json must encode a JSON object"
)
event_id = f"evt_{uuid.uuid4().hex[:12]}"
append_and_apply(
conn,
kind="event_planned",
payload={
"event_id": event_id,
"chat_id": chat_id,
"kind": kind,
"props": props,
"planned_for": planned_for,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/event/cancel/{event_id}",
response_class=HTMLResponse,
)
async def cancel_event(
chat_id: str,
event_id: str,
request: Request,
conn=Depends(get_conn),
):
"""Append an ``event_cancelled`` row for ``event_id``.
``completed_at`` is sourced from the chat clock (so cancellations
timeline-align with the rest of the fiction) with a UTC-now fallback
when the clock isn't set. The projector is idempotent on terminal
statuses so a stale double-submit is harmless.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
completed_at = chat.get("time") or _now_iso()
append_and_apply(
conn,
kind="event_cancelled",
payload={
"event_id": event_id,
"completed_at": completed_at,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/skip/elision",
response_class=HTMLResponse,
)
async def skip_elision(
chat_id: str,
request: Request,
landing_state_hint: str = Form(""),
new_time: str = Form(...),
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
"""Elision skip: collapse in-progress activity into its end-state.
Thin HTTP wrapper around :func:`chat.web.skip.process_elision_skip`
(T62 extracted the controller). Validation failures surface as
``400`` and the route still returns the refreshed drawer partial on
success so HTMX swaps in the new chat clock.
"""
settings = request.app.state.settings
try:
await process_elision_skip(
conn,
client,
settings,
chat_id=chat_id,
new_time=new_time,
landing_state_hint=landing_state_hint,
app=request.app,
)
except ChatNotFoundError as exc:
# Missing chat row: typed exception (T81) replaces the prior
# ``str(exc).startswith("chat not found")`` prefix sniff.
raise HTTPException(status_code=404, detail=str(exc))
except ValueError as exc:
# Input-validation failure (malformed or backwards new_time).
raise HTTPException(status_code=400, detail=str(exc))
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/skip/jump",
response_class=HTMLResponse,
)
async def skip_jump(
chat_id: str,
request: Request,
new_time: str = Form(...),
notable_prose: str = Form(""),
reset_activity: str = Form(""),
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
"""Jump skip: bridge a longer fiction-time delta.
Thin HTTP wrapper around :func:`chat.web.skip.process_jump_skip`
(T62 extracted the controller). ``reset_activity`` is parsed
permissively here ("1" / "true" / "on" / "yes" — same shape as the
add-guest reseed flag) since HTML checkboxes typically post the
literal "1" or omit the field entirely.
"""
reset_flag = reset_activity.lower() in ("1", "true", "on", "yes")
settings = request.app.state.settings
try:
await process_jump_skip(
conn,
client,
settings,
chat_id=chat_id,
new_time=new_time,
notable_prose=notable_prose,
reset_activity=reset_flag,
app=request.app,
)
except ChatNotFoundError as exc:
# Missing chat row: typed exception (T81) replaces the prior
# ``str(exc).startswith("chat not found")`` prefix sniff.
raise HTTPException(status_code=404, detail=str(exc))
except ValueError as exc:
# Input-validation failure (malformed or backwards new_time).
raise HTTPException(status_code=400, detail=str(exc))
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/thread/close/{thread_id}",
response_class=HTMLResponse,
)
async def close_thread(
chat_id: str,
thread_id: str,
request: Request,
conn=Depends(get_conn),
):
"""Append a ``thread_closed`` row for ``thread_id``.
Mirrors :func:`cancel_event` — chat-clock-or-now timestamp, projector
handles idempotency. The drawer's open-threads list is sourced from
``list_open_threads`` which filters by ``status='open'`` so a stale
double-submit is a no-op visually.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
closed_at = chat.get("time") or _now_iso()
append_and_apply(
conn,
kind="thread_closed",
payload={
"thread_id": thread_id,
"closed_at": closed_at,
},
)
return await drawer(chat_id, request, conn)
# --- T98.1 branching UI --------------------------------------------------
#
# Three POST endpoints wired to the Phase 4 :mod:`chat.services.branching`
# helpers. The drawer's "Branches" panel exposes:
#
# * Create from a free-form ``origin_event_id``.
# * Switch the active branch by name.
# * Convenience "branch from this turn" against a per-turn event_id (the
# chat surface stamps ``id="turn-<event_id>"`` on every turn so users can
# pick the right one without copying ids by hand).
#
# All three return the refreshed drawer partial; failures from the service
# layer (duplicate name, unknown branch, invalid origin) surface as 400 so
# HTMX displays the inline error.
@router.post(
"/chats/{chat_id}/drawer/branch/create",
response_class=HTMLResponse,
)
async def create_branch(
chat_id: str,
request: Request,
name: str = Form(...),
origin_event_id: 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}")
try:
branch_from_event(
conn,
name=name,
origin_event_id=int(origin_event_id),
chat_id=chat_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/branch/switch",
response_class=HTMLResponse,
)
async def switch_branch(
chat_id: str,
request: Request,
name: 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}")
try:
switch_active_branch(conn, name=name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/turn/hide/{event_id}",
response_class=HTMLResponse,
)
async def hide_turn(
chat_id: str,
event_id: int,
request: Request,
hidden: int = Form(...),
conn=Depends(get_conn),
):
"""Toggle ``event_log.hidden`` on a turn via the ``turn_hidden``
``manual_edit`` projector branch.
The route validates the target is an actual turn-shaped row in this
chat (so a stray click on the chat panel can't hide a system event)
and snapshots the prior ``hidden`` value for §6.4 reversibility.
"""
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 kind, payload_json, hidden FROM event_log WHERE id = ?",
(int(event_id),),
).fetchone()
if row is None:
raise HTTPException(
status_code=404, detail=f"event not found: {event_id}"
)
if row[0] not in ("user_turn", "assistant_turn", "user_turn_edit"):
raise HTTPException(
status_code=400,
detail=f"event {event_id} is not a turn (kind={row[0]})",
)
try:
payload = json.loads(row[1]) if row[1] else {}
except (json.JSONDecodeError, TypeError):
payload = {}
if payload.get("chat_id") != chat_id:
raise HTTPException(
status_code=404,
detail=f"event {event_id} not in chat {chat_id}",
)
prior_hidden = 1 if int(row[2]) else 0
new_hidden = 1 if int(hidden) else 0
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "turn_hidden",
"target_id": int(event_id),
"prior_value": {"hidden": prior_hidden},
"new_value": {"hidden": new_hidden},
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
response_class=HTMLResponse,
)
async def branch_from_turn(
chat_id: str,
event_id: int,
request: Request,
name: str = Form(...),
conn=Depends(get_conn),
):
"""Convenience: branch from a specific turn event.
Identical to :func:`create_branch` except ``origin_event_id`` is
encoded in the URL — the chat surface renders one such form per turn
so users can fork mid-conversation without authoring an event id by
hand.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
try:
branch_from_event(
conn,
name=name,
origin_event_id=int(event_id),
chat_id=chat_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return await drawer(chat_id, request, conn)