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 + +