From 21404a373bf20d2c75a5ccf0fb607f49a1198e0d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:24:24 -0400 Subject: [PATCH 1/3] 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. --- chat/state/manual_edit.py | 41 +++- chat/templates/_drawer.html | 100 ++++++++- chat/web/drawer.py | 235 +++++++++++++++++++- tests/test_drawer_edits_extended.py | 327 ++++++++++++++++++++++++++++ 4 files changed, 685 insertions(+), 18 deletions(-) create mode 100644 tests/test_drawer_edits_extended.py diff --git a/chat/state/manual_edit.py b/chat/state/manual_edit.py index e796a7a..57e6c3b 100644 --- a/chat/state/manual_edit.py +++ b/chat/state/manual_edit.py @@ -6,7 +6,7 @@ be reversed by emitting an inverse ``manual_edit`` later. This module applies the new value to the appropriate target table; the snapshot of ``prior_value`` is taken by the route handler before this fires. -Phase 1 covers four target kinds: +Phase 1 covers five target kinds: - ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge, clamped to 0..100. - ``memory_significance`` — dropdown edit, clamped to 0..3. @@ -17,8 +17,12 @@ Phase 1 covers four target kinds: field. Driven by T27 from the classifier's ``relationship_summary`` output combined with the prior summary. -Other §6.4 editable fields (activity verb / attention / posture, -knowledge_facts list manipulation) are deferred to Phase 1.5. +T72.1 (Phase 2.5) adds one list-shaped edit: +- ``edge_knowledge_fact`` — add/remove a single fact on an edge's + ``knowledge_json`` list. Payload carries an ``action`` of ``"add"`` or + ``"remove"`` and a ``fact`` string; remove matches the first occurrence + by string equality so the route handler doesn't have to track fact + indices across re-renders. Pin toggles intentionally use the existing ``memory_pin_changed`` event (registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so @@ -27,6 +31,7 @@ the projection writes both ``pinned`` and ``auto_pinned`` atomically. from __future__ import annotations +import json from sqlite3 import Connection from chat.eventlog.log import Event @@ -87,5 +92,33 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None: target_id["target_id"], ), ) + elif kind == "edge_knowledge_fact": + # T72.1: add or remove a single fact on an edge's knowledge list. + # ``target_id`` is the {"source_id", "target_id"} edge pair; + # ``new_value`` carries ``{"action": "add"|"remove", "fact": str}``. + # Remove matches by string equality (first occurrence) so callers + # don't have to thread a fact_index through re-rendered drawers. + action = new_value["action"] + fact = str(new_value["fact"]) + row = conn.execute( + "SELECT knowledge_json FROM edges " + "WHERE source_id = ? AND target_id = ?", + (target_id["source_id"], target_id["target_id"]), + ).fetchone() + if row is not None: + knowledge = json.loads(row[0]) + if action == "add": + knowledge.append(fact) + elif action == "remove" and fact in knowledge: + knowledge.remove(fact) + conn.execute( + "UPDATE edges SET knowledge_json = ? " + "WHERE source_id = ? AND target_id = ?", + ( + json.dumps(knowledge), + target_id["source_id"], + target_id["target_id"], + ), + ) # Unknown target_kind: silently no-op for v1. Future kinds (activity - # fields, knowledge_facts list manipulation) extend the dispatch above. + # fields, etc.) extend the dispatch above. diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index 6bfb887..b6d2033 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -156,19 +156,95 @@ - {% if edge_b2y.summary %}

{{ edge_b2y.summary }}

{% endif %} - {% if edge_b2y.knowledge %} -
Knowledge ({{ edge_b2y.knowledge|length }}) - -
- {% endif %} +
+ + + + +
+
+ + + + +
+
+ Knowledge ({{ (edge_b2y.knowledge or [])|length }}) + {% if edge_b2y.knowledge %} + + {% endif %} +
+ + + + + +
+
{% endif %} {% if edge_y2b %}
you → {{ host_bot.name }}

Affinity: {{ edge_y2b.affinity }}/100 · Trust: {{ edge_y2b.trust }}/100

- {% if edge_y2b.summary %}

{{ edge_y2b.summary }}

{% endif %} +
+ + + + +
+
+ + + + +
{% endif %} {% if not edge_b2y and not edge_y2b %} @@ -224,6 +300,16 @@ +
+ Edit POV summary +
+ + + +
+
{% endfor %} diff --git a/chat/web/drawer.py b/chat/web/drawer.py index d38d850..341dce7 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -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 diff --git a/tests/test_drawer_edits_extended.py b/tests/test_drawer_edits_extended.py new file mode 100644 index 0000000..670ead6 --- /dev/null +++ b/tests/test_drawer_edits_extended.py @@ -0,0 +1,327 @@ +"""T72.1: deferred v1 drawer edits. + +T25 shipped affinity / significance / pin. T72.1 fills in the rest of the +§6.4 editable surface whose ``manual_edit`` projector dispatch was already +in place (or, for ``edge_knowledge_fact``, added alongside the route): + +* ``POST /chats/{chat_id}/drawer/edge/trust`` — slider 0..100. +* ``POST /chats/{chat_id}/drawer/edge/summary`` — textarea, capped 2000. +* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped. +* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact. + +Each test asserts (a) the ``manual_edit`` event lands in the log, +(b) the projected table reflects the new value, and (c) the response is +the refreshed drawer partial. + +T72.3's witness-flag tests extend this file with the inline-edit pair. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app +from chat.db.connection import open_db +from chat.eventlog.log import append_event +from chat.eventlog.projector import project + + +@pytest.fixture +def client(tmp_path, monkeypatch): + cfg = tmp_path / "config.toml" + cfg.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) + db = tmp_path / "test.db" + monkeypatch.setenv("CHAT_DB_PATH", str(db)) + with TestClient(app) as c: + if hasattr(app.state, "background_worker"): + app.state.background_worker.enabled = False + yield c + + +def _seed(db: Path) -> None: + """Seed a chat with one host bot, one host->you edge with a fact and + summary already set, and one memory authored by ``bot_a`` witnessed by + all three roles. Tests reach into projected state to verify edits. + """ + with open_db(db) as conn: + append_event( + conn, + kind="bot_authored", + payload={ + "id": "bot_a", + "name": "BotA", + "persona": "...", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "", + "kickoff_prose": "", + }, + ) + append_event( + conn, + kind="chat_created", + payload={ + "id": "chat_bot_a", + "host_bot_id": "bot_a", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + # Materialise edge bot_a -> you with a knowledge_fact already on it + # so the remove path has something to consume. + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "you", + "chat_id": "chat_bot_a", + "affinity_delta": 0, + "knowledge_facts": ["studied physics together"], + }, + ) + append_event( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "pov_summary": "Original summary text.", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "significance": 1, + }, + ) + project(conn) + + +# --- T72.1 tests ---------------------------------------------------------- + + +def test_edit_edge_trust_emits_manual_edit_and_updates(client, tmp_path): + _seed(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/drawer/edge/trust", + data={"source_id": "bot_a", "target_id": "you", "new_value": "73"}, + ) + assert response.status_code == 200 + # Refresh shows the new trust value somewhere in the partial. + assert "73" in response.text + + with open_db(tmp_path / "test.db") as conn: + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["target_kind"] == "edge_trust" + assert payload["prior_value"] == 50 + assert payload["new_value"] == 73 + assert payload["target_id"] == { + "source_id": "bot_a", + "target_id": "you", + } + + from chat.state.edges import get_edge + + edge = get_edge(conn, "bot_a", "you") + assert edge["trust"] == 73 + + +def test_edit_edge_trust_400_on_out_of_range(client, tmp_path): + _seed(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/drawer/edge/trust", + data={"source_id": "bot_a", "target_id": "you", "new_value": "150"}, + ) + assert response.status_code == 400 + + +def test_edit_edge_summary_emits_manual_edit_and_updates(client, tmp_path): + _seed(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/drawer/edge/summary", + data={ + "source_id": "bot_a", + "target_id": "you", + "new_summary": "BotA respects you and shares lab notes.", + }, + ) + assert response.status_code == 200 + + with open_db(tmp_path / "test.db") as conn: + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["target_kind"] == "edge_summary" + assert payload["new_value"].startswith("BotA respects") + assert payload["target_id"] == { + "source_id": "bot_a", + "target_id": "you", + } + + summary = conn.execute( + "SELECT summary FROM edges " + "WHERE source_id = ? AND target_id = ?", + ("bot_a", "you"), + ).fetchone()[0] + assert "respects" in summary + + # And the refreshed partial echoes the new summary back. + assert "respects" in response.text + + +def test_edit_edge_summary_400_on_overflow(client, tmp_path): + _seed(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/drawer/edge/summary", + data={ + "source_id": "bot_a", + "target_id": "you", + "new_summary": "x" * 2001, + }, + ) + assert response.status_code == 400 + + +def test_edit_memory_pov_summary_emits_manual_edit_and_updates( + client, tmp_path +): + _seed(tmp_path / "test.db") + with open_db(tmp_path / "test.db") as conn: + memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0] + + response = client.post( + "/chats/chat_bot_a/drawer/memory/pov-summary", + data={ + "memory_id": str(memory_id), + "new_summary": "Cleaner per-POV restatement of the moment.", + }, + ) + assert response.status_code == 200 + + with open_db(tmp_path / "test.db") as conn: + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["target_kind"] == "memory_pov_summary" + assert payload["prior_value"] == "Original summary text." + assert payload["new_value"].startswith("Cleaner per-POV") + assert payload["target_id"] == memory_id + + pov = conn.execute( + "SELECT pov_summary FROM memories WHERE id = ?", (memory_id,) + ).fetchone()[0] + assert pov.startswith("Cleaner per-POV") + + assert "Cleaner per-POV" in response.text + + +def test_edit_memory_pov_summary_404_when_wrong_chat(client, tmp_path): + _seed(tmp_path / "test.db") + with open_db(tmp_path / "test.db") as conn: + memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0] + # Re-home the memory to a different chat to confirm the route's + # cross-chat guard fires. + conn.execute( + "UPDATE memories SET chat_id = 'other_chat' WHERE id = ?", + (memory_id,), + ) + conn.commit() + + response = client.post( + "/chats/chat_bot_a/drawer/memory/pov-summary", + data={"memory_id": str(memory_id), "new_summary": "..."}, + ) + assert response.status_code == 404 + + +def test_edit_edge_knowledge_facts_add_emits_event_and_appends(client, tmp_path): + _seed(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/drawer/edge/knowledge-facts", + data={ + "source_id": "bot_a", + "target_id": "you", + "action": "add", + "fact": "lent you a textbook", + }, + ) + assert response.status_code == 200 + + with open_db(tmp_path / "test.db") as conn: + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["target_kind"] == "edge_knowledge_fact" + assert payload["new_value"] == { + "action": "add", + "fact": "lent you a textbook", + } + # Prior value snapshots the entire knowledge list before the edit. + assert payload["prior_value"] == ["studied physics together"] + + from chat.state.edges import get_edge + + edge = get_edge(conn, "bot_a", "you") + assert "lent you a textbook" in edge["knowledge"] + assert "studied physics together" in edge["knowledge"] + + assert "lent you a textbook" in response.text + + +def test_edit_edge_knowledge_facts_remove_drops_matching_fact(client, tmp_path): + _seed(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/drawer/edge/knowledge-facts", + data={ + "source_id": "bot_a", + "target_id": "you", + "action": "remove", + "fact": "studied physics together", + }, + ) + assert response.status_code == 200 + + with open_db(tmp_path / "test.db") as conn: + from chat.state.edges import get_edge + + edge = get_edge(conn, "bot_a", "you") + assert "studied physics together" not in edge["knowledge"] + + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" + ).fetchall() + payload = json.loads(rows[0][0]) + assert payload["target_kind"] == "edge_knowledge_fact" + assert payload["new_value"]["action"] == "remove" + + +def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path): + _seed(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/drawer/edge/knowledge-facts", + data={ + "source_id": "bot_a", + "target_id": "you", + "action": "delete", + "fact": "x", + }, + ) + assert response.status_code == 400 + + From c265e4ce0f1e1b69d385e4cfc1339e66288bcd5e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:26:31 -0400 Subject: [PATCH 2/3] feat: first-meeting gate on drawer Add-guest form (T72.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a host->candidate edge already exists from a prior chat, the Add-guest form renders the prose textarea disabled with an "already know each other" note. Submission without the explicit "re-seed anyway" toggle skips seed_inter_bot_edges so existing edge content (affinity, trust, knowledge, summaries) survives — guest_added and group_node_initialized still fire. A small inline script enables / disables the textarea per-option based on a pre-computed existing_guest_edges dict surfaced by the GET handler. --- chat/templates/_drawer.html | 53 +++++++++++- chat/web/drawer.py | 47 ++++++++--- tests/test_drawer_guest.py | 156 ++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 14 deletions(-) diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index b6d2033..228a440 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -100,24 +100,71 @@

Add guest

{% if available_guests %} -
+

+ they already know each other (edge exists from a prior chat) +

+
+ {% else %}

No other bots authored yet.

{% endif %} diff --git a/chat/web/drawer.py b/chat/web/drawer.py index 341dce7..a2928ff 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -113,6 +113,14 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): 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. @@ -161,6 +169,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): "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, @@ -602,6 +611,7 @@ async def add_guest( request: Request, guest_bot_id: str = Form(...), relationship_prose: str = Form(""), + reseed: str = Form(""), conn=Depends(get_conn), client=Depends(get_llm_client), ): @@ -633,17 +643,32 @@ async def add_guest( 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, + # 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, @@ -658,7 +683,7 @@ async def add_guest( # 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): + if seed is not None and not _seed_is_default(seed): append_and_apply( conn, kind="edge_update", diff --git a/tests/test_drawer_guest.py b/tests/test_drawer_guest.py index a599c03..f0a14d6 100644 --- a/tests/test_drawer_guest.py +++ b/tests/test_drawer_guest.py @@ -265,6 +265,162 @@ def test_drawer_remove_guest_clears_and_closes_scene(client, tmp_path): assert kinds.index("scene_closed") < kinds.index("guest_removed") +# --- T72.2 first-meeting gate ---------------------------------------------- + + +def _seed_host_to_guest_edge(db: Path) -> None: + """Materialise a bot_a -> bot_b edge so the gate's check fires.""" + from chat.eventlog.log import append_and_apply + + with open_db(db) as conn: + append_and_apply( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "bot_b", + "chat_id": "chat_bot_a", + "affinity_delta": 0, + "knowledge_facts": ["already met before"], + }, + ) + + +def test_add_guest_form_disables_prose_when_edge_exists(client, tmp_path): + """When host->candidate edge already exists, the GET partial renders + the textarea disabled and surfaces the "already know each other" + message so the user knows submitting will skip the seed. + """ + _seed_chat(tmp_path / "test.db") + _seed_host_to_guest_edge(tmp_path / "test.db") + + response = client.get("/chats/chat_bot_a/drawer") + assert response.status_code == 200 + body = response.text + # Note + disabled state both present. The textarea sits next to the + # ``add-guest-prose`` class so we can match it specifically. + assert "already know each other" in body + assert 'class="add-guest-prose"' in body + # The textarea for the first (auto-selected) candidate should be + # disabled in the initial markup since an edge exists. + assert "disabled" in body.split('class="add-guest-prose"', 1)[1].split(">", 1)[0] + # And the option carries the ``data-existing-edge="true"`` attribute + # the inline JS uses to flip state on subsequent select changes. + assert 'data-existing-edge="true"' in body + + +def test_add_guest_with_existing_edge_skips_seed_call(client, tmp_path): + """Submitting the Add-guest form WITHOUT toggling re-seed must skip + ``seed_inter_bot_edges`` entirely. We assert this via an empty mock + queue: if the seed function had been called it would have consumed + a canned response (or raised because none was available). + """ + _seed_chat(tmp_path / "test.db") + _seed_host_to_guest_edge(tmp_path / "test.db") + + # Empty queue: any classifier call would raise inside MockLLMClient. + canned_queue: list[str] = [] + _override_llm(canned_queue) + try: + response = client.post( + "/chats/chat_bot_a/drawer/guest/add", + data={ + "guest_bot_id": "bot_b", + "relationship_prose": "ignored prose", + # NO reseed flag — gate should suppress the seed call. + }, + ) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() + + with open_db(tmp_path / "test.db") as conn: + from chat.state.edges import get_edge + from chat.state.world import get_chat + + chat = get_chat(conn, "chat_bot_a") + assert chat["guest_bot_id"] == "bot_b" + + # The pre-seeded knowledge fact survives — proof the seed didn't run + # and overwrite the existing edge. + edge = get_edge(conn, "bot_a", "bot_b") + assert "already met before" in edge["knowledge"] + + # Exactly one guest_added; no new edge_update events between + # bot_a and bot_b (the pre-seed edge_update from the test setup + # is the only edge_update on this pair). + added = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'guest_added'" + ).fetchone()[0] + assert added == 1 + + edge_updates = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'edge_update'" + ).fetchall() + # Only the pre-seed edge_update from _seed_host_to_guest_edge. + ab_updates = [ + json.loads(p[0]) + for p in edge_updates + if { + json.loads(p[0]).get("source_id"), + json.loads(p[0]).get("target_id"), + } + == {"bot_a", "bot_b"} + ] + assert len(ab_updates) == 1 + assert ab_updates[0]["knowledge_facts"] == ["already met before"] + + +def test_add_guest_with_existing_edge_and_reseed_runs_seed(client, tmp_path): + """Toggling ``re-seed anyway`` flips the gate off — the existing + flow runs (seed produces deltas, two ``edge_update`` events fire). + """ + _seed_chat(tmp_path / "test.db") + _seed_host_to_guest_edge(tmp_path / "test.db") + + canned = json.dumps( + { + "a_to_b_summary": "reconnected", + "a_to_b_knowledge_facts": ["new fact"], + "a_to_b_affinity_delta": 2, + "a_to_b_trust_delta": 1, + "b_to_a_summary": "reconnected", + "b_to_a_knowledge_facts": [], + "b_to_a_affinity_delta": 1, + "b_to_a_trust_delta": 0, + } + ) + _override_llm([canned]) + try: + response = client.post( + "/chats/chat_bot_a/drawer/guest/add", + data={ + "guest_bot_id": "bot_b", + "relationship_prose": "fresh prose", + "reseed": "1", + }, + ) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() + + with open_db(tmp_path / "test.db") as conn: + edge_updates = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'edge_update'" + ).fetchall() + # Pre-seed (1) + two from the re-seed = 3 edge_updates total. + ab_updates = [ + json.loads(p[0]) + for p in edge_updates + if { + json.loads(p[0]).get("source_id"), + json.loads(p[0]).get("target_id"), + } + == {"bot_a", "bot_b"} + ] + assert len(ab_updates) == 3 + + def test_drawer_with_guest_renders_guest_and_group_sections(client, tmp_path): _seed_chat(tmp_path / "test.db") from chat.eventlog.log import append_and_apply From 607d0971c4565bd5cae7775a0b67095e950fc3ce Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:28:25 -0400 Subject: [PATCH 3/3] feat: drawer witness flag inline-edit (T72.3) Memories grow per-flag witness checkboxes (you / host / guest) that auto-submit on change via HTMX. The new POST route emits a manual_edit event with target_kind=memory_witness and a {flag, value} payload; prior_value mirrors the same shape so an inverse edit restores the flag. The drawer's recent-memories query now selects the three witness columns alongside the existing fields so the template can render checkbox state without a second query per row. --- chat/state/manual_edit.py | 18 +++++++ chat/templates/_drawer.html | 17 ++++++ chat/web/drawer.py | 73 ++++++++++++++++++++++++- tests/test_drawer_edits_extended.py | 82 +++++++++++++++++++++++++++-- 4 files changed, 185 insertions(+), 5 deletions(-) diff --git a/chat/state/manual_edit.py b/chat/state/manual_edit.py index 57e6c3b..3bfff79 100644 --- a/chat/state/manual_edit.py +++ b/chat/state/manual_edit.py @@ -24,6 +24,12 @@ T72.1 (Phase 2.5) adds one list-shaped edit: by string equality so the route handler doesn't have to track fact indices across re-renders. +T72.3 adds a per-flag witness toggle: +- ``memory_witness`` — flip one of ``witness_you`` / ``witness_host`` / + ``witness_guest`` on a memory row. Payload's ``new_value`` is a dict + ``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value`` + mirrors the same shape so an inverse edit can restore the flag. + Pin toggles intentionally use the existing ``memory_pin_changed`` event (registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so the projection writes both ``pinned`` and ``auto_pinned`` atomically. @@ -37,6 +43,8 @@ from sqlite3 import Connection from chat.eventlog.log import Event from chat.eventlog.projector import on +_VALID_WITNESS_FLAGS = {"you", "host", "guest"} + def _clamp(value: int, lo: int, hi: int) -> int: return max(lo, min(hi, value)) @@ -120,5 +128,15 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None: target_id["target_id"], ), ) + elif kind == "memory_witness": + # T72.3: toggle one of the three witness flags on a memory row. + # ``new_value`` is the dict ``{"flag", "value"}``; ``prior_value`` + # mirrors the same shape so an inverse edit restores the flag. + flag = new_value["flag"] + if flag in _VALID_WITNESS_FLAGS: + conn.execute( + f"UPDATE memories SET witness_{flag} = ? WHERE id = ?", + (1 if int(new_value["value"]) else 0, int(target_id)), + ) # Unknown target_kind: silently no-op for v1. Future kinds (activity # fields, etc.) extend the dispatch above. diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index 228a440..2cf48a7 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -347,6 +347,23 @@ +
+ {% for flag in ['you', 'host', 'guest'] %} + {% set witnessed = m['witness_' ~ flag] %} +
+ + + + +
+ {% endfor %} +
Edit POV summary