feat: meanwhile summary digest surfaces to next you-scene (T65)

This commit is contained in:
Joseph Doherty
2026-04-26 20:59:35 -04:00
parent c9d58b8229
commit a781732ee6
3 changed files with 502 additions and 10 deletions
+127 -9
View File
@@ -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.
@@ -519,6 +545,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"])
@@ -616,6 +668,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.
@@ -653,6 +706,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,
@@ -674,10 +731,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,
@@ -688,6 +747,7 @@ def assemble_narrative_prompt(
include_group_node=group,
include_active_events=events,
include_open_threads=threads,
include_meanwhile_digests=digests,
)
return body, total
@@ -696,6 +756,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.
@@ -731,6 +792,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)
@@ -742,6 +804,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)
@@ -753,6 +816,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)
@@ -765,18 +829,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(
@@ -784,6 +862,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:
@@ -793,6 +872,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:
@@ -802,6 +882,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:
@@ -811,6 +892,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:
@@ -820,6 +902,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:
@@ -829,6 +912,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:
@@ -854,4 +938,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",
]
+52 -1
View File
@@ -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,
*,
+323
View File
@@ -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