feat: per-POV summary and edge summary update on scene close

This commit is contained in:
Joseph Doherty
2026-04-26 13:53:12 -04:00
parent 0997562e75
commit b5175aefaa
6 changed files with 601 additions and 12 deletions
+269
View File
@@ -0,0 +1,269 @@
"""Per-POV scene summary and edge summary update on scene close (T27).
When a scene closes — either auto-detected by the hard-signal classifier
in T26 or fired by the manual close button on the drawer — we run a
single-shot classifier per present witness that produces three signals
in one pass:
* ``summary`` — a 2-4 sentence per-POV recap of the scene from this
witness's perspective. Different from omniscient narration; focuses on
what the witness noticed/felt/remembers.
* ``knowledge_facts`` — concrete new things this witness learned about
the user during the scene. Promoted to the directed edge's
``knowledge`` list via ``edge_update``.
* ``relationship_summary`` — a 1-2 sentence delta on how the
witness's relationship to the user shifted in this scene. v1
combines this with the prior edge summary by simple concatenation —
the LLM is asked to phrase ``relationship_summary`` as a merge-ready
fragment, so the result reads naturally without a second classifier
round-trip.
Phase 1 single-bot only the host bot is summarized; "you" doesn't have
a memory store in v1 so per-POV writes for the user are deferred. The
:func:`apply_scene_close_summary` driver is intentionally tolerant: if
no memories belong to the closed scene it silently skips the rewrite,
and a flapping classifier returns the empty default so the close flow
keeps moving.
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from pydantic import BaseModel, Field
from chat.eventlog.log import append_and_apply
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class ScenePOVSummary(BaseModel):
"""Classifier output: one witness's view of a closing scene.
Defaults are an inert no-op so a classifier failure is harmless —
callers can apply the result unconditionally and end up not
rewriting anything when the model misbehaves.
"""
summary: str = ""
knowledge_facts: list[str] = Field(default_factory=list)
relationship_summary: str = ""
_SYSTEM_TEMPLATE = (
"You are summarizing a roleplay scene from {bot_name}'s point of "
"view. Read the dialogue, then output JSON with exactly three "
"fields:\n"
"- summary: 2-4 sentences, in {bot_name}'s POV, of what happened "
"in the scene. This is NOT omniscient narration — focus on what "
"{bot_name} noticed, felt, and would remember.\n"
"- knowledge_facts: list of NEW factual things {bot_name} learned "
"about the user during this scene. Use specific stated content; do "
"not infer or interpret. Empty list is fine.\n"
"- relationship_summary: a SHORT (1-2 sentence) summary of how "
"{bot_name}'s relationship with the user changed or developed in "
"this scene. Phrase it so it reads as a continuation of the prior "
"summary; the caller will concatenate them.\n\n"
"Be specific. Avoid generic phrases."
)
def _format_dialogue(dialogue: list[dict]) -> str:
if not dialogue:
return "(no dialogue)"
return "\n".join(
f"{turn.get('speaker', '?')}: {turn.get('text', '')}"
for turn in dialogue
)
async def summarize_scene(
client: LLMClient,
*,
model: str,
bot_name: str,
bot_persona: str,
you_name: str,
prior_edge_summary: str,
dialogue: list[dict],
timeout_s: float = 10.0,
) -> ScenePOVSummary:
"""Run the per-POV summary classifier for one witness.
The signature mirrors :func:`compute_state_update` — passing the
bot's name and persona as separate fields lets the prompt address
the model directly ("YOU are {bot_name}") rather than handing it an
opaque id. ``prior_edge_summary`` is included so the classifier can
phrase ``relationship_summary`` as an additive fragment.
Returns the empty default on classifier failure (after one retry)
rather than raising, so the close pipeline keeps moving.
"""
system = _SYSTEM_TEMPLATE.format(bot_name=bot_name)
user = (
f"YOU are {bot_name}. {bot_persona or '(no persona on file)'}\n"
f"USER name: {you_name}\n"
f"PRIOR EDGE SUMMARY ({bot_name} -> {you_name}): "
f"{prior_edge_summary or '(empty)'}\n\n"
f"DIALOGUE:\n{_format_dialogue(dialogue)}\n\n"
f"Produce the JSON summary in {bot_name}'s POV."
)
return await classify(
client,
model=model,
system=system,
user=user,
schema=ScenePOVSummary,
default=ScenePOVSummary(),
timeout_s=timeout_s,
)
def _read_recent_dialogue(
conn: Connection, chat_id: str, *, limit: int = 50
) -> list[dict]:
"""Pull the last ``limit`` user/assistant turns for ``chat_id``.
Phase 1 ``user_turn`` / ``assistant_turn`` events don't carry a
``scene_id``, so we approximate the scene's transcript by taking
the most recent turns of the chat. Superseded and hidden rows are
filtered out so regenerated turns (T29) don't bleed into the
summary.
"""
cur = conn.execute(
"SELECT kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
"ORDER BY id DESC LIMIT ?",
(limit,),
)
rows = list(reversed(cur.fetchall()))
out: list[dict] = []
for kind, payload_json in rows:
p = json.loads(payload_json)
if p.get("chat_id") != chat_id:
continue
if kind == "user_turn":
out.append({"speaker": "you", "text": p.get("prose", "")})
else:
out.append(
{
"speaker": p.get("speaker_id", "bot"),
"text": p.get("text", ""),
}
)
return out
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``.
Steps (Phase 1, single-bot):
1. Gather the closing scene's dialogue from the event_log.
2. Run :func:`summarize_scene` for the host bot.
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.entities import get_bot, get_you
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id, "persona": ""}
you_entity = get_you(conn) or {"name": "you", "persona": ""}
dialogue = _read_recent_dialogue(conn, chat_id)
edge_b2y = get_edge(conn, host_bot_id, "you")
prior_summary = (edge_b2y or {}).get("summary", "") or ""
pov = await summarize_scene(
client,
model=classifier_model,
bot_name=host_bot.get("name", host_bot_id),
bot_persona=host_bot.get("persona", "") or "",
you_name=you_entity.get("name", "you") or "you",
prior_edge_summary=prior_summary,
dialogue=dialogue,
timeout_s=timeout_s,
)
# Update memories belonging to the closed scene for the host bot.
cur = conn.execute(
"SELECT id, pov_summary FROM memories "
"WHERE scene_id = ? AND owner_id = ?",
(scene_id, host_bot_id),
)
for memory_id, prior_pov in cur.fetchall():
if not pov.summary:
# Empty default -> skip the memory rewrite; the seeded
# per-turn pov_summary stays in place.
continue
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_pov_summary",
"target_id": int(memory_id),
"prior_value": prior_pov,
"new_value": pov.summary,
},
)
# Update the bot->you edge summary if we have an edge row and a
# non-empty relationship_summary to merge.
if edge_b2y is not None and pov.relationship_summary:
new_summary = (
f"{prior_summary} {pov.relationship_summary}".strip()
if prior_summary
else pov.relationship_summary
)
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_summary",
"target_id": {
"source_id": host_bot_id,
"target_id": "you",
},
"prior_value": prior_summary,
"new_value": new_summary,
},
)
# Append knowledge_facts to the bot->you edge if present.
if pov.knowledge_facts:
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": host_bot_id,
"target_id": "you",
"chat_id": chat_id,
"knowledge_facts": list(pov.knowledge_facts),
},
)
return pov
+23 -5
View File
@@ -6,14 +6,19 @@ be reversed by emitting an inverse ``manual_edit`` later. This module
applies the new value to the appropriate target table; the snapshot of
``prior_value`` is taken by the route handler before this fires.
Phase 1 covers three target kinds:
Phase 1 covers four target kinds:
- ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge,
clamped to 0..100.
- ``memory_significance`` — dropdown edit, clamped to 0..3.
- ``memory_pov_summary`` — textarea edit (string).
- ``memory_pov_summary`` — textarea edit (string). Also reused by T27's
scene-close pipeline to rewrite per-turn raw narratives into a proper
per-POV scene summary.
- ``edge_summary`` — string overwrite of the directed edge's ``summary``
field. Driven by T27 from the classifier's ``relationship_summary``
output combined with the prior summary.
Other §6.4 editable fields (activity verb / attention / posture, edge
summary, knowledge_facts list manipulation) are deferred to Phase 1.5.
Other §6.4 editable fields (activity verb / attention / posture,
knowledge_facts list manipulation) are deferred to Phase 1.5.
Pin toggles intentionally use the existing ``memory_pin_changed`` event
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
@@ -69,5 +74,18 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
"UPDATE memories SET pov_summary = ? WHERE id = ?",
(str(new_value), int(target_id)),
)
elif kind == "edge_summary":
# ``target_id`` here is a {"source_id", "target_id"} pair like
# the affinity/trust edits, since edges are keyed by the
# directed pair, not a single rowid.
conn.execute(
"UPDATE edges SET summary = ? "
"WHERE source_id = ? AND target_id = ?",
(
str(new_value),
target_id["source_id"],
target_id["target_id"],
),
)
# Unknown target_kind: silently no-op for v1. Future kinds (activity
# fields, edge summary, knowledge_facts) extend the dispatch above.
# fields, knowledge_facts list manipulation) extend the dispatch above.
+22 -5
View File
@@ -32,11 +32,13 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply
from chat.services.scene_summarize import apply_scene_close_summary
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
from chat.state.memory import get_pinned
from chat.state.world import active_scene, get_activity, get_chat, get_container
from chat.web.bots import get_conn
from chat.web.kickoff import get_llm_client
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
@@ -143,13 +145,17 @@ async def close_scene_manual(
chat_id: str,
request: Request,
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
"""Manual scene close from the drawer button.
Always available when there's an active scene; mirrors the auto-close
path in the turn flow but bypasses the classifier. Returns the refreshed
drawer partial so HTMX swaps it in. ``400`` when no scene is active —
the button is hidden in that state but a stale tab might still POST.
path in the turn flow but bypasses the hard-signal classifier. After
emitting ``scene_closed`` we run the T27 per-POV summary pipeline
(one classifier call) so the manual path produces the same memory /
edge updates as the auto path. Returns the refreshed drawer partial
so HTMX swaps it in. ``400`` when no scene is active — the button is
hidden in that state but a stale tab might still POST.
"""
chat = get_chat(conn, chat_id)
if chat is None:
@@ -167,11 +173,22 @@ async def close_scene_manual(
payload={
"scene_id": scene["id"],
"ended_at": chat.get("time"),
# T27 will set this from the per-POV summary pass; for T26 we
# default to 0 so the projector update has a value to write.
# Significance defaults to 0; T22's significance worker
# operates on memories, not scenes.
"significance": 0,
},
)
settings = request.app.state.settings
await apply_scene_close_summary(
conn,
client,
classifier_model=settings.classifier_model,
chat_id=chat_id,
scene_id=scene["id"],
host_bot_id=chat["host_bot_id"],
timeout_s=settings.classifier_timeout_s,
)
return await drawer(chat_id, request, conn)
+18 -2
View File
@@ -43,6 +43,7 @@ from chat.services.background import SignificanceJob
from chat.services.memory_write import record_turn_memory
from chat.services.prompt import assemble_narrative_prompt
from chat.services.scene_close import detect_scene_close
from chat.services.scene_summarize import apply_scene_close_summary
from chat.services.state_update import compute_state_update
from chat.services.turn_parse import ParsedTurn, parse_turn
from chat.state.edges import get_edge
@@ -364,11 +365,26 @@ async def post_turn(
payload={
"scene_id": scene["id"],
"ended_at": chat.get("time"),
# T27 will set significance from the per-POV summary
# pass; T26 just emits the close event with a default.
# T27 promotes the per-POV summary into ``edges.summary``
# but doesn't currently set scene significance — the
# async significance pass (T22) operates on memories.
"significance": 0,
},
)
# T27: per-POV summary + edge summary update + knowledge
# promotion. Runs synchronously after the close so the
# next turn (or a subsequent GET /chats/<id>) sees the
# rewritten memories and edge summary. Tolerates classifier
# failure (returns the empty default and skips the writes).
await apply_scene_close_summary(
conn,
client,
classifier_model=settings.classifier_model,
chat_id=chat_id,
scene_id=scene["id"],
host_bot_id=host_bot["id"],
timeout_s=settings.classifier_timeout_s,
)
# 7. Broadcast a JSON completion event (for JS consumers) and an HTML
# fragment event (for HTMX SSE swap-into-timeline).
+260
View File
@@ -0,0 +1,260 @@
"""Per-POV summary and edge summary update on scene close (T27).
When a scene closes (via the auto-close path in the turn flow or the
manual button in the drawer), we run a classifier that produces a
per-POV summary for each present witness — Phase 1 single-bot only the
host bot, since "you" doesn't have a memory store in v1. The output
drives three projected updates:
1. Each ``memories`` row for the closed scene owned by the host bot has
its ``pov_summary`` rewritten via ``manual_edit`` events
(``target_kind="memory_pov_summary"``) so the field carries a proper
scene-level summary instead of the per-turn raw narrative seeded by
T21.
2. The directed bot->you ``edges.summary`` is updated via a new
``manual_edit`` target_kind ``edge_summary``. v1 strategy combines
the prior summary with the classifier's ``relationship_summary``
field; the LLM is the one phrasing the merge.
3. Newly-learned facts from the classifier's ``knowledge_facts`` field
are appended via the existing ``edge_update`` event handler.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.llm.mock import MockLLMClient
from chat.services.scene_summarize import (
ScenePOVSummary,
apply_scene_close_summary,
summarize_scene,
)
# Importing for handler-registration side effects so the freshly-migrated
# DB created in each test below has the projector ready.
import chat.state.edges # noqa: F401
import chat.state.entities # noqa: F401
import chat.state.manual_edit # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
# ---------------------------------------------------------------------------
# Service-level tests — no FastAPI involvement.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_summarize_scene_parses_classifier_output():
canned = json.dumps(
{
"summary": "BotA shared a quiet moment with you in the office.",
"knowledge_facts": ["You like coffee black."],
"relationship_summary": "BotA feels closer to you after this conversation.",
}
)
mock = MockLLMClient(canned=[canned])
result = await summarize_scene(
mock,
model="x",
bot_name="BotA",
bot_persona="thoughtful",
you_name="Me",
prior_edge_summary="",
dialogue=[
{"speaker": "Me", "text": "hi"},
{"speaker": "BotA", "text": "Hello!"},
],
)
assert isinstance(result, ScenePOVSummary)
assert result.summary.startswith("BotA shared")
assert result.knowledge_facts == ["You like coffee black."]
assert "closer" in result.relationship_summary
@pytest.mark.asyncio
async def test_summarize_scene_default_on_failure():
"""Two consecutive non-JSON returns trip the classifier's retry-then-default
path; we should get the empty fallback rather than crashing the close
flow."""
mock = MockLLMClient(canned=["bad", "still bad"])
result = await summarize_scene(
mock,
model="x",
bot_name="BotA",
bot_persona="",
you_name="Me",
prior_edge_summary="",
dialogue=[],
)
assert result.summary == ""
assert result.knowledge_facts == []
assert result.relationship_summary == ""
@pytest.mark.asyncio
async def test_apply_scene_close_summary_updates_memories_and_edge(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA reassured you about the project deadline.",
"knowledge_facts": ["You are nervous about the deadline."],
"relationship_summary": "BotA showed quiet support.",
}
)
with open_db(db) as conn:
# Seed bot, you, chat, container, scene, edge, memory, dialogue.
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
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",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "I'm nervous 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,
},
)
project(conn)
client = MockLLMClient(canned=[canned])
result = await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Returned summary plumbs through.
assert "reassured" in result.summary
assert result.knowledge_facts == ["You are nervous about the deadline."]
# Memory pov_summary updated.
new_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
assert "reassured" in new_pov
# And the manual_edit event was logged with prior_value capture.
edits = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert any(
json.loads(p[0]).get("target_kind") == "memory_pov_summary"
for p in edits
)
mem_edit = next(
json.loads(p[0])
for p in edits
if json.loads(p[0]).get("target_kind") == "memory_pov_summary"
)
assert mem_edit["prior_value"] == "Original raw narrative"
# Edge summary updated via manual_edit (target_kind="edge_summary").
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "support" in edge["summary"]
assert any(
json.loads(p[0]).get("target_kind") == "edge_summary"
for p in edits
)
# Knowledge fact appended via edge_update.
assert any("deadline" in fact for fact in edge["knowledge"])
+9
View File
@@ -87,6 +87,7 @@ def client(tmp_path, monkeypatch):
# 3. state_update bot->you
# 4. state_update you->bot
# 5. detect_scene_close (runs AFTER assistant_turn — see turns.py)
# 6. summarize_scene (T27, runs only when scene-close fires)
parse_canned = json.dumps(
{"segments": [{"kind": "dialogue", "text": "hello"}]}
)
@@ -101,6 +102,13 @@ def client(tmp_path, monkeypatch):
"new_container_hint": "park",
}
)
pov_summary_canned = json.dumps(
{
"summary": "BotA noticed you leaving the office.",
"knowledge_facts": [],
"relationship_summary": "BotA wonders where you're headed.",
}
)
from chat.web.kickoff import get_llm_client
@@ -111,6 +119,7 @@ def client(tmp_path, monkeypatch):
state_update_canned,
state_update_canned,
scene_close_canned,
pov_summary_canned,
]
)
app.dependency_overrides[get_llm_client] = lambda: mock