feat: per-POV summaries on close for each present witness

This commit is contained in:
Joseph Doherty
2026-04-26 16:06:05 -04:00
parent a90647dddb
commit 4e240347b4
2 changed files with 549 additions and 37 deletions
+422
View File
@@ -258,3 +258,425 @@ async def test_apply_scene_close_summary_updates_memories_and_edge(tmp_path):
# Knowledge fact appended via edge_update.
assert any("deadline" in fact for fact in edge["knowledge"])
# ---------------------------------------------------------------------------
# T45: per-POV summaries on close for each present witness.
# ---------------------------------------------------------------------------
def _bot_payload(bot_id: str, name: str, persona: str = "thoughtful") -> dict:
return {
"id": bot_id,
"name": name,
"persona": persona,
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
}
def _seed_single_bot_scene(conn) -> None:
"""Seed the canonical Phase 1 single-bot scene used by the regression test."""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
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": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (host)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Quick chat about the deadline",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "It's going to be okay.",
"truncated": False,
"user_turn_id": 1,
},
)
def _seed_two_bot_scene(conn, *, with_group_node: bool = False) -> None:
"""Seed a host+guest scene with bot_a (host) and bot_b (guest), plus a
memory row per bot owner so each per-POV update has something to rewrite,
and seeded directed edges from each bot to ``you`` so each edge_summary
update has a row to operate on. Optionally seeds the group_node row too.
"""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a", "bot_b"],
},
)
# Seed edges in both bot -> you directions so the edge_summary updates
# have rows to target.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_b",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
# One memory per witness, scene 1.
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (host)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_b",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (guest)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Three of us in the office.",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "Glad you're both here.",
"truncated": False,
"user_turn_id": 1,
},
)
if with_group_node:
append_event(
conn,
kind="group_node_initialized",
payload={
"chat_id": "chat_bot_a",
"members": ["you", "bot_a", "bot_b"],
"summary": "",
"dynamic": "",
"threads": [],
},
)
@pytest.mark.asyncio
async def test_close_with_no_guest_matches_phase1(tmp_path):
"""Regression: when guest_bot_id is None, the close summary path runs
summarize_scene exactly once and rewrites the host's memory + host->you
edge in place — same as Phase 1 behavior."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA helped you talk through the deadline anxiety.",
"knowledge_facts": ["Deadline next Friday."],
"relationship_summary": "BotA leaned in supportively.",
}
)
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
# canned has 2 entries to detect any over-call; the assertion below
# confirms only one was consumed.
client = MockLLMClient(canned=[canned, canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Exactly one classifier call -> exactly one canned entry consumed,
# leaving the second untouched.
assert len(client._canned) == 1
# Host memory rewritten with the per-POV summary content.
new_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
assert "BotA helped" in new_pov
# host->you edge summary rewritten with the relationship_summary.
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "supportively" in edge["summary"]
@pytest.mark.asyncio
async def test_close_with_guest_calls_summarize_twice(tmp_path):
"""When a guest is present, summarize_scene runs once per witness
(host + guest) and each bot's memory rewrite uses its own POV summary."""
db = tmp_path / "t.db"
apply_migrations(db)
host_canned = json.dumps(
{
"summary": "BotA noticed BotB warming up to you.",
"knowledge_facts": ["You sketched on the whiteboard."],
"relationship_summary": "BotA felt steady around you.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the office quieter than expected.",
"knowledge_facts": ["You prefer black coffee."],
"relationship_summary": "BotB warmed up to you a little.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Both canned entries consumed -> classifier ran twice.
assert client._canned == []
# Host memory carries the host's per-POV summary; guest memory
# carries the guest's.
host_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
guest_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_b' AND scene_id = 1"
).fetchone()[0]
assert "BotA noticed" in host_pov
assert "BotB found" in guest_pov
assert host_pov != guest_pov
@pytest.mark.asyncio
async def test_close_with_guest_updates_both_edges(tmp_path):
"""Both bot->you edges receive their own relationship_summary on close."""
db = tmp_path / "t.db"
apply_migrations(db)
host_canned = json.dumps(
{
"summary": "BotA noticed BotB warming up.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady around you.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB warmed to the office.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up to you a little.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
from chat.state.edges import get_edge
edge_h2y = get_edge(conn, "bot_a", "you")
edge_g2y = get_edge(conn, "bot_b", "you")
assert "steady" in edge_h2y["summary"]
assert "warmed up" in edge_g2y["summary"]
# Per-POV; the two edges did not collapse onto the same text.
assert edge_h2y["summary"] != edge_g2y["summary"]
@pytest.mark.asyncio
async def test_close_with_group_node_updates_group_summary(tmp_path):
"""When a group_node row exists, scene close emits group_node_updated
with a non-empty summary that mentions both bots' names (v2 naive
concat of per-POV summaries)."""
db = tmp_path / "t.db"
apply_migrations(db)
import chat.state.group_node # noqa: F401 -- register handlers
host_canned = json.dumps(
{
"summary": "BotA appreciated the calm.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the room friendly.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn, with_group_node=True)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
from chat.state.group_node import get_group_node
gn = get_group_node(conn, "chat_bot_a")
assert gn is not None
assert gn["summary"] # non-empty
# Naive concat surfaces both bot names in the group summary.
assert "BotA" in gn["summary"]
assert "BotB" in gn["summary"]
# Phase 2 v2 keeps dynamic empty (Phase 3 polishes).
assert gn["dynamic"] == ""