diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py index 43fb5ec..d57f7e2 100644 --- a/chat/services/scene_summarize.py +++ b/chat/services/scene_summarize.py @@ -156,64 +156,50 @@ def _read_recent_dialogue( return out -async def apply_scene_close_summary( +async def _summarize_and_apply_for_witness( conn: Connection, client: LLMClient, *, classifier_model: str, chat_id: str, scene_id: int, - host_bot_id: str, - timeout_s: float = 10.0, + bot_id: str, + you_name: str, + dialogue: list[dict], + timeout_s: float, ) -> ScenePOVSummary: - """Drive the per-POV summary pipeline after ``scene_closed``. + """Run :func:`summarize_scene` for one bot witness and apply the + three projected updates (memory pov_summary rewrite, edge summary + overwrite, edge knowledge_facts append). - Steps (Phase 1, single-bot): - 1. Gather the closing scene's dialogue from the event_log. - 2. Run :func:`summarize_scene` for the host bot. - 3. Rewrite each scene-bound memory's ``pov_summary`` via - ``manual_edit`` (target_kind ``memory_pov_summary``), capturing - the prior value for §6.4 reversibility. - 4. Update the bot->you edge summary via ``manual_edit`` with the - new ``edge_summary`` target_kind. v1 combines prior + new by - concatenation — the classifier's ``relationship_summary`` is - already phrased as a continuation. - 5. Append any new knowledge_facts to the same edge via - ``edge_update``. - - Tolerant of missing pieces: no memories -> skip step 3 silently; - no edge row -> skip step 4; empty knowledge_facts -> skip step 5. - The classifier's empty default flows through harmlessly. + Tolerant of missing pieces in the same way Phase 1 was: no memory + row -> skip the rewrite; no edge row -> skip the edge_summary write + (the empty-default classifier output simply yields no rewrites). """ - # Local imports to keep the module-level surface tight and avoid - # any chance of a circular dep through chat.state.*. from chat.state.edges import get_edge - from chat.state.entities import get_bot, get_you + from chat.state.entities import get_bot - host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id, "persona": ""} - you_entity = get_you(conn) or {"name": "you", "persona": ""} + bot = get_bot(conn, bot_id) or {"name": bot_id, "persona": ""} - dialogue = _read_recent_dialogue(conn, chat_id) - - edge_b2y = get_edge(conn, host_bot_id, "you") + edge_b2y = get_edge(conn, bot_id, "you") prior_summary = (edge_b2y or {}).get("summary", "") or "" pov = await summarize_scene( client, model=classifier_model, - bot_name=host_bot.get("name", host_bot_id), - bot_persona=host_bot.get("persona", "") or "", - you_name=you_entity.get("name", "you") or "you", + bot_name=bot.get("name", bot_id), + bot_persona=bot.get("persona", "") or "", + you_name=you_name, prior_edge_summary=prior_summary, dialogue=dialogue, timeout_s=timeout_s, ) - # Update memories belonging to the closed scene for the host bot. + # Update memories belonging to the closed scene for this witness. cur = conn.execute( "SELECT id, pov_summary FROM memories " "WHERE scene_id = ? AND owner_id = ?", - (scene_id, host_bot_id), + (scene_id, bot_id), ) for memory_id, prior_pov in cur.fetchall(): if not pov.summary: @@ -231,7 +217,7 @@ async def apply_scene_close_summary( }, ) - # Update the bot->you edge summary if we have an edge row and a + # Update this bot->you edge summary if we have an edge row and a # non-empty relationship_summary to merge. if edge_b2y is not None and pov.relationship_summary: new_summary = ( @@ -245,7 +231,7 @@ async def apply_scene_close_summary( payload={ "target_kind": "edge_summary", "target_id": { - "source_id": host_bot_id, + "source_id": bot_id, "target_id": "you", }, "prior_value": prior_summary, @@ -253,13 +239,13 @@ async def apply_scene_close_summary( }, ) - # Append knowledge_facts to the bot->you edge if present. + # Append knowledge_facts to this bot->you edge if present. if pov.knowledge_facts: append_and_apply( conn, kind="edge_update", payload={ - "source_id": host_bot_id, + "source_id": bot_id, "target_id": "you", "chat_id": chat_id, "knowledge_facts": list(pov.knowledge_facts), @@ -267,3 +253,107 @@ async def apply_scene_close_summary( ) return pov + + +async def apply_scene_close_summary( + conn: Connection, + client: LLMClient, + *, + classifier_model: str, + chat_id: str, + scene_id: int, + host_bot_id: str, + timeout_s: float = 10.0, +) -> ScenePOVSummary: + """Drive the per-POV summary pipeline after ``scene_closed``. + + Phase 1 (single-bot) behavior — the host bot is summarized once and + the result drives memory + edge rewrites — is preserved exactly when + the chat has no guest. T45 extends this to fan out across each + present bot witness when a guest is also in the room: + + 1. Gather the closing scene's dialogue from the event_log. + 2. For each present witness (host + guest if any), run + :func:`summarize_scene` once with that witness's persona and + their own prior ``bot -> you`` edge summary. + 3. For each witness independently: + a. Rewrite each scene-bound memory's ``pov_summary`` via + ``manual_edit`` (target_kind ``memory_pov_summary``). + b. Update that witness's ``bot -> you`` edge summary via + ``manual_edit`` (target_kind ``edge_summary``). v2 combines + prior + classifier ``relationship_summary`` by simple + concatenation. + c. Append any ``knowledge_facts`` to the same edge via + ``edge_update``. + 4. If a ``group_node`` row exists for this chat, append a + ``group_node_updated`` event whose ``summary`` is the naive + per-POV concat ``f"{name}: {summary}\\n\\n..."``. A true + LLM-merged group view is deferred to Phase 2.5; ``dynamic`` + is left empty here for v2 (Phase 3 polishes it). + + The host's :class:`ScenePOVSummary` is returned to preserve the + Phase 1 callers' contract. + """ + # Local imports to keep the module-level surface tight and avoid + # any chance of a circular dep through chat.state.*. + from chat.state.entities import get_bot, get_you + from chat.state.group_node import get_group_node + from chat.state.world import get_chat + + you_entity = get_you(conn) or {"name": "you", "persona": ""} + you_name = you_entity.get("name", "you") or "you" + + chat = get_chat(conn, chat_id) or {} + guest_bot_id = chat.get("guest_bot_id") + + dialogue = _read_recent_dialogue(conn, chat_id) + + host_pov = await _summarize_and_apply_for_witness( + conn, + client, + classifier_model=classifier_model, + chat_id=chat_id, + scene_id=scene_id, + bot_id=host_bot_id, + you_name=you_name, + dialogue=dialogue, + timeout_s=timeout_s, + ) + + guest_pov: ScenePOVSummary | None = None + if guest_bot_id is not None: + guest_pov = await _summarize_and_apply_for_witness( + conn, + client, + classifier_model=classifier_model, + chat_id=chat_id, + scene_id=scene_id, + bot_id=guest_bot_id, + you_name=you_name, + dialogue=dialogue, + timeout_s=timeout_s, + ) + + # Group node update: naive per-POV concat for v2. Only fires when + # both POVs ran (i.e. the guest is present) and a group_node row + # exists for this chat. + if guest_pov is not None and get_group_node(conn, chat_id) is not None: + host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id} + guest_bot = get_bot(conn, guest_bot_id) or {"name": guest_bot_id} + host_name = host_bot.get("name", host_bot_id) or host_bot_id + guest_name = guest_bot.get("name", guest_bot_id) or guest_bot_id + group_summary = ( + f"{host_name}: {host_pov.summary}\n\n" + f"{guest_name}: {guest_pov.summary}" + ) + append_and_apply( + conn, + kind="group_node_updated", + payload={ + "chat_id": chat_id, + "summary": group_summary, + "dynamic": "", + }, + ) + + return host_pov diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py index 3e9d597..f33345a 100644 --- a/tests/test_per_pov_summary.py +++ b/tests/test_per_pov_summary.py @@ -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"] == ""