merge: T60 prompt assembly active events + open threads
This commit is contained in:
+127
-5
@@ -37,8 +37,10 @@ import tiktoken
|
|||||||
from chat.llm.client import Message
|
from chat.llm.client import Message
|
||||||
from chat.state.edges import get_edge, list_edges_for
|
from chat.state.edges import get_edge, list_edges_for
|
||||||
from chat.state.entities import get_bot, get_you
|
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.group_node import get_group_node
|
||||||
from chat.state.memory import search_memories
|
from chat.state.memory import search_memories
|
||||||
|
from chat.state.threads import list_open_threads
|
||||||
from chat.state.world import (
|
from chat.state.world import (
|
||||||
active_scene,
|
active_scene,
|
||||||
get_activity,
|
get_activity,
|
||||||
@@ -227,6 +229,76 @@ def _build_group_node_block(group_node: dict | None) -> str | None:
|
|||||||
return "\n".join(lines)
|
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:
|
def _closing_instruction(speaker_name: str, addressee_name: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"Continue the scene as {speaker_name}, in their voice, responding "
|
f"Continue the scene as {speaker_name}, in their voice, responding "
|
||||||
@@ -436,6 +508,17 @@ def assemble_narrative_prompt(
|
|||||||
if required.issubset(members):
|
if required.issubset(members):
|
||||||
group_node_block = _build_group_node_block(gn)
|
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
|
container = None
|
||||||
if chat.get("active_scene_id"):
|
if chat.get("active_scene_id"):
|
||||||
scene = get_scene(conn, chat["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_you_activity: bool = True,
|
||||||
include_guest_activity: bool = True,
|
include_guest_activity: bool = True,
|
||||||
include_group_node: bool = True,
|
include_group_node: bool = True,
|
||||||
|
include_active_events: bool = True,
|
||||||
|
include_open_threads: bool = True,
|
||||||
) -> tuple[str, int, list[dict]]:
|
) -> tuple[str, int, list[dict]]:
|
||||||
# dialogue: keep the last `dialogue_keep` turns verbatim; older
|
# dialogue: keep the last `dialogue_keep` turns verbatim; older
|
||||||
# turns become an "earlier:" placeholder line.
|
# turns become an "earlier:" placeholder line.
|
||||||
@@ -566,6 +651,8 @@ def assemble_narrative_prompt(
|
|||||||
scene_block,
|
scene_block,
|
||||||
activity_block,
|
activity_block,
|
||||||
group_node_block if include_group_node else None,
|
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,
|
prev_block,
|
||||||
memories_block,
|
memories_block,
|
||||||
dialogue_block,
|
dialogue_block,
|
||||||
@@ -585,9 +672,12 @@ def assemble_narrative_prompt(
|
|||||||
include_you_activity = you_activity is not None
|
include_you_activity = you_activity is not None
|
||||||
include_guest_activity = guest_activity is not None
|
include_guest_activity = guest_activity is not None
|
||||||
include_group_node = group_node_block 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,
|
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(
|
body, total, _ = assemble(
|
||||||
include_other_edges=other,
|
include_other_edges=other,
|
||||||
include_previous_scene=prev,
|
include_previous_scene=prev,
|
||||||
@@ -596,6 +686,8 @@ def assemble_narrative_prompt(
|
|||||||
include_you_activity=you_act,
|
include_you_activity=you_act,
|
||||||
include_guest_activity=guest_act,
|
include_guest_activity=guest_act,
|
||||||
include_group_node=group,
|
include_group_node=group,
|
||||||
|
include_active_events=events,
|
||||||
|
include_open_threads=threads,
|
||||||
)
|
)
|
||||||
return body, total
|
return body, total
|
||||||
|
|
||||||
@@ -603,6 +695,7 @@ def assemble_narrative_prompt(
|
|||||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
events=include_active_events, threads=include_open_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If under soft, we're done.
|
# 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,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
events=include_active_events, threads=include_open_threads,
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
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,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
events=include_active_events, threads=include_open_threads,
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
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,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
events=include_active_events, threads=include_open_threads,
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
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,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
events=include_active_events, threads=include_open_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Drop SHOULD-tier extras in order:
|
# 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)
|
# single ACTIVITIES: block — guest goes first per Task 43 spec)
|
||||||
# 2. group node block
|
# 4. group node block
|
||||||
# 3. you activity bullet (still SHOULD-tier; speaker bullet is the
|
# 5. you activity bullet (still SHOULD-tier; speaker bullet is the
|
||||||
# MUST-tier floor and never dropped)
|
# 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:
|
if include_guest_activity and total > budget_hard:
|
||||||
include_guest_activity = False
|
include_guest_activity = False
|
||||||
body, total = _build(
|
body, total = _build(
|
||||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
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:
|
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,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
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:
|
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,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
events=include_active_events, threads=include_open_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_other and total > budget_hard:
|
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,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
other=include_other, you_act=include_you_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
guest_act=include_guest_activity, group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
events=include_active_events, threads=include_open_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
if total > budget_hard:
|
if total > budget_hard:
|
||||||
|
|||||||
+92
-1
@@ -12,12 +12,14 @@ import pytest
|
|||||||
|
|
||||||
from chat.db.connection import open_db
|
from chat.db.connection import open_db
|
||||||
from chat.db.migrate import apply_migrations
|
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
|
from chat.eventlog.projector import project
|
||||||
import chat.state.entities # noqa: F401 (registers handlers)
|
import chat.state.entities # noqa: F401 (registers handlers)
|
||||||
import chat.state.edges # noqa: F401
|
import chat.state.edges # noqa: F401
|
||||||
import chat.state.memory # noqa: F401
|
import chat.state.memory # noqa: F401
|
||||||
import chat.state.world # 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.llm.client import Message
|
||||||
from chat.services.prompt import assemble_narrative_prompt
|
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
|
import tiktoken
|
||||||
enc = tiktoken.get_encoding("cl100k_base")
|
enc = tiktoken.get_encoding("cl100k_base")
|
||||||
assert len(enc.encode(body)) <= 340
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user