feat: prompt assembly renders active events + open threads (T60)

This commit is contained in:
Joseph Doherty
2026-04-26 20:34:26 -04:00
parent 83f94a4325
commit 21c4ffa63c
2 changed files with 219 additions and 6 deletions
+127 -5
View File
@@ -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: