refactor: single ACTIVITIES: block with bullet-level trim (T71.2)
Phase 2 T43 added a SECOND ACTIVITIES: block to render guest activity separately from you+speaker. Two consecutive ACTIVITIES: headers can read as a duplicate-section bug to the LLM and bloat the prompt. Consolidate to a single ACTIVITIES: block whose body is composed from up to three bullets (you, speaker, guest). The block itself is MUST-tier (always renders); bullet-level trim drops bullets in the order guest -> group node -> you -> other edges, with the speaker bullet as the MUST-tier floor (the speaker's own current activity is the load-bearing slice). Implementation chose Option B from the polish plan: pre-truncate the bullets list at trim time before _build_activity_block runs, rather than introduce a granular tier mode in the trim machinery. Rationale documented in code; the existing block-level trim ladder gains a single new toggle (include_you_activity) and the SHOULD-tier guest_activity_block is gone. Tests: - test_single_activities_block_with_three_bullets_when_3_entities: exactly one ACTIVITIES: header with all three entity bullets. - test_tight_budget_drops_guest_activity_bullet_first: speaker bullet survives, guest bullet absent under tight budget. - Existing test_assemble_with_tight_budget_drops_guest_activity_first still passes (asserts on bullet absence, not block-header absence).
This commit is contained in:
+92
-40
@@ -368,34 +368,57 @@ def assemble_narrative_prompt(
|
|||||||
addressee_name,
|
addressee_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Activity for present entities. Core (MUST): you + speaker bot.
|
# Activity for present entities — single ACTIVITIES: block with up
|
||||||
# Phase 2 (SHOULD-tier): when a third party (guest) is present in
|
# to three bullets (you, speaker, guest). The block itself is
|
||||||
# the chat, append their activity in a separate block so it can be
|
# MUST-tier and survives all trims, but bullet-level trim drops
|
||||||
# trimmed independently under tight budget.
|
# bullets in the order guest -> you, keeping the speaker bullet
|
||||||
activities: list[dict] = []
|
# (the speaker's own current activity is the load-bearing slice).
|
||||||
|
#
|
||||||
|
# T71.2 chose Option B from the polish plan: pre-truncate the
|
||||||
|
# bullets list at trim time before _build_activity_block runs,
|
||||||
|
# rather than introducing a granular tier mode in the trim
|
||||||
|
# machinery. The single-block render avoids the dual-ACTIVITIES:
|
||||||
|
# header that Phase 2 T43 introduced (read by some LLMs as a
|
||||||
|
# duplicate-section bug).
|
||||||
|
you_activity: dict | None = None
|
||||||
you_act = get_activity(conn, "you")
|
you_act = get_activity(conn, "you")
|
||||||
if you_act is not None:
|
if you_act is not None:
|
||||||
you_act = dict(you_act)
|
you_activity = dict(you_act)
|
||||||
you_act["_display_name"] = (you or {}).get("name") or "you"
|
you_activity["_display_name"] = (you or {}).get("name") or "you"
|
||||||
activities.append(you_act)
|
|
||||||
|
speaker_activity: dict | None = None
|
||||||
bot_act = get_activity(conn, speaker_bot_id)
|
bot_act = get_activity(conn, speaker_bot_id)
|
||||||
if bot_act is not None:
|
if bot_act is not None:
|
||||||
bot_act = dict(bot_act)
|
speaker_activity = dict(bot_act)
|
||||||
bot_act["_display_name"] = bot["name"]
|
speaker_activity["_display_name"] = bot["name"]
|
||||||
activities.append(bot_act)
|
|
||||||
activity_block = _build_activity_block(activities)
|
|
||||||
|
|
||||||
# SHOULD-tier guest activity extension (Phase 2 / Task 43).
|
guest_activity: dict | None = None
|
||||||
guest_activity_block: str | None = None
|
|
||||||
if guest_id is not None:
|
if guest_id is not None:
|
||||||
guest_act = get_activity(conn, guest_id)
|
guest_act = get_activity(conn, guest_id)
|
||||||
if guest_act is not None:
|
if guest_act is not None:
|
||||||
guest_act = dict(guest_act)
|
guest_activity = dict(guest_act)
|
||||||
guest_bot = get_bot(conn, guest_id)
|
guest_bot = get_bot(conn, guest_id)
|
||||||
guest_act["_display_name"] = (
|
guest_activity["_display_name"] = (
|
||||||
guest_bot["name"] if guest_bot else guest_id
|
guest_bot["name"] if guest_bot else guest_id
|
||||||
)
|
)
|
||||||
guest_activity_block = _build_activity_block([guest_act])
|
|
||||||
|
def _activity_block_for(
|
||||||
|
*, include_you: bool, include_guest: bool
|
||||||
|
) -> str | None:
|
||||||
|
"""Render the single ACTIVITIES: block with the requested bullets.
|
||||||
|
|
||||||
|
Speaker bullet is always included (it's the MUST-tier baseline);
|
||||||
|
``you`` and ``guest`` bullets are toggled by the caller during
|
||||||
|
trim. Returns None when no bullets remain.
|
||||||
|
"""
|
||||||
|
bullets: list[dict] = []
|
||||||
|
if include_you and you_activity is not None:
|
||||||
|
bullets.append(you_activity)
|
||||||
|
if speaker_activity is not None:
|
||||||
|
bullets.append(speaker_activity)
|
||||||
|
if include_guest and guest_activity is not None:
|
||||||
|
bullets.append(guest_activity)
|
||||||
|
return _build_activity_block(bullets)
|
||||||
|
|
||||||
# SHOULD-tier group-node block (Phase 2 / Task 43): rendered only
|
# SHOULD-tier group-node block (Phase 2 / Task 43): rendered only
|
||||||
# when the group_node row is present AND it covers all three of
|
# when the group_node row is present AND it covers all three of
|
||||||
@@ -469,11 +492,18 @@ def assemble_narrative_prompt(
|
|||||||
last4 = dialogue_full[-4:] if dialogue_full else []
|
last4 = dialogue_full[-4:] if dialogue_full else []
|
||||||
must_dialogue_block = _build_dialogue_block(last4, earlier_summary=None)
|
must_dialogue_block = _build_dialogue_block(last4, earlier_summary=None)
|
||||||
|
|
||||||
|
# MUST-tier ACTIVITIES floor: the speaker bullet alone (you and
|
||||||
|
# guest bullets are dropped first under bullet-level trim before
|
||||||
|
# the block bottoms out at speaker-only).
|
||||||
|
must_activity_block = _activity_block_for(
|
||||||
|
include_you=False, include_guest=False
|
||||||
|
)
|
||||||
|
|
||||||
must_blocks: list[str | None] = [
|
must_blocks: list[str | None] = [
|
||||||
speaker_identity,
|
speaker_identity,
|
||||||
edge_to_addressee,
|
edge_to_addressee,
|
||||||
scene_block,
|
scene_block,
|
||||||
activity_block,
|
must_activity_block,
|
||||||
must_dialogue_block,
|
must_dialogue_block,
|
||||||
closing,
|
closing,
|
||||||
]
|
]
|
||||||
@@ -498,6 +528,7 @@ def assemble_narrative_prompt(
|
|||||||
include_previous_scene: bool,
|
include_previous_scene: bool,
|
||||||
include_memories_top_k: int,
|
include_memories_top_k: int,
|
||||||
dialogue_keep: int,
|
dialogue_keep: int,
|
||||||
|
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,
|
||||||
) -> tuple[str, int, list[dict]]:
|
) -> tuple[str, int, list[dict]]:
|
||||||
@@ -520,13 +551,20 @@ def assemble_narrative_prompt(
|
|||||||
if include_previous_scene else None
|
if include_previous_scene else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Single ACTIVITIES: block, bullet-level trim (T71.2). Guest
|
||||||
|
# bullet drops first, then the you bullet; speaker bullet is the
|
||||||
|
# MUST-tier floor and always present when an activity row exists.
|
||||||
|
activity_block = _activity_block_for(
|
||||||
|
include_you=include_you_activity,
|
||||||
|
include_guest=include_guest_activity,
|
||||||
|
)
|
||||||
|
|
||||||
body = _join_blocks([
|
body = _join_blocks([
|
||||||
speaker_identity,
|
speaker_identity,
|
||||||
edge_to_addressee,
|
edge_to_addressee,
|
||||||
other_edges_block if include_other_edges else None,
|
other_edges_block if include_other_edges else None,
|
||||||
scene_block,
|
scene_block,
|
||||||
activity_block,
|
activity_block,
|
||||||
guest_activity_block if include_guest_activity else None,
|
|
||||||
group_node_block if include_group_node else None,
|
group_node_block if include_group_node else None,
|
||||||
prev_block,
|
prev_block,
|
||||||
memories_block,
|
memories_block,
|
||||||
@@ -544,16 +582,18 @@ def assemble_narrative_prompt(
|
|||||||
nice_memories_k = min(4, len(memory_summaries))
|
nice_memories_k = min(4, len(memory_summaries))
|
||||||
include_prev = previous_scene_summary is not None
|
include_prev = previous_scene_summary is not None
|
||||||
include_other = other_edges_block is not None
|
include_other = other_edges_block is not None
|
||||||
include_guest_activity = guest_activity_block is not None
|
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_group_node = group_node_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,
|
||||||
guest_act: bool, group: bool) -> tuple[str, int]:
|
you_act: bool, guest_act: bool, group: 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,
|
||||||
include_memories_top_k=mem_k,
|
include_memories_top_k=mem_k,
|
||||||
dialogue_keep=dlg,
|
dialogue_keep=dlg,
|
||||||
|
include_you_activity=you_act,
|
||||||
include_guest_activity=guest_act,
|
include_guest_activity=guest_act,
|
||||||
include_group_node=group,
|
include_group_node=group,
|
||||||
)
|
)
|
||||||
@@ -561,8 +601,8 @@ def assemble_narrative_prompt(
|
|||||||
|
|
||||||
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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If under soft, we're done.
|
# If under soft, we're done.
|
||||||
@@ -575,8 +615,8 @@ def assemble_narrative_prompt(
|
|||||||
include_prev = False
|
include_prev = 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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
return _emit(body, user_turn_prose)
|
||||||
@@ -585,8 +625,8 @@ def assemble_narrative_prompt(
|
|||||||
nice_memories_k = 2
|
nice_memories_k = 2
|
||||||
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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
return _emit(body, user_turn_prose)
|
||||||
@@ -595,8 +635,8 @@ def assemble_narrative_prompt(
|
|||||||
nice_dialogue_keep = baseline_keep
|
nice_dialogue_keep = baseline_keep
|
||||||
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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
return _emit(body, user_turn_prose)
|
||||||
@@ -606,35 +646,47 @@ def assemble_narrative_prompt(
|
|||||||
nice_memories_k = max(0, nice_memories_k - 1)
|
nice_memories_k = max(0, nice_memories_k - 1)
|
||||||
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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Drop SHOULD-tier blocks in order: guest activity → group node →
|
# Drop SHOULD-tier extras in order:
|
||||||
# other edges. (Guest activity goes first per Task 43 spec — it's
|
# 1. guest activity bullet (T71.2: bullet-level trim within the
|
||||||
# the most expendable additive context.)
|
# 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
|
||||||
|
# MUST-tier floor and never dropped)
|
||||||
|
# 4. other edges
|
||||||
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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_group_node and total > budget_hard:
|
if include_group_node and total > budget_hard:
|
||||||
include_group_node = False
|
include_group_node = 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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
|
)
|
||||||
|
|
||||||
|
if include_you_activity and total > budget_hard:
|
||||||
|
include_you_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,
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_other and total > budget_hard:
|
if include_other and total > budget_hard:
|
||||||
include_other = False
|
include_other = 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, guest_act=include_guest_activity,
|
other=include_other, you_act=include_you_activity,
|
||||||
group=include_group_node,
|
guest_act=include_guest_activity, group=include_group_node,
|
||||||
)
|
)
|
||||||
|
|
||||||
if total > budget_hard:
|
if total > budget_hard:
|
||||||
|
|||||||
@@ -514,6 +514,66 @@ def test_speaker_is_host_uses_host_witness_role(tmp_path, monkeypatch):
|
|||||||
assert captured["witness_role"] == "host"
|
assert captured["witness_role"] == "host"
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_activities_block_with_three_bullets_when_3_entities(tmp_path):
|
||||||
|
"""T71.2: with you + host + guest present, the assembled prompt
|
||||||
|
contains exactly ONE ``ACTIVITIES:`` header and bullets for all
|
||||||
|
three entities (no duplicate header from the prior dual-block
|
||||||
|
rendering).
|
||||||
|
"""
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
_seed_with_guest(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
|
||||||
|
# Exactly one ACTIVITIES: header.
|
||||||
|
assert body.count("ACTIVITIES:") == 1
|
||||||
|
# Bullets for all three entities (you=Sam, host=Aria, guest=Iris)
|
||||||
|
# — pin on the unique action verbs from the seed data.
|
||||||
|
assert "finishing emails" in body # you bullet
|
||||||
|
assert "pretending to work" in body # speaker (host) bullet
|
||||||
|
assert "smirking-distinctively" in body # guest bullet
|
||||||
|
|
||||||
|
|
||||||
|
def test_tight_budget_drops_guest_activity_bullet_first(tmp_path):
|
||||||
|
"""T71.2: under tight budget the speaker bullet survives but the
|
||||||
|
guest activity bullet is the first ACTIVITIES: bullet to drop. The
|
||||||
|
block as a whole stays present (it's MUST-tier); only its body
|
||||||
|
contracts.
|
||||||
|
"""
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
_seed_with_guest(conn)
|
||||||
|
dialogue = [
|
||||||
|
{"speaker": "you", "text": "line-16 hi there"},
|
||||||
|
{"speaker": "bot_a", "text": "line-17 hey"},
|
||||||
|
{"speaker": "you", "text": "line-18 quiet night"},
|
||||||
|
{"speaker": "bot_a", "text": "line-19 indeed"},
|
||||||
|
]
|
||||||
|
msgs = assemble_narrative_prompt(
|
||||||
|
conn,
|
||||||
|
chat_id="chat_bot_a",
|
||||||
|
speaker_bot_id="bot_a",
|
||||||
|
recent_dialogue=dialogue,
|
||||||
|
retrieved_memory_summaries=[],
|
||||||
|
budget_soft=250,
|
||||||
|
budget_hard=340,
|
||||||
|
)
|
||||||
|
body = msgs[0].content
|
||||||
|
# Speaker bullet survives (MUST-tier floor).
|
||||||
|
assert "pretending to work" in body
|
||||||
|
assert "ACTIVITIES:" in body
|
||||||
|
# Guest bullet is dropped first under budget pressure.
|
||||||
|
assert "smirking-distinctively" not in body
|
||||||
|
|
||||||
|
|
||||||
def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path):
|
def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path):
|
||||||
"""Under tight budget MUST blocks survive but SHOULD-tier guest
|
"""Under tight budget MUST blocks survive but SHOULD-tier guest
|
||||||
activity is dropped first."""
|
activity is dropped first."""
|
||||||
|
|||||||
Reference in New Issue
Block a user