From 21c4ffa63c7e41060cd2759e6a8e6c561df836b8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:34:26 -0400 Subject: [PATCH] feat: prompt assembly renders active events + open threads (T60) --- chat/services/prompt.py | 132 ++++++++++++++++++++++++++++++++++++++-- tests/test_prompt.py | 93 +++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 6 deletions(-) diff --git a/chat/services/prompt.py b/chat/services/prompt.py index 6e6d72c..6f836dc 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -37,8 +37,10 @@ import tiktoken from chat.llm.client import Message 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.memory import search_memories +from chat.state.threads import list_open_threads from chat.state.world import ( active_scene, get_activity, @@ -227,6 +229,76 @@ def _build_group_node_block(group_node: dict | None) -> str | None: return "\n".join(lines) +def _props_excerpt(props: dict | None, limit: int = 80) -> str: + """Return a one-line excerpt of an event's ``props`` dict. + + Renders ``key=value`` pairs separated by ", " (deterministic by dict + insertion order) and truncates to ~``limit`` characters with a + trailing ellipsis. Returns empty string for falsy/empty props so the + caller can omit the line entirely. + """ + if not props: + return "" + pieces: list[str] = [] + for k, v in props.items(): + pieces.append(f"{k}={v}") + rendered = ", ".join(pieces) + if len(rendered) > limit: + # Reserve 1 char for the ellipsis so the total never exceeds limit. + rendered = rendered[: max(0, limit - 1)] + "…" + return rendered + + +def _build_active_events_block(events: list[dict]) -> str | None: + """Render the ``Active events:`` block for Phase 3 Task 60. + + One bullet per event. The sub-label depends on status: + - ``planned`` → ``(planned for {planned_for})`` + - ``active`` → ``(active, started_at={started_at})`` + A second indented line carries a one-line excerpt of the event's + ``props`` (truncated ~80 chars) when non-empty. Returns ``None`` when + there are no active events so the caller can omit the entire block. + """ + if not events: + return None + lines = ["Active events:"] + for ev in events: + kind = ev.get("kind") or "?" + status = ev.get("status") or "" + if status == "active": + started = ev.get("started_at") or "" + lines.append(f"- {kind} (active, started_at={started})") + else: + planned = ev.get("planned_for") or "" + lines.append(f"- {kind} (planned for {planned})") + excerpt = _props_excerpt(ev.get("props")) + if excerpt: + lines.append(f" {excerpt}") + return "\n".join(lines) + + +def _build_open_threads_block(threads: list[dict]) -> str | None: + """Render the ``Open threads:`` block for Phase 3 Task 60. + + One bullet per thread, formatted as ``- {title}: {summary}`` with the + summary truncated to ~120 characters. Returns ``None`` when there are + no open threads so the caller can omit the entire block. + """ + if not threads: + return None + lines = ["Open threads:"] + for t in threads: + title = t.get("title") or "?" + summary = t.get("summary") or "" + if len(summary) > 120: + summary = summary[:119] + "…" + if summary: + lines.append(f"- {title}: {summary}") + else: + lines.append(f"- {title}") + return "\n".join(lines) + + def _closing_instruction(speaker_name: str, addressee_name: str) -> str: return ( f"Continue the scene as {speaker_name}, in their voice, responding " @@ -436,6 +508,17 @@ def assemble_narrative_prompt( if required.issubset(members): group_node_block = _build_group_node_block(gn) + # SHOULD-tier active events + open threads (Phase 3 / Task 60). + # Auto-detect both from the chat_id per the Phase 2 T43 precedent — + # no new function parameter. Both blocks are omit-when-empty so a + # Phase 1 chat with no events/threads renders identically to before. + active_events_block = _build_active_events_block( + list_active_events(conn, chat_id) + ) + open_threads_block = _build_open_threads_block( + list_open_threads(conn, chat_id) + ) + container = None if chat.get("active_scene_id"): scene = get_scene(conn, chat["active_scene_id"]) @@ -531,6 +614,8 @@ def assemble_narrative_prompt( include_you_activity: bool = True, include_guest_activity: bool = True, include_group_node: bool = True, + include_active_events: bool = True, + include_open_threads: bool = True, ) -> tuple[str, int, list[dict]]: # dialogue: keep the last `dialogue_keep` turns verbatim; older # turns become an "earlier:" placeholder line. @@ -566,6 +651,8 @@ def assemble_narrative_prompt( scene_block, activity_block, 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, prev_block, memories_block, dialogue_block, @@ -585,9 +672,12 @@ def assemble_narrative_prompt( include_you_activity = you_activity is not None include_guest_activity = guest_activity is not None 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 def _build(*, prev: bool, mem_k: int, dlg: int, other: bool, - you_act: bool, guest_act: bool, group: bool) -> tuple[str, int]: + you_act: bool, guest_act: bool, group: bool, + events: bool, threads: bool) -> tuple[str, int]: body, total, _ = assemble( include_other_edges=other, include_previous_scene=prev, @@ -596,6 +686,8 @@ def assemble_narrative_prompt( include_you_activity=you_act, include_guest_activity=guest_act, include_group_node=group, + include_active_events=events, + include_open_threads=threads, ) return body, total @@ -603,6 +695,7 @@ def assemble_narrative_prompt( 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, ) # If under soft, we're done. @@ -637,6 +730,7 @@ def assemble_narrative_prompt( 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, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -647,6 +741,7 @@ def assemble_narrative_prompt( 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, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -657,6 +752,7 @@ def assemble_narrative_prompt( 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, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -668,21 +764,44 @@ def assemble_narrative_prompt( 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, ) # Drop SHOULD-tier extras in order: - # 1. guest activity bullet (T71.2: bullet-level trim within the + # 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 # single ACTIVITIES: block — guest goes first per Task 43 spec) - # 2. group node block - # 3. you activity bullet (still SHOULD-tier; speaker bullet is the + # 4. group node block + # 5. you activity bullet (still SHOULD-tier; speaker bullet is the # MUST-tier floor and never dropped) - # 4. other edges + # 6. other edges + if include_open_threads and total > budget_hard: + include_open_threads = 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, + ) + + if include_active_events and total > budget_hard: + include_active_events = 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, + ) + if include_guest_activity and total > budget_hard: include_guest_activity = 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, ) if include_group_node and total > budget_hard: @@ -691,6 +810,7 @@ def assemble_narrative_prompt( 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, ) if include_you_activity and total > budget_hard: @@ -699,6 +819,7 @@ def assemble_narrative_prompt( 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, ) if include_other and total > budget_hard: @@ -707,6 +828,7 @@ def assemble_narrative_prompt( 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, ) if total > budget_hard: diff --git a/tests/test_prompt.py b/tests/test_prompt.py index f50fdea..e8fc30d 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -12,12 +12,14 @@ import pytest from chat.db.connection import open_db from chat.db.migrate import apply_migrations -from chat.eventlog.log import append_event +from chat.eventlog.log import append_and_apply, append_event from chat.eventlog.projector import project import chat.state.entities # noqa: F401 (registers handlers) import chat.state.edges # noqa: F401 import chat.state.memory # noqa: F401 import chat.state.world # noqa: F401 +import chat.state.events # noqa: F401 +import chat.state.threads # noqa: F401 from chat.llm.client import Message from chat.services.prompt import assemble_narrative_prompt @@ -761,3 +763,92 @@ def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path): import tiktoken enc = tiktoken.get_encoding("cl100k_base") assert len(enc.encode(body)) <= 340 + + +# --------------------------------------------------------------------------- +# Task 60: Active events + open threads in prompt assembly +# --------------------------------------------------------------------------- + + +def test_assemble_with_no_events_or_threads_omits_blocks(tmp_path): + """Regression: with the basic 2-entity scenario (no events seeded, no + threads seeded), the assembled prompt must NOT contain the + ``Active events:`` or ``Open threads:`` headers — both blocks are + omit-when-empty.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_basic(conn) + 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 "Active events:" not in body + assert "Open threads:" not in body + + +def test_assemble_with_active_events_renders_block(tmp_path): + """Seed a planned event then transition it to active; the assembled + prompt should render the ``Active events:`` block listing the event + by kind.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_basic(conn) + # event_planned then event_started → status="active". Use + # append_and_apply because _seed_basic already projected; calling + # project() again would replay every prior event (and trip + # UNIQUE constraints on chat_created etc.). + append_and_apply(conn, kind="event_planned", payload={ + "event_id": "evt_park", + "chat_id": "chat_bot_a", + "kind": "date_at_park", + "props": {"location": "Riverside Park", "vibe": "casual"}, + "planned_for": "2026-04-30T18:00:00+00:00", + }) + append_and_apply(conn, kind="event_started", payload={ + "event_id": "evt_park", + "started_at": "2026-04-30T18:05:00+00:00", + }) + 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 "Active events:" in body + assert "date_at_park" in body + + +def test_assemble_with_open_thread_renders_block(tmp_path): + """Seed a single open thread; the assembled prompt should render the + ``Open threads:`` block listing the thread by title.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_basic(conn) + # _seed_basic already projected; use append_and_apply for the + # post-seed event so we don't re-trigger UNIQUE constraint + # collisions on the prior chat_created/etc. events. + append_and_apply(conn, kind="thread_opened", payload={ + "thread_id": "thr_job", + "chat_id": "chat_bot_a", + "title": "Maya's job hunt", + "summary": "Maya is looking for a new job", + }) + 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 "Open threads:" in body + assert "Maya's job hunt" in body