feat: multi-entity prompt assembly with guest activity, edges, group node
This commit is contained in:
@@ -253,3 +253,244 @@ def test_must_exceeds_budget_hard_raises_value_error(tmp_path):
|
||||
budget_soft=5,
|
||||
budget_hard=10,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 43: multi-entity prompt assembly (guest_id support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_with_guest(conn) -> None:
|
||||
"""Seed a 3-entity scene: you (Sam) + host (Aria, bot_a) + guest (Iris, bot_b).
|
||||
|
||||
Group node row is initialized with summary + dynamic, edges in all
|
||||
relevant directions are seeded, and activities are recorded for all
|
||||
three entities.
|
||||
"""
|
||||
append_event(conn, kind="bot_authored", payload={
|
||||
"id": "bot_a",
|
||||
"name": "Aria",
|
||||
"persona": "reserved coworker who notices things",
|
||||
"voice_samples": ["I — sorry, I didn't mean to.", "Right. Of course."],
|
||||
"traits": ["introverted", "observant"],
|
||||
"backstory": "An archivist who joined the firm last spring.",
|
||||
"initial_relationship_to_you": "coworker; mild crush; never voiced",
|
||||
"kickoff_prose": "you stay late at the office",
|
||||
})
|
||||
append_event(conn, kind="bot_authored", payload={
|
||||
"id": "bot_b",
|
||||
"name": "Iris",
|
||||
"persona": "wry transplant from the Boston office",
|
||||
"voice_samples": ["Oh, please.", "Don't make me say it twice."],
|
||||
"traits": ["sardonic", "loyal"],
|
||||
"backstory": "Met Aria at a conference two years back.",
|
||||
"initial_relationship_to_you": "stranger; curious",
|
||||
"kickoff_prose": "",
|
||||
})
|
||||
append_event(conn, kind="you_authored", payload={
|
||||
"name": "Sam",
|
||||
"pronouns": "they/them",
|
||||
"persona": "tired analyst",
|
||||
})
|
||||
append_event(conn, kind="chat_created", payload={
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
})
|
||||
append_event(conn, kind="container_created", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"name": "office bullpen",
|
||||
"type": "workplace",
|
||||
"properties": {"public": False, "moving": False, "audible_range": "room"},
|
||||
})
|
||||
# Edges: host -> you, guest -> you, host -> guest, guest -> host.
|
||||
append_event(conn, kind="edge_update", payload={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"affinity_delta": 12,
|
||||
"trust_delta": 5,
|
||||
"knowledge_facts": ["they work on the same floor"],
|
||||
})
|
||||
append_event(conn, kind="edge_update", payload={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "bot_b",
|
||||
"affinity_delta": 20,
|
||||
"trust_delta": 15,
|
||||
"knowledge_facts": ["studied physics together"],
|
||||
})
|
||||
append_event(conn, kind="edge_update", payload={
|
||||
"source_id": "bot_b",
|
||||
"target_id": "you",
|
||||
"affinity_delta": 4,
|
||||
"trust_delta": 0,
|
||||
"knowledge_facts": ["Aria's coworker"],
|
||||
})
|
||||
append_event(conn, kind="edge_update", payload={
|
||||
"source_id": "bot_b",
|
||||
"target_id": "bot_a",
|
||||
"affinity_delta": 18,
|
||||
"trust_delta": 12,
|
||||
"knowledge_facts": ["former roommate"],
|
||||
})
|
||||
# Activity for all three entities — note distinct verbs so we can
|
||||
# check whose activity got dropped under tight budget.
|
||||
append_event(conn, kind="activity_change", payload={
|
||||
"entity_id": "you",
|
||||
"container_id": 1,
|
||||
"posture": "sitting at your desk",
|
||||
"action": {"verb": "finishing emails"},
|
||||
"attention": "the screen",
|
||||
"holding": ["coffee mug"],
|
||||
})
|
||||
append_event(conn, kind="activity_change", payload={
|
||||
"entity_id": "bot_a",
|
||||
"container_id": 1,
|
||||
"posture": "sitting at her desk",
|
||||
"action": {"verb": "pretending to work"},
|
||||
"attention": "you, in glances",
|
||||
})
|
||||
append_event(conn, kind="activity_change", payload={
|
||||
"entity_id": "bot_b",
|
||||
"container_id": 1,
|
||||
"posture": "leaning against the doorframe",
|
||||
"action": {"verb": "smirking-distinctively"},
|
||||
"attention": "Aria",
|
||||
})
|
||||
append_event(conn, kind="scene_opened", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"container_id": 1,
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a", "bot_b"],
|
||||
})
|
||||
append_event(conn, kind="group_node_initialized", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"members": ["you", "bot_a", "bot_b"],
|
||||
"summary": "Three coworkers catching up after hours UNIQUE-GROUP-SUMMARY.",
|
||||
"dynamic": "warm-but-prickly UNIQUE-GROUP-DYNAMIC",
|
||||
})
|
||||
project(conn)
|
||||
|
||||
|
||||
def test_assemble_with_no_guest_matches_phase1(tmp_path):
|
||||
"""Regression: 2-entity scenario without guest_id behaves exactly as Phase 1."""
|
||||
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
|
||||
# Phase 1 must blocks present.
|
||||
assert "Aria" in body
|
||||
assert "PERSONA" in body
|
||||
assert "Sam" in body
|
||||
assert "ACTIVITIES" in body
|
||||
assert "62/100" in body # speaker → addressee edge intact
|
||||
# No guest content leaks in.
|
||||
assert "Group dynamic" not in body
|
||||
assert "Iris" not in body
|
||||
|
||||
|
||||
def test_assemble_with_guest_includes_group_node_summary(tmp_path):
|
||||
"""When guest is present (auto-detected via chat.guest_bot_id) and a
|
||||
group_node row exists, its summary + dynamic are rendered."""
|
||||
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
|
||||
assert "Group dynamic" in body
|
||||
assert "UNIQUE-GROUP-SUMMARY" in body
|
||||
assert "UNIQUE-GROUP-DYNAMIC" in body
|
||||
# Guest activity also present (SHOULD-tier, fits at default budget).
|
||||
assert "smirking-distinctively" in body
|
||||
# Speaker's other edges include the host -> guest direction.
|
||||
assert "Iris" in body
|
||||
|
||||
|
||||
def test_assemble_when_speaker_is_guest_orients_edges_correctly(tmp_path):
|
||||
"""When the guest is the speaker, identity is the guest, the
|
||||
addressee edge is guest → you, and other edges include guest → host."""
|
||||
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_b", # guest as speaker
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
body = msgs[0].content
|
||||
# Speaker identity is the guest's persona.
|
||||
assert "You are Iris." in body
|
||||
assert "wry transplant from the Boston office" in body
|
||||
# Edge to addressee is guest → you (Sam) with the seeded values
|
||||
# (default 50 + 4 affinity = 54).
|
||||
assert "YOUR EDGE TO Sam" in body
|
||||
assert "54/100" in body
|
||||
# Other edges include guest → host (Aria) with seeded value
|
||||
# (default 50 + 18 = 68).
|
||||
assert "OTHER EDGES" in body
|
||||
assert "Aria" in body
|
||||
assert "68/100" 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."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_with_guest(conn)
|
||||
# Short dialogue so MUST core (speaker identity + edge + last 4
|
||||
# turns + closing) sits comfortably under the hard budget while
|
||||
# SHOULD-tier additions (guest activity, group node, other edges)
|
||||
# would push over.
|
||||
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=[],
|
||||
# MUST core ~310 tokens; SHOULD additions (guest activity +
|
||||
# group node + other edges) push it well over 380. budget_hard
|
||||
# is set just above MUST core so SHOULD-tier blocks must be
|
||||
# trimmed away.
|
||||
budget_soft=250,
|
||||
budget_hard=340,
|
||||
)
|
||||
body = msgs[0].content
|
||||
# MUST: speaker identity, edge to addressee, last 4 dialogue turns.
|
||||
assert "Aria" in body
|
||||
assert "YOUR EDGE TO Sam" in body
|
||||
for i in range(16, 20):
|
||||
assert f"line-{i:02d}" in body
|
||||
# Guest activity (SHOULD-tier) must be dropped under tight budget.
|
||||
assert "smirking-distinctively" not in body
|
||||
# Token budget honoured.
|
||||
import tiktoken
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
assert len(enc.encode(body)) <= 340
|
||||
|
||||
Reference in New Issue
Block a user