feat: per-POV summaries on close for each present witness
This commit is contained in:
@@ -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"] == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user