diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py new file mode 100644 index 0000000..43fb5ec --- /dev/null +++ b/chat/services/scene_summarize.py @@ -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 diff --git a/chat/state/manual_edit.py b/chat/state/manual_edit.py index ff6b247..e796a7a 100644 --- a/chat/state/manual_edit.py +++ b/chat/state/manual_edit.py @@ -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. diff --git a/chat/web/drawer.py b/chat/web/drawer.py index 1d4e0e2..3b61e50 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -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) diff --git a/chat/web/turns.py b/chat/web/turns.py index 61a1e05..c781d09 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -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/) 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). diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py new file mode 100644 index 0000000..d6ce0f2 --- /dev/null +++ b/tests/test_per_pov_summary.py @@ -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"]) diff --git a/tests/test_scene_close.py b/tests/test_scene_close.py index 8447d24..83a9701 100644 --- a/tests/test_scene_close.py +++ b/tests/test_scene_close.py @@ -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