Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44c8735b27 | |||
| 9b601650fb | |||
| fcb111310a | |||
| 4e240347b4 |
+124
-34
@@ -37,6 +37,7 @@ 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.group_node import get_group_node
|
||||||
from chat.state.memory import search_memories
|
from chat.state.memory import search_memories
|
||||||
from chat.state.world import (
|
from chat.state.world import (
|
||||||
active_scene,
|
active_scene,
|
||||||
@@ -206,6 +207,26 @@ def _build_previous_scene_block(pov_summary: str | None) -> str | None:
|
|||||||
return "PREVIOUS SCENE SUMMARY:\n" + pov_summary
|
return "PREVIOUS SCENE SUMMARY:\n" + pov_summary
|
||||||
|
|
||||||
|
|
||||||
|
def _build_group_node_block(group_node: dict | None) -> str | None:
|
||||||
|
"""Render the group-node summary + dynamic as a SHOULD-tier block.
|
||||||
|
|
||||||
|
Used only in 3-entity scenes (you + host + guest). Returns None when
|
||||||
|
the row is missing or both summary and dynamic are empty.
|
||||||
|
"""
|
||||||
|
if not group_node:
|
||||||
|
return None
|
||||||
|
summary = (group_node.get("summary") or "").strip()
|
||||||
|
dynamic = (group_node.get("dynamic") or "").strip()
|
||||||
|
if not summary and not dynamic:
|
||||||
|
return None
|
||||||
|
lines = ["Group dynamic:"]
|
||||||
|
if summary:
|
||||||
|
lines.append(f"- Summary: {summary}")
|
||||||
|
if dynamic:
|
||||||
|
lines.append(f"- Dynamic: {dynamic}")
|
||||||
|
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 "
|
||||||
@@ -287,6 +308,7 @@ def assemble_narrative_prompt(
|
|||||||
budget_soft: int = 6000,
|
budget_soft: int = 6000,
|
||||||
budget_hard: int = 8000,
|
budget_hard: int = 8000,
|
||||||
encoding_name: str = "cl100k_base",
|
encoding_name: str = "cl100k_base",
|
||||||
|
guest_id: str | None = None,
|
||||||
) -> list[Message]:
|
) -> list[Message]:
|
||||||
"""Assemble the narrative prompt for ``speaker_bot_id`` to respond.
|
"""Assemble the narrative prompt for ``speaker_bot_id`` to respond.
|
||||||
|
|
||||||
@@ -313,6 +335,15 @@ def assemble_narrative_prompt(
|
|||||||
if chat is None:
|
if chat is None:
|
||||||
raise ValueError(f"chat_id {chat_id!r} not found")
|
raise ValueError(f"chat_id {chat_id!r} not found")
|
||||||
|
|
||||||
|
# Auto-detect guest from chat state when caller didn't pass one.
|
||||||
|
# Phase 1 chats have ``guest_bot_id is None``; the auto-detect is a
|
||||||
|
# no-op there and the function behaves exactly as before.
|
||||||
|
if guest_id is None:
|
||||||
|
guest_id = chat.get("guest_bot_id")
|
||||||
|
# A speaker addressing themself as guest doesn't add a third party.
|
||||||
|
if guest_id is not None and guest_id == speaker_bot_id:
|
||||||
|
guest_id = None
|
||||||
|
|
||||||
you = get_you(conn)
|
you = get_you(conn)
|
||||||
addressee_id, addressee_name = _resolve_addressee(conn, addressee, you)
|
addressee_id, addressee_name = _resolve_addressee(conn, addressee, you)
|
||||||
|
|
||||||
@@ -325,9 +356,10 @@ def assemble_narrative_prompt(
|
|||||||
addressee_name,
|
addressee_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Activity for present entities. Phase 1: you + speaker bot. (When a
|
# Activity for present entities. Core (MUST): you + speaker bot.
|
||||||
# guest is added in Phase 1+, callers that know about it can pass
|
# Phase 2 (SHOULD-tier): when a third party (guest) is present in
|
||||||
# extra activities via a future hook; for now we keep it strict.)
|
# the chat, append their activity in a separate block so it can be
|
||||||
|
# trimmed independently under tight budget.
|
||||||
activities: list[dict] = []
|
activities: list[dict] = []
|
||||||
you_act = get_activity(conn, "you")
|
you_act = get_activity(conn, "you")
|
||||||
if you_act is not None:
|
if you_act is not None:
|
||||||
@@ -341,6 +373,34 @@ def assemble_narrative_prompt(
|
|||||||
activities.append(bot_act)
|
activities.append(bot_act)
|
||||||
activity_block = _build_activity_block(activities)
|
activity_block = _build_activity_block(activities)
|
||||||
|
|
||||||
|
# SHOULD-tier guest activity extension (Phase 2 / Task 43).
|
||||||
|
guest_activity_block: str | 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_bot = get_bot(conn, guest_id)
|
||||||
|
guest_act["_display_name"] = (
|
||||||
|
guest_bot["name"] if guest_bot else guest_id
|
||||||
|
)
|
||||||
|
guest_activity_block = _build_activity_block([guest_act])
|
||||||
|
|
||||||
|
# SHOULD-tier group-node block (Phase 2 / Task 43): rendered only
|
||||||
|
# when the group_node row is present AND it covers all three of
|
||||||
|
# you + host + guest (per the Task 43 spec).
|
||||||
|
group_node_block: str | None = None
|
||||||
|
if guest_id is not None:
|
||||||
|
gn = get_group_node(conn, chat_id)
|
||||||
|
if gn is not None:
|
||||||
|
members = set(gn.get("members") or [])
|
||||||
|
host_id = chat.get("host_bot_id")
|
||||||
|
required = {"you"}
|
||||||
|
if host_id is not None:
|
||||||
|
required.add(host_id)
|
||||||
|
required.add(guest_id)
|
||||||
|
if required.issubset(members):
|
||||||
|
group_node_block = _build_group_node_block(gn)
|
||||||
|
|
||||||
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"])
|
||||||
@@ -421,6 +481,8 @@ 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_guest_activity: bool = True,
|
||||||
|
include_group_node: 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.
|
||||||
@@ -447,6 +509,8 @@ def assemble_narrative_prompt(
|
|||||||
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,
|
||||||
prev_block,
|
prev_block,
|
||||||
memories_block,
|
memories_block,
|
||||||
dialogue_block,
|
dialogue_block,
|
||||||
@@ -463,12 +527,25 @@ 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_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]:
|
||||||
body, total, _ = assemble(
|
body, total, _ = assemble(
|
||||||
include_other_edges=include_other,
|
include_other_edges=other,
|
||||||
include_previous_scene=include_prev,
|
include_previous_scene=prev,
|
||||||
include_memories_top_k=nice_memories_k,
|
include_memories_top_k=mem_k,
|
||||||
dialogue_keep=nice_dialogue_keep,
|
dialogue_keep=dlg,
|
||||||
|
include_guest_activity=guest_act,
|
||||||
|
include_group_node=group,
|
||||||
|
)
|
||||||
|
return body, total
|
||||||
|
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If under soft, we're done.
|
# If under soft, we're done.
|
||||||
@@ -478,34 +555,31 @@ def assemble_narrative_prompt(
|
|||||||
# Drop NICE in order: previous scene → memories beyond top-2 →
|
# Drop NICE in order: previous scene → memories beyond top-2 →
|
||||||
# older dialogue turns (collapse to 4).
|
# older dialogue turns (collapse to 4).
|
||||||
if include_prev:
|
if include_prev:
|
||||||
body, total, _ = assemble(
|
|
||||||
include_other_edges=include_other,
|
|
||||||
include_previous_scene=False,
|
|
||||||
include_memories_top_k=nice_memories_k,
|
|
||||||
dialogue_keep=nice_dialogue_keep,
|
|
||||||
)
|
|
||||||
include_prev = False
|
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,
|
||||||
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
return _emit(body, user_turn_prose)
|
||||||
|
|
||||||
if nice_memories_k > 2:
|
if nice_memories_k > 2:
|
||||||
nice_memories_k = 2
|
nice_memories_k = 2
|
||||||
body, total, _ = assemble(
|
body, total = _build(
|
||||||
include_other_edges=include_other,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
include_previous_scene=False,
|
other=include_other, guest_act=include_guest_activity,
|
||||||
include_memories_top_k=nice_memories_k,
|
group=include_group_node,
|
||||||
dialogue_keep=nice_dialogue_keep,
|
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
return _emit(body, user_turn_prose)
|
||||||
|
|
||||||
if nice_dialogue_keep > baseline_keep:
|
if nice_dialogue_keep > baseline_keep:
|
||||||
nice_dialogue_keep = baseline_keep
|
nice_dialogue_keep = baseline_keep
|
||||||
body, total, _ = assemble(
|
body, total = _build(
|
||||||
include_other_edges=include_other,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
include_previous_scene=False,
|
other=include_other, guest_act=include_guest_activity,
|
||||||
include_memories_top_k=nice_memories_k,
|
group=include_group_node,
|
||||||
dialogue_keep=nice_dialogue_keep,
|
|
||||||
)
|
)
|
||||||
if total <= budget_soft:
|
if total <= budget_soft:
|
||||||
return _emit(body, user_turn_prose)
|
return _emit(body, user_turn_prose)
|
||||||
@@ -513,21 +587,37 @@ def assemble_narrative_prompt(
|
|||||||
# Drop more NICE until we're under hard: memories all the way to 0.
|
# Drop more NICE until we're under hard: memories all the way to 0.
|
||||||
while nice_memories_k > 0 and total > budget_hard:
|
while nice_memories_k > 0 and total > budget_hard:
|
||||||
nice_memories_k = max(0, nice_memories_k - 1)
|
nice_memories_k = max(0, nice_memories_k - 1)
|
||||||
body, total, _ = assemble(
|
body, total = _build(
|
||||||
include_other_edges=include_other,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
include_previous_scene=False,
|
other=include_other, guest_act=include_guest_activity,
|
||||||
include_memories_top_k=nice_memories_k,
|
group=include_group_node,
|
||||||
dialogue_keep=nice_dialogue_keep,
|
)
|
||||||
|
|
||||||
|
# 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.)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Drop SHOULD: other edges.
|
|
||||||
if include_other and total > budget_hard:
|
if include_other and total > budget_hard:
|
||||||
include_other = False
|
include_other = False
|
||||||
body, total, _ = assemble(
|
body, total = _build(
|
||||||
include_other_edges=False,
|
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||||
include_previous_scene=False,
|
other=include_other, guest_act=include_guest_activity,
|
||||||
include_memories_top_k=nice_memories_k,
|
group=include_group_node,
|
||||||
dialogue_keep=nice_dialogue_keep,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if total > budget_hard:
|
if total > budget_hard:
|
||||||
|
|||||||
@@ -156,64 +156,50 @@ def _read_recent_dialogue(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def apply_scene_close_summary(
|
async def _summarize_and_apply_for_witness(
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
client: LLMClient,
|
client: LLMClient,
|
||||||
*,
|
*,
|
||||||
classifier_model: str,
|
classifier_model: str,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
scene_id: int,
|
scene_id: int,
|
||||||
host_bot_id: str,
|
bot_id: str,
|
||||||
timeout_s: float = 10.0,
|
you_name: str,
|
||||||
|
dialogue: list[dict],
|
||||||
|
timeout_s: float,
|
||||||
) -> ScenePOVSummary:
|
) -> ScenePOVSummary:
|
||||||
"""Drive the per-POV summary pipeline after ``scene_closed``.
|
"""Run :func:`summarize_scene` for one bot witness and apply the
|
||||||
|
three projected updates (memory pov_summary rewrite, edge summary
|
||||||
|
overwrite, edge knowledge_facts append).
|
||||||
|
|
||||||
Steps (Phase 1, single-bot):
|
Tolerant of missing pieces in the same way Phase 1 was: no memory
|
||||||
1. Gather the closing scene's dialogue from the event_log.
|
row -> skip the rewrite; no edge row -> skip the edge_summary write
|
||||||
2. Run :func:`summarize_scene` for the host bot.
|
(the empty-default classifier output simply yields no rewrites).
|
||||||
3. Rewrite each scene-bound memory's ``pov_summary`` via
|
|
||||||
``manual_edit`` (target_kind ``memory_pov_summary``), capturing
|
|
||||||
the prior value for §6.4 reversibility.
|
|
||||||
4. Update the bot->you edge summary via ``manual_edit`` with the
|
|
||||||
new ``edge_summary`` target_kind. v1 combines prior + new by
|
|
||||||
concatenation — the classifier's ``relationship_summary`` is
|
|
||||||
already phrased as a continuation.
|
|
||||||
5. Append any new knowledge_facts to the same edge via
|
|
||||||
``edge_update``.
|
|
||||||
|
|
||||||
Tolerant of missing pieces: no memories -> skip step 3 silently;
|
|
||||||
no edge row -> skip step 4; empty knowledge_facts -> skip step 5.
|
|
||||||
The classifier's empty default flows through harmlessly.
|
|
||||||
"""
|
"""
|
||||||
# Local imports to keep the module-level surface tight and avoid
|
|
||||||
# any chance of a circular dep through chat.state.*.
|
|
||||||
from chat.state.edges import get_edge
|
from chat.state.edges import get_edge
|
||||||
from chat.state.entities import get_bot, get_you
|
from chat.state.entities import get_bot
|
||||||
|
|
||||||
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id, "persona": ""}
|
bot = get_bot(conn, bot_id) or {"name": bot_id, "persona": ""}
|
||||||
you_entity = get_you(conn) or {"name": "you", "persona": ""}
|
|
||||||
|
|
||||||
dialogue = _read_recent_dialogue(conn, chat_id)
|
edge_b2y = get_edge(conn, bot_id, "you")
|
||||||
|
|
||||||
edge_b2y = get_edge(conn, host_bot_id, "you")
|
|
||||||
prior_summary = (edge_b2y or {}).get("summary", "") or ""
|
prior_summary = (edge_b2y or {}).get("summary", "") or ""
|
||||||
|
|
||||||
pov = await summarize_scene(
|
pov = await summarize_scene(
|
||||||
client,
|
client,
|
||||||
model=classifier_model,
|
model=classifier_model,
|
||||||
bot_name=host_bot.get("name", host_bot_id),
|
bot_name=bot.get("name", bot_id),
|
||||||
bot_persona=host_bot.get("persona", "") or "",
|
bot_persona=bot.get("persona", "") or "",
|
||||||
you_name=you_entity.get("name", "you") or "you",
|
you_name=you_name,
|
||||||
prior_edge_summary=prior_summary,
|
prior_edge_summary=prior_summary,
|
||||||
dialogue=dialogue,
|
dialogue=dialogue,
|
||||||
timeout_s=timeout_s,
|
timeout_s=timeout_s,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update memories belonging to the closed scene for the host bot.
|
# Update memories belonging to the closed scene for this witness.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"SELECT id, pov_summary FROM memories "
|
"SELECT id, pov_summary FROM memories "
|
||||||
"WHERE scene_id = ? AND owner_id = ?",
|
"WHERE scene_id = ? AND owner_id = ?",
|
||||||
(scene_id, host_bot_id),
|
(scene_id, bot_id),
|
||||||
)
|
)
|
||||||
for memory_id, prior_pov in cur.fetchall():
|
for memory_id, prior_pov in cur.fetchall():
|
||||||
if not pov.summary:
|
if not pov.summary:
|
||||||
@@ -231,7 +217,7 @@ async def apply_scene_close_summary(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the bot->you edge summary if we have an edge row and a
|
# Update this bot->you edge summary if we have an edge row and a
|
||||||
# non-empty relationship_summary to merge.
|
# non-empty relationship_summary to merge.
|
||||||
if edge_b2y is not None and pov.relationship_summary:
|
if edge_b2y is not None and pov.relationship_summary:
|
||||||
new_summary = (
|
new_summary = (
|
||||||
@@ -245,7 +231,7 @@ async def apply_scene_close_summary(
|
|||||||
payload={
|
payload={
|
||||||
"target_kind": "edge_summary",
|
"target_kind": "edge_summary",
|
||||||
"target_id": {
|
"target_id": {
|
||||||
"source_id": host_bot_id,
|
"source_id": bot_id,
|
||||||
"target_id": "you",
|
"target_id": "you",
|
||||||
},
|
},
|
||||||
"prior_value": prior_summary,
|
"prior_value": prior_summary,
|
||||||
@@ -253,13 +239,13 @@ async def apply_scene_close_summary(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Append knowledge_facts to the bot->you edge if present.
|
# Append knowledge_facts to this bot->you edge if present.
|
||||||
if pov.knowledge_facts:
|
if pov.knowledge_facts:
|
||||||
append_and_apply(
|
append_and_apply(
|
||||||
conn,
|
conn,
|
||||||
kind="edge_update",
|
kind="edge_update",
|
||||||
payload={
|
payload={
|
||||||
"source_id": host_bot_id,
|
"source_id": bot_id,
|
||||||
"target_id": "you",
|
"target_id": "you",
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"knowledge_facts": list(pov.knowledge_facts),
|
"knowledge_facts": list(pov.knowledge_facts),
|
||||||
@@ -267,3 +253,107 @@ async def apply_scene_close_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return pov
|
return pov
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_scene_close_summary(
|
||||||
|
conn: Connection,
|
||||||
|
client: LLMClient,
|
||||||
|
*,
|
||||||
|
classifier_model: str,
|
||||||
|
chat_id: str,
|
||||||
|
scene_id: int,
|
||||||
|
host_bot_id: str,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
) -> ScenePOVSummary:
|
||||||
|
"""Drive the per-POV summary pipeline after ``scene_closed``.
|
||||||
|
|
||||||
|
Phase 1 (single-bot) behavior — the host bot is summarized once and
|
||||||
|
the result drives memory + edge rewrites — is preserved exactly when
|
||||||
|
the chat has no guest. T45 extends this to fan out across each
|
||||||
|
present bot witness when a guest is also in the room:
|
||||||
|
|
||||||
|
1. Gather the closing scene's dialogue from the event_log.
|
||||||
|
2. For each present witness (host + guest if any), run
|
||||||
|
:func:`summarize_scene` once with that witness's persona and
|
||||||
|
their own prior ``bot -> you`` edge summary.
|
||||||
|
3. For each witness independently:
|
||||||
|
a. Rewrite each scene-bound memory's ``pov_summary`` via
|
||||||
|
``manual_edit`` (target_kind ``memory_pov_summary``).
|
||||||
|
b. Update that witness's ``bot -> you`` edge summary via
|
||||||
|
``manual_edit`` (target_kind ``edge_summary``). v2 combines
|
||||||
|
prior + classifier ``relationship_summary`` by simple
|
||||||
|
concatenation.
|
||||||
|
c. Append any ``knowledge_facts`` to the same edge via
|
||||||
|
``edge_update``.
|
||||||
|
4. If a ``group_node`` row exists for this chat, append a
|
||||||
|
``group_node_updated`` event whose ``summary`` is the naive
|
||||||
|
per-POV concat ``f"{name}: {summary}\\n\\n..."``. A true
|
||||||
|
LLM-merged group view is deferred to Phase 2.5; ``dynamic``
|
||||||
|
is left empty here for v2 (Phase 3 polishes it).
|
||||||
|
|
||||||
|
The host's :class:`ScenePOVSummary` is returned to preserve the
|
||||||
|
Phase 1 callers' contract.
|
||||||
|
"""
|
||||||
|
# Local imports to keep the module-level surface tight and avoid
|
||||||
|
# any chance of a circular dep through chat.state.*.
|
||||||
|
from chat.state.entities import get_bot, get_you
|
||||||
|
from chat.state.group_node import get_group_node
|
||||||
|
from chat.state.world import get_chat
|
||||||
|
|
||||||
|
you_entity = get_you(conn) or {"name": "you", "persona": ""}
|
||||||
|
you_name = you_entity.get("name", "you") or "you"
|
||||||
|
|
||||||
|
chat = get_chat(conn, chat_id) or {}
|
||||||
|
guest_bot_id = chat.get("guest_bot_id")
|
||||||
|
|
||||||
|
dialogue = _read_recent_dialogue(conn, chat_id)
|
||||||
|
|
||||||
|
host_pov = await _summarize_and_apply_for_witness(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
classifier_model=classifier_model,
|
||||||
|
chat_id=chat_id,
|
||||||
|
scene_id=scene_id,
|
||||||
|
bot_id=host_bot_id,
|
||||||
|
you_name=you_name,
|
||||||
|
dialogue=dialogue,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
guest_pov: ScenePOVSummary | None = None
|
||||||
|
if guest_bot_id is not None:
|
||||||
|
guest_pov = await _summarize_and_apply_for_witness(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
classifier_model=classifier_model,
|
||||||
|
chat_id=chat_id,
|
||||||
|
scene_id=scene_id,
|
||||||
|
bot_id=guest_bot_id,
|
||||||
|
you_name=you_name,
|
||||||
|
dialogue=dialogue,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group node update: naive per-POV concat for v2. Only fires when
|
||||||
|
# both POVs ran (i.e. the guest is present) and a group_node row
|
||||||
|
# exists for this chat.
|
||||||
|
if guest_pov is not None and get_group_node(conn, chat_id) is not None:
|
||||||
|
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id}
|
||||||
|
guest_bot = get_bot(conn, guest_bot_id) or {"name": guest_bot_id}
|
||||||
|
host_name = host_bot.get("name", host_bot_id) or host_bot_id
|
||||||
|
guest_name = guest_bot.get("name", guest_bot_id) or guest_bot_id
|
||||||
|
group_summary = (
|
||||||
|
f"{host_name}: {host_pov.summary}\n\n"
|
||||||
|
f"{guest_name}: {guest_pov.summary}"
|
||||||
|
)
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="group_node_updated",
|
||||||
|
payload={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"summary": group_summary,
|
||||||
|
"dynamic": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return host_pov
|
||||||
|
|||||||
@@ -258,3 +258,425 @@ async def test_apply_scene_close_summary_updates_memories_and_edge(tmp_path):
|
|||||||
|
|
||||||
# Knowledge fact appended via edge_update.
|
# Knowledge fact appended via edge_update.
|
||||||
assert any("deadline" in fact for fact in edge["knowledge"])
|
assert any("deadline" in fact for fact in edge["knowledge"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T45: per-POV summaries on close for each present witness.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bot_payload(bot_id: str, name: str, persona: str = "thoughtful") -> dict:
|
||||||
|
return {
|
||||||
|
"id": bot_id,
|
||||||
|
"name": name,
|
||||||
|
"persona": persona,
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": [],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_single_bot_scene(conn) -> None:
|
||||||
|
"""Seed the canonical Phase 1 single-bot scene used by the regression test."""
|
||||||
|
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="chat_created",
|
||||||
|
payload={
|
||||||
|
"id": "chat_bot_a",
|
||||||
|
"host_bot_id": "bot_a",
|
||||||
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||||
|
"narrative_anchor": "Day 1",
|
||||||
|
"weather": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="container_created",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"name": "office",
|
||||||
|
"type": "workplace",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_a",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"scene_id": 1,
|
||||||
|
"pov_summary": "Original raw narrative (host)",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 0,
|
||||||
|
"significance": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="user_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"prose": "Quick chat about the deadline",
|
||||||
|
"segments": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="assistant_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"speaker_id": "bot_a",
|
||||||
|
"text": "It's going to be okay.",
|
||||||
|
"truncated": False,
|
||||||
|
"user_turn_id": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_two_bot_scene(conn, *, with_group_node: bool = False) -> None:
|
||||||
|
"""Seed a host+guest scene with bot_a (host) and bot_b (guest), plus a
|
||||||
|
memory row per bot owner so each per-POV update has something to rewrite,
|
||||||
|
and seeded directed edges from each bot to ``you`` so each edge_summary
|
||||||
|
update has a row to operate on. Optionally seeds the group_node row too.
|
||||||
|
"""
|
||||||
|
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||||
|
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
"weather": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="container_created",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"name": "office",
|
||||||
|
"type": "workplace",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Seed edges in both bot -> you directions so the edge_summary updates
|
||||||
|
# have rows to target.
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "bot_b",
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# One memory per witness, scene 1.
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_a",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"scene_id": 1,
|
||||||
|
"pov_summary": "Original raw narrative (host)",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 1,
|
||||||
|
"significance": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_b",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"scene_id": 1,
|
||||||
|
"pov_summary": "Original raw narrative (guest)",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 1,
|
||||||
|
"significance": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="user_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"prose": "Three of us in the office.",
|
||||||
|
"segments": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="assistant_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"speaker_id": "bot_a",
|
||||||
|
"text": "Glad you're both here.",
|
||||||
|
"truncated": False,
|
||||||
|
"user_turn_id": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if with_group_node:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="group_node_initialized",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"members": ["you", "bot_a", "bot_b"],
|
||||||
|
"summary": "",
|
||||||
|
"dynamic": "",
|
||||||
|
"threads": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_with_no_guest_matches_phase1(tmp_path):
|
||||||
|
"""Regression: when guest_bot_id is None, the close summary path runs
|
||||||
|
summarize_scene exactly once and rewrites the host's memory + host->you
|
||||||
|
edge in place — same as Phase 1 behavior."""
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
canned = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotA helped you talk through the deadline anxiety.",
|
||||||
|
"knowledge_facts": ["Deadline next Friday."],
|
||||||
|
"relationship_summary": "BotA leaned in supportively.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
_seed_single_bot_scene(conn)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
# canned has 2 entries to detect any over-call; the assertion below
|
||||||
|
# confirms only one was consumed.
|
||||||
|
client = MockLLMClient(canned=[canned, canned])
|
||||||
|
await apply_scene_close_summary(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
classifier_model="x",
|
||||||
|
chat_id="chat_bot_a",
|
||||||
|
scene_id=1,
|
||||||
|
host_bot_id="bot_a",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exactly one classifier call -> exactly one canned entry consumed,
|
||||||
|
# leaving the second untouched.
|
||||||
|
assert len(client._canned) == 1
|
||||||
|
|
||||||
|
# Host memory rewritten with the per-POV summary content.
|
||||||
|
new_pov = conn.execute(
|
||||||
|
"SELECT pov_summary FROM memories "
|
||||||
|
"WHERE owner_id = 'bot_a' AND scene_id = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert "BotA helped" in new_pov
|
||||||
|
|
||||||
|
# host->you edge summary rewritten with the relationship_summary.
|
||||||
|
from chat.state.edges import get_edge
|
||||||
|
|
||||||
|
edge = get_edge(conn, "bot_a", "you")
|
||||||
|
assert "supportively" in edge["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_with_guest_calls_summarize_twice(tmp_path):
|
||||||
|
"""When a guest is present, summarize_scene runs once per witness
|
||||||
|
(host + guest) and each bot's memory rewrite uses its own POV summary."""
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
host_canned = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotA noticed BotB warming up to you.",
|
||||||
|
"knowledge_facts": ["You sketched on the whiteboard."],
|
||||||
|
"relationship_summary": "BotA felt steady around you.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guest_canned = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotB found the office quieter than expected.",
|
||||||
|
"knowledge_facts": ["You prefer black coffee."],
|
||||||
|
"relationship_summary": "BotB warmed up to you a little.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
_seed_two_bot_scene(conn)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
client = MockLLMClient(canned=[host_canned, guest_canned])
|
||||||
|
await apply_scene_close_summary(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
classifier_model="x",
|
||||||
|
chat_id="chat_bot_a",
|
||||||
|
scene_id=1,
|
||||||
|
host_bot_id="bot_a",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both canned entries consumed -> classifier ran twice.
|
||||||
|
assert client._canned == []
|
||||||
|
|
||||||
|
# Host memory carries the host's per-POV summary; guest memory
|
||||||
|
# carries the guest's.
|
||||||
|
host_pov = conn.execute(
|
||||||
|
"SELECT pov_summary FROM memories "
|
||||||
|
"WHERE owner_id = 'bot_a' AND scene_id = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
guest_pov = conn.execute(
|
||||||
|
"SELECT pov_summary FROM memories "
|
||||||
|
"WHERE owner_id = 'bot_b' AND scene_id = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert "BotA noticed" in host_pov
|
||||||
|
assert "BotB found" in guest_pov
|
||||||
|
assert host_pov != guest_pov
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_with_guest_updates_both_edges(tmp_path):
|
||||||
|
"""Both bot->you edges receive their own relationship_summary on close."""
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
host_canned = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotA noticed BotB warming up.",
|
||||||
|
"knowledge_facts": [],
|
||||||
|
"relationship_summary": "BotA felt steady around you.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guest_canned = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotB warmed to the office.",
|
||||||
|
"knowledge_facts": [],
|
||||||
|
"relationship_summary": "BotB warmed up to you a little.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
_seed_two_bot_scene(conn)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
client = MockLLMClient(canned=[host_canned, guest_canned])
|
||||||
|
await apply_scene_close_summary(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
classifier_model="x",
|
||||||
|
chat_id="chat_bot_a",
|
||||||
|
scene_id=1,
|
||||||
|
host_bot_id="bot_a",
|
||||||
|
)
|
||||||
|
|
||||||
|
from chat.state.edges import get_edge
|
||||||
|
|
||||||
|
edge_h2y = get_edge(conn, "bot_a", "you")
|
||||||
|
edge_g2y = get_edge(conn, "bot_b", "you")
|
||||||
|
assert "steady" in edge_h2y["summary"]
|
||||||
|
assert "warmed up" in edge_g2y["summary"]
|
||||||
|
# Per-POV; the two edges did not collapse onto the same text.
|
||||||
|
assert edge_h2y["summary"] != edge_g2y["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_with_group_node_updates_group_summary(tmp_path):
|
||||||
|
"""When a group_node row exists, scene close emits group_node_updated
|
||||||
|
with a non-empty summary that mentions both bots' names (v2 naive
|
||||||
|
concat of per-POV summaries)."""
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
import chat.state.group_node # noqa: F401 -- register handlers
|
||||||
|
|
||||||
|
host_canned = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotA appreciated the calm.",
|
||||||
|
"knowledge_facts": [],
|
||||||
|
"relationship_summary": "BotA felt steady.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guest_canned = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotB found the room friendly.",
|
||||||
|
"knowledge_facts": [],
|
||||||
|
"relationship_summary": "BotB warmed up.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
_seed_two_bot_scene(conn, with_group_node=True)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
client = MockLLMClient(canned=[host_canned, guest_canned])
|
||||||
|
await apply_scene_close_summary(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
classifier_model="x",
|
||||||
|
chat_id="chat_bot_a",
|
||||||
|
scene_id=1,
|
||||||
|
host_bot_id="bot_a",
|
||||||
|
)
|
||||||
|
|
||||||
|
from chat.state.group_node import get_group_node
|
||||||
|
|
||||||
|
gn = get_group_node(conn, "chat_bot_a")
|
||||||
|
assert gn is not None
|
||||||
|
assert gn["summary"] # non-empty
|
||||||
|
# Naive concat surfaces both bot names in the group summary.
|
||||||
|
assert "BotA" in gn["summary"]
|
||||||
|
assert "BotB" in gn["summary"]
|
||||||
|
# Phase 2 v2 keeps dynamic empty (Phase 3 polishes).
|
||||||
|
assert gn["dynamic"] == ""
|
||||||
|
|||||||
@@ -253,3 +253,244 @@ def test_must_exceeds_budget_hard_raises_value_error(tmp_path):
|
|||||||
budget_soft=5,
|
budget_soft=5,
|
||||||
budget_hard=10,
|
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