diff --git a/chat/services/prompt.py b/chat/services/prompt.py index eeb5bf2..0ba2bbd 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -39,6 +39,7 @@ from chat.state.edges import get_edge, list_edges_for from chat.state.entities import get_bot, get_you from chat.state.events import list_active_events from chat.state.group_node import get_group_node +from chat.state.meanwhile import list_pending_meanwhile_digests from chat.state.memory import search_memories from chat.state.threads import list_open_threads from chat.state.world import ( @@ -277,6 +278,31 @@ def _build_active_events_block(events: list[dict]) -> str | None: return "\n".join(lines) +def _build_meanwhile_digests_block(digests: list[dict]) -> str | None: + """Render the ``Meanwhile while you were away:`` block for T65. + + One bullet per pending digest, formatted as ``- {summary}`` with the + summary truncated to ~200 characters per spec. Returns ``None`` when + there are no pending digests so the caller can omit the entire block. + + The block is rendered ONLY when the prompt is for a regular you-scene + (``present_set_kind != "host_guest"``); the caller filters before + constructing the digests list. + """ + if not digests: + return None + lines = ["Meanwhile while you were away:"] + for d in digests: + summary = d.get("summary") or "" + if len(summary) > 200: + summary = summary[:199] + "…" + if summary: + lines.append(f"- {summary}") + if len(lines) == 1: + return None + return "\n".join(lines) + + def _build_open_threads_block(threads: list[dict]) -> str | None: """Render the ``Open threads:`` block for Phase 3 Task 60. @@ -529,6 +555,32 @@ def assemble_narrative_prompt( list_open_threads(conn, chat_id) ) + # SHOULD-tier meanwhile digest (Phase 3 / Task 65). Only surfaces + # when the prompt is for a regular you-scene (NOT for a meanwhile + # child scene — the absent player is the audience, not the bots + # currently mid-meanwhile). We distinguish via the chat's active + # scene's ``present_set_kind``; a missing scene row defaults to a + # you-scene render so the block can still surface during the + # post-meanwhile-close transition before the next scene opens. + # + # Consumption is INTENTIONALLY left to the post_turn flow (a + # ``consume_pending_meanwhile_digests`` helper, see below) rather + # than emitted inline here. Surfacing has no side-effects; the + # caller appends ``meanwhile_digest_consumed`` after the response + # streams. This keeps prompt assembly pure and deterministic — the + # Phase 1 invariant T29's regenerate flow relies on. + meanwhile_digests_block: str | None = None + active_scene_kind: str | None = None + if chat.get("active_scene_id"): + active_sc = get_scene(conn, chat["active_scene_id"]) + if active_sc: + active_scene_kind = active_sc.get("present_set_kind") + if active_scene_kind != "host_guest": + pending_digests = list_pending_meanwhile_digests(conn, chat_id) + meanwhile_digests_block = _build_meanwhile_digests_block( + pending_digests + ) + container = None if chat.get("active_scene_id"): scene = get_scene(conn, chat["active_scene_id"]) @@ -632,6 +684,7 @@ def assemble_narrative_prompt( include_group_node: bool = True, include_active_events: bool = True, include_open_threads: bool = True, + include_meanwhile_digests: bool = True, ) -> tuple[str, int, list[dict]]: # dialogue: keep the last `dialogue_keep` turns verbatim; older # turns become an "earlier:" placeholder line. @@ -669,6 +722,10 @@ def assemble_narrative_prompt( group_node_block if include_group_node else None, active_events_block if include_active_events else None, open_threads_block if include_open_threads else None, + ( + meanwhile_digests_block + if include_meanwhile_digests else None + ), prev_block, memories_block, dialogue_block, @@ -690,10 +747,12 @@ def assemble_narrative_prompt( include_group_node = group_node_block is not None include_active_events = active_events_block is not None include_open_threads = open_threads_block is not None + include_meanwhile_digests = meanwhile_digests_block is not None def _build(*, prev: bool, mem_k: int, dlg: int, other: bool, you_act: bool, guest_act: bool, group: bool, - events: bool, threads: bool) -> tuple[str, int]: + events: bool, threads: bool, + digests: bool) -> tuple[str, int]: body, total, _ = assemble( include_other_edges=other, include_previous_scene=prev, @@ -704,6 +763,7 @@ def assemble_narrative_prompt( include_group_node=group, include_active_events=events, include_open_threads=threads, + include_meanwhile_digests=digests, ) return body, total @@ -712,6 +772,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) # If under soft, we're done. @@ -747,6 +808,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -758,6 +820,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -769,6 +832,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -781,18 +845,32 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) # Drop SHOULD-tier extras in order: - # 1. open threads block (T60: SHOULD-tier; least critical to the - # speaker's immediate voice — drop first among SHOULD) - # 2. active events block (T60: same tier, drops next) - # 3. guest activity bullet (T71.2: bullet-level trim within the + # 1. meanwhile digests block (T65: SHOULD-tier; refers to a past + # meanwhile scene — least critical to the speaker's immediate + # voice, so dropped first among SHOULD) + # 2. open threads block (T60: SHOULD-tier; least critical to the + # speaker's immediate voice — drop next among SHOULD) + # 3. active events block (T60: same tier, drops next) + # 4. guest activity bullet (T71.2: bullet-level trim within the # single ACTIVITIES: block — guest goes first per Task 43 spec) - # 4. group node block - # 5. you activity bullet (still SHOULD-tier; speaker bullet is the + # 5. group node block + # 6. you activity bullet (still SHOULD-tier; speaker bullet is the # MUST-tier floor and never dropped) - # 6. other edges + # 7. other edges + if include_meanwhile_digests and total > budget_hard: + include_meanwhile_digests = False + body, total = _build( + prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, + ) + if include_open_threads and total > budget_hard: include_open_threads = False body, total = _build( @@ -800,6 +878,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_active_events and total > budget_hard: @@ -809,6 +888,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_guest_activity and total > budget_hard: @@ -818,6 +898,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_group_node and total > budget_hard: @@ -827,6 +908,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_you_activity and total > budget_hard: @@ -836,6 +918,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_other and total > budget_hard: @@ -845,6 +928,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total > budget_hard: @@ -870,4 +954,38 @@ def _emit(system_body: str, user_turn_prose: str | None) -> list[Message]: return msgs -__all__ = ["assemble_narrative_prompt"] +def consume_pending_meanwhile_digests(conn: Connection, chat_id: str) -> int: + """Mark every pending meanwhile digest for ``chat_id`` as consumed. + + Called by the post_turn flow AFTER the assistant response streams, + once for the first you-turn that surfaced any pending digests. We + keep this side-effect out of :func:`assemble_narrative_prompt` so + prompt assembly stays pure (T29's regenerate flow rebuilds prompts + repeatedly without state mutation). + + Returns the number of digests consumed (0 when none were pending). + """ + from datetime import datetime, timezone + + from chat.eventlog.log import append_and_apply + + pending = list_pending_meanwhile_digests(conn, chat_id) + if not pending: + return 0 + now = datetime.now(timezone.utc).isoformat() + for d in pending: + append_and_apply( + conn, + kind="meanwhile_digest_consumed", + payload={ + "digest_id": d["id"], + "consumed_at": now, + }, + ) + return len(pending) + + +__all__ = [ + "assemble_narrative_prompt", + "consume_pending_meanwhile_digests", +] diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py index fa5958f..386cf51 100644 --- a/chat/services/scene_summarize.py +++ b/chat/services/scene_summarize.py @@ -342,7 +342,7 @@ async def apply_scene_close_summary( from chat.state.entities import get_bot, get_you from chat.state.group_node import get_group_node from chat.state.threads import list_open_threads - from chat.state.world import get_chat + from chat.state.world import get_chat, get_scene you_entity = get_you(conn) or {"name": "you", "persona": ""} you_name = you_entity.get("name", "you") or "you" @@ -350,6 +350,15 @@ async def apply_scene_close_summary( chat = get_chat(conn, chat_id) or {} guest_bot_id = chat.get("guest_bot_id") + # T65: detect meanwhile child scenes via the migration-0011 + # ``present_set_kind`` column. The mechanism is a single field read + # — meanwhile scenes carry ``"host_guest"``, regular you-scenes + # carry the default ``"you_host"``. We read this once up front so + # both the dialogue source and the post-summary digest emission + # branches can reference it. + closing_scene = get_scene(conn, scene_id) or {} + is_meanwhile = closing_scene.get("present_set_kind") == "host_guest" + dialogue = _read_recent_dialogue(conn, chat_id) # T58.1: build the "Key quotes:" suffix BEFORE the per-POV rewrites @@ -415,6 +424,36 @@ async def apply_scene_close_summary( }, ) + # T65: when the closing scene was a meanwhile child (host_guest + # present set), generate an omniscient briefing for the absent + # "you" and queue it as a pending digest. We reuse summarize_scene + # with a narrator persona so the digest text is shaped by the same + # classifier — only the ``summary`` field is consumed downstream. + # Emitted AFTER per-POV summaries land so witness memories carry + # their own POV text first; this mirrors how group_node_updated is + # ordered relative to the per-POV writes above. + if is_meanwhile: + digest_pov = await summarize_scene( + client, + model=classifier_model, + bot_name="Narrator", + bot_persona=_MEANWHILE_DIGEST_PERSONA, + you_name=you_name, + prior_edge_summary="", + dialogue=dialogue, + timeout_s=timeout_s, + ) + if digest_pov.summary: + append_and_apply( + conn, + kind="meanwhile_digest_created", + payload={ + "chat_id": chat_id, + "scene_id": scene_id, + "summary": digest_pov.summary, + }, + ) + # T58.2: thread detection on close. Reuses the dialogue we already # gathered for per-POV summarization — same {speaker, text} shape # detect_threads expects. Failure-tolerant: classify() returns the @@ -491,6 +530,18 @@ _GROUP_MERGE_SYSTEM = ( ) +# T65: meanwhile-scene digest. The "you" player was absent during this +# scene; the digest is a short neutral briefing they'll read on the next +# you-scene resume. Reuses the ScenePOVSummary schema so the same +# `summarize_scene` helper can be called with a different persona — only +# the ``summary`` field is used downstream. +_MEANWHILE_DIGEST_PERSONA = ( + "an omniscient narrator briefing the absent player in 2-3 neutral " + "sentences on what happened while they were away — no editorializing, " + "no second-person address" +) + + async def merge_group_summary( client: LLMClient, *, diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py index 2453b92..6984b5c 100644 --- a/tests/test_per_pov_summary.py +++ b/tests/test_per_pov_summary.py @@ -1095,3 +1095,326 @@ async def test_thread_detection_emits_events(tmp_path, monkeypatch): open_threads = list_open_threads(conn, "chat_bot_a") assert len(open_threads) == 1 assert open_threads[0]["title"] == "Test thread" + + +# --------------------------------------------------------------------------- +# T65: meanwhile summary digest emitted on meanwhile-scene close, surfaced in +# the next you-scene's prompt as a SHOULD-tier "Meanwhile while you were away:" +# block, then consumed so it never re-renders. +# --------------------------------------------------------------------------- + + +def _seed_meanwhile_scene(conn) -> None: + """Seed a parent you-scene + a meanwhile child scene with one assistant + turn so apply_scene_close_summary has dialogue to summarize. + + The meanwhile scene id is 2 (parent is scene 1). The meanwhile dialogue + is appended via assistant_turn events under chat_bot_a; the + _read_recent_dialogue helper picks them up by chat_id. + """ + import chat.state.meanwhile # noqa: F401 -- register handlers + + 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": {}, + }, + ) + # Parent you-scene (scene_id=1). + 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"], + }, + ) + # Meanwhile child scene (scene_id=2) — bot_a + bot_b only. + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + # Edges so per-POV apply has rows to update. + 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 in the meanwhile scene. + append_event( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "scene_id": 2, + "pov_summary": "Original raw narrative (host, meanwhile)", + "witness_you": 0, + "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": 2, + "pov_summary": "Original raw narrative (guest, meanwhile)", + "witness_you": 0, + "witness_host": 1, + "witness_guest": 1, + "significance": 1, + }, + ) + # A bot-bot turn happens during the meanwhile scene. + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_a", + "text": "Did you hear what happened with the missing file?", + "truncated": False, + "user_turn_id": None, + }, + ) + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_b", + "text": "I have a theory but no proof yet.", + "truncated": False, + "user_turn_id": None, + }, + ) + + +@pytest.mark.asyncio +async def test_meanwhile_close_creates_digest(tmp_path): + """When apply_scene_close_summary runs on a meanwhile scene + (present_set_kind == 'host_guest'), it emits a meanwhile_digest_created + event after the per-POV summaries land; the meanwhile_digest_pending + table then holds a row with non-empty summary text.""" + db = tmp_path / "t.db" + apply_migrations(db) + host_canned = json.dumps( + { + "summary": "BotA confided in BotB about the missing file.", + "knowledge_facts": [], + "relationship_summary": "BotA leaned on BotB.", + } + ) + guest_canned = json.dumps( + { + "summary": "BotB listened and offered to help investigate.", + "knowledge_facts": [], + "relationship_summary": "BotB grew protective.", + } + ) + digest_canned = json.dumps( + { + "summary": ( + "While you were away, BotA confided in BotB about a " + "missing file; BotB offered to help quietly investigate." + ), + "knowledge_facts": [], + "relationship_summary": "", + } + ) + no_threads = json.dumps({"candidates": []}) + with open_db(db) as conn: + _seed_meanwhile_scene(conn) + project(conn) + + # Order: host POV summary, guest POV summary, digest summary, + # thread detection. + client = MockLLMClient( + canned=[host_canned, guest_canned, digest_canned, no_threads] + ) + await apply_scene_close_summary( + conn, + client, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=2, + host_bot_id="bot_a", + ) + + # The meanwhile_digest_pending row was written. + from chat.state.meanwhile import list_pending_meanwhile_digests + + pending = list_pending_meanwhile_digests(conn, "chat_bot_a") + assert len(pending) == 1 + assert pending[0]["scene_id"] == 2 + assert pending[0]["summary"] + assert "missing file" in pending[0]["summary"] + + # And the meanwhile_digest_created event was logged. + rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'meanwhile_digest_created'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["chat_id"] == "chat_bot_a" + assert payload["scene_id"] == 2 + assert "missing file" in payload["summary"] + + +def test_pending_digest_renders_in_you_scene_prompt(tmp_path): + """A pending meanwhile digest (created via direct event append) renders + as a 'Meanwhile while you were away:' SHOULD-tier block in the + assembled you-scene narrative prompt.""" + from chat.eventlog.log import append_and_apply + from chat.services.prompt import assemble_narrative_prompt + import chat.state.meanwhile # noqa: F401 -- register handlers + import chat.state.threads # noqa: F401 + import chat.state.events # noqa: F401 + + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_single_bot_scene(conn) + project(conn) + + digest_text = ( + "While you were away, BotA confided in BotB about a missing file." + ) + append_and_apply( + conn, + kind="meanwhile_digest_created", + payload={ + "chat_id": "chat_bot_a", + "scene_id": 2, + "summary": digest_text, + }, + ) + + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + assert "Meanwhile while you were away:" in body + assert digest_text in body + + +def test_consumed_digest_does_not_render_again(tmp_path): + """After meanwhile_digest_consumed lands for a digest, reassembling the + you-scene prompt must NOT include that digest's text — the pending + list is filtered by ``consumed_at IS NULL``.""" + from chat.eventlog.log import append_and_apply + from chat.services.prompt import assemble_narrative_prompt + import chat.state.meanwhile # noqa: F401 -- register handlers + import chat.state.threads # noqa: F401 + import chat.state.events # noqa: F401 + + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_single_bot_scene(conn) + project(conn) + + digest_text = ( + "While you were away, BotA confided in BotB about a missing file." + ) + append_and_apply( + conn, + kind="meanwhile_digest_created", + payload={ + "chat_id": "chat_bot_a", + "scene_id": 2, + "summary": digest_text, + }, + ) + + # Sanity: it renders before consumption. + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + assert digest_text in msgs[0].content + + # Look up the pending digest id, then consume it. + from chat.state.meanwhile import list_pending_meanwhile_digests + + pending = list_pending_meanwhile_digests(conn, "chat_bot_a") + assert len(pending) == 1 + digest_id = pending[0]["id"] + + append_and_apply( + conn, + kind="meanwhile_digest_consumed", + payload={ + "digest_id": digest_id, + "consumed_at": "2026-04-26T20:30:00+00:00", + }, + ) + + msgs2 = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body2 = msgs2[0].content + assert "Meanwhile while you were away:" not in body2 + assert digest_text not in body2