feat: drawer edits for edge_trust / edge_summary / memory_pov_summary / knowledge_facts (T72.1)
Adds the four POST routes whose state-layer support was already dispatched by the manual_edit projector (edge_trust, edge_summary, memory_pov_summary) plus a new edge_knowledge_fact dispatch branch for add/remove fact list manipulation. Drawer template gains editable textareas, sliders, and add/remove fact controls. Remove semantics on knowledge_fact match by string (not index) so concurrent edge_update events appending facts between drawer renders don't desync the form.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user