diff --git a/chat/services/prompt.py b/chat/services/prompt.py index 5ed4905..c820136 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -368,34 +368,57 @@ def assemble_narrative_prompt( addressee_name, ) - # Activity for present entities. Core (MUST): you + speaker bot. - # Phase 2 (SHOULD-tier): when a third party (guest) is present in - # the chat, append their activity in a separate block so it can be - # trimmed independently under tight budget. - activities: list[dict] = [] + # Activity for present entities — single ACTIVITIES: block with up + # to three bullets (you, speaker, guest). The block itself is + # MUST-tier and survives all trims, but bullet-level trim drops + # bullets in the order guest -> you, keeping the speaker bullet + # (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") if you_act is not None: - you_act = dict(you_act) - you_act["_display_name"] = (you or {}).get("name") or "you" - activities.append(you_act) + you_activity = dict(you_act) + you_activity["_display_name"] = (you or {}).get("name") or "you" + + speaker_activity: dict | None = None bot_act = get_activity(conn, speaker_bot_id) if bot_act is not None: - bot_act = dict(bot_act) - bot_act["_display_name"] = bot["name"] - activities.append(bot_act) - activity_block = _build_activity_block(activities) + speaker_activity = dict(bot_act) + speaker_activity["_display_name"] = bot["name"] - # SHOULD-tier guest activity extension (Phase 2 / Task 43). - guest_activity_block: str | None = None + guest_activity: dict | None = None if guest_id is not None: guest_act = get_activity(conn, guest_id) if guest_act is not None: - guest_act = dict(guest_act) + guest_activity = dict(guest_act) 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_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 # 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 [] 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] = [ speaker_identity, edge_to_addressee, scene_block, - activity_block, + must_activity_block, must_dialogue_block, closing, ] @@ -498,6 +528,7 @@ def assemble_narrative_prompt( include_previous_scene: bool, include_memories_top_k: int, dialogue_keep: int, + include_you_activity: bool = True, include_guest_activity: bool = True, include_group_node: bool = True, ) -> tuple[str, int, list[dict]]: @@ -520,13 +551,20 @@ def assemble_narrative_prompt( 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([ speaker_identity, edge_to_addressee, other_edges_block if include_other_edges else None, scene_block, activity_block, - guest_activity_block if include_guest_activity else None, group_node_block if include_group_node else None, prev_block, memories_block, @@ -544,16 +582,18 @@ def assemble_narrative_prompt( nice_memories_k = min(4, len(memory_summaries)) include_prev = previous_scene_summary 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 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( include_other_edges=other, include_previous_scene=prev, include_memories_top_k=mem_k, dialogue_keep=dlg, + include_you_activity=you_act, include_guest_activity=guest_act, include_group_node=group, ) @@ -561,8 +601,8 @@ def assemble_narrative_prompt( body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) # If under soft, we're done. @@ -575,8 +615,8 @@ def assemble_narrative_prompt( include_prev = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -585,8 +625,8 @@ def assemble_narrative_prompt( nice_memories_k = 2 body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -595,8 +635,8 @@ def assemble_narrative_prompt( nice_dialogue_keep = baseline_keep body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -606,35 +646,47 @@ def assemble_narrative_prompt( nice_memories_k = max(0, nice_memories_k - 1) body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) - # Drop SHOULD-tier blocks in order: guest activity → group node → - # other edges. (Guest activity goes first per Task 43 spec — it's - # the most expendable additive context.) + # Drop SHOULD-tier extras in order: + # 1. 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 + # MUST-tier floor and never dropped) + # 4. other edges 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, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if include_group_node and total > budget_hard: include_group_node = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + 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: include_other = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total > budget_hard: diff --git a/tests/test_prompt.py b/tests/test_prompt.py index d4fc6a5..322ddab 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -514,6 +514,66 @@ def test_speaker_is_host_uses_host_witness_role(tmp_path, monkeypatch): 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): """Under tight budget MUST blocks survive but SHOULD-tier guest activity is dropped first."""