diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py index 49fb375..b6f3d09 100644 --- a/chat/services/scene_summarize.py +++ b/chat/services/scene_summarize.py @@ -123,7 +123,11 @@ async def summarize_scene( def _read_recent_dialogue( - conn: Connection, chat_id: str, *, limit: int = 50 + conn: Connection, + chat_id: str, + *, + limit: int = 50, + since_event_id: int | None = None, ) -> list[dict]: """Pull the last ``limit`` user/assistant turns for ``chat_id``. @@ -132,14 +136,29 @@ def _read_recent_dialogue( the most recent turns of the chat. Superseded and hidden rows are filtered out so regenerated turns (T29) don't bleed into the summary. + + T80.2: ``since_event_id`` clamps the result to event_log rows whose + ``id >= since_event_id`` so callers needing a scene-scoped view (e.g. + thread detection on close) don't pull turns that landed before the + closing scene's ``scene_opened`` event. """ - 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,), - ) + if since_event_id is None: + 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,), + ) + else: + 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 " + " AND id >= ? " + "ORDER BY id DESC LIMIT ?", + (since_event_id, limit), + ) rows = list(reversed(cur.fetchall())) out: list[dict] = [] for kind, payload_json in rows: @@ -158,6 +177,65 @@ def _read_recent_dialogue( return out +def _scene_opened_event_id( + conn: Connection, chat_id: str, scene_id: int +) -> int | None: + """Return the event_log id of the ``scene_opened`` (or + ``meanwhile_scene_started``) event that created scene row + ``scene_id``. Used by T80.2 to lower-bound dialogue reads to a + single scene's transcript. + + ``meanwhile_scene_started`` carries an explicit ``scene_id`` so we + match on that directly. ``scene_opened`` doesn't, so we walk the + chat's scene rows in id order and zip against the chat's scene-open + events in id order — the projector creates one scene row per + scene-open event, so positions correspond. + + Returns ``None`` when no matching event is found; callers should + treat that as "fall back to chat-wide" rather than over-filter. + """ + # Fast path for meanwhile children (explicit scene_id in payload). + for ev_id, payload_json in conn.execute( + "SELECT id, payload_json FROM event_log " + "WHERE kind = 'meanwhile_scene_started' " + " AND superseded_by IS NULL AND hidden = 0", + ).fetchall(): + try: + p = json.loads(payload_json) + except (TypeError, ValueError): + continue + if p.get("chat_id") == chat_id and p.get("scene_id") == scene_id: + return ev_id + # Fallback for parent you-scenes: zip chat-scoped scene-open events + # against chat-scoped scene rows in id order. + chat_scene_ids = [ + r[0] + for r in conn.execute( + "SELECT id FROM scenes WHERE chat_id = ? ORDER BY id ASC", + (chat_id,), + ).fetchall() + ] + if scene_id not in chat_scene_ids: + return None + chat_open_evs: list[int] = [] + for ev_id, _kind, payload_json in conn.execute( + "SELECT id, kind, payload_json FROM event_log " + "WHERE kind IN ('scene_opened', 'meanwhile_scene_started') " + " AND superseded_by IS NULL AND hidden = 0 " + "ORDER BY id ASC", + ).fetchall(): + try: + p = json.loads(payload_json) + except (TypeError, ValueError): + continue + if p.get("chat_id") == chat_id: + chat_open_evs.append(ev_id) + idx = chat_scene_ids.index(scene_id) + if idx < len(chat_open_evs): + return chat_open_evs[idx] + return None + + async def _summarize_and_apply_for_witness( conn: Connection, client: LLMClient, @@ -487,16 +565,27 @@ async def apply_scene_close_summary( }, ) - # T58.2: thread detection on close. Reuses the dialogue we already - # gathered for per-POV summarization — same {speaker, text} shape - # detect_threads expects. Failure-tolerant: classify() returns the - # empty default on retry-exhaustion, and the broad except below - # protects the close pipeline from any other classifier/mock flap. + # T58.2: thread detection on close. Failure-tolerant: classify() + # returns the empty default on retry-exhaustion, and the broad except + # below protects the close pipeline from any other classifier/mock + # flap. + # + # T80.2: thread detection runs against a SCENE-SCOPED transcript, + # not the chat-wide last-50 turns used by the per-POV summaries. + # Mis-attributing threads when scene boundaries fall inside the last + # 50 turns would otherwise close threads opened in a prior scene. + scene_open_ev_id = _scene_opened_event_id(conn, chat_id, scene_id) + if scene_open_ev_id is not None: + scene_dialogue = _read_recent_dialogue( + conn, chat_id, since_event_id=scene_open_ev_id + ) + else: + scene_dialogue = dialogue try: thread_result = await detect_threads( client, classifier_model=classifier_model, - scene_transcript=dialogue, + scene_transcript=scene_dialogue, open_threads=list_open_threads(conn, chat_id), timeout_s=timeout_s, ) diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py index 9be3db1..593f696 100644 --- a/tests/test_per_pov_summary.py +++ b/tests/test_per_pov_summary.py @@ -1490,3 +1490,138 @@ async def test_scene_close_re_run_does_not_double_suffix(tmp_path): # from a row whose text already contained the suffix). inner_count = pov.count("Key quotes:") assert inner_count == 1 + + +@pytest.mark.asyncio +async def test_thread_detection_uses_scene_scoped_transcript( + tmp_path, monkeypatch +): + """T80.2: when a chat has multiple closed scenes, the second scene's + close must hand ``detect_threads`` ONLY the second scene's turns — + not the chat-wide last-50, which would bleed in the first scene's + transcript and risk mis-closing threads.""" + from chat.services import thread_detection as td_mod + + canned = json.dumps( + { + "summary": "BotA had a quick chat.", + "knowledge_facts": [], + "relationship_summary": "Steady.", + } + ) + + captured_transcripts: list[list[dict]] = [] + + async def capturing_detect_threads(client, **kwargs): + captured_transcripts.append(list(kwargs["scene_transcript"])) + return td_mod.ThreadDetectionResult() + + monkeypatch.setattr(td_mod, "detect_threads", capturing_detect_threads) + + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + # Seed scene 1 + 3 turns + close. + _seed_single_bot_scene(conn) + # Add two extra distinct turns inside scene 1 so the transcript + # has clearly-scene-1 markers we can assert on. + append_event( + conn, + kind="user_turn", + payload={ + "chat_id": "chat_bot_a", + "prose": "SCENE_ONE_USER_TURN", + "segments": [], + }, + ) + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_a", + "text": "SCENE_ONE_BOT_TURN", + "truncated": False, + "user_turn_id": 2, + }, + ) + project(conn) + + # Close scene 1. + client = MockLLMClient(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", + ) + + # Open scene 2 with distinct dialogue. Use append_and_apply so + # the new events project incrementally without re-running the + # already-applied seed events. + from chat.eventlog.log import append_and_apply + + append_and_apply( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T21:00:00+00:00", + "participants": ["you", "bot_a"], + }, + ) + append_and_apply( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "scene_id": 2, + "pov_summary": "Original (scene 2)", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "significance": 1, + }, + ) + append_and_apply( + conn, + kind="user_turn", + payload={ + "chat_id": "chat_bot_a", + "prose": "SCENE_TWO_USER_TURN", + "segments": [], + }, + ) + append_and_apply( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_a", + "text": "SCENE_TWO_BOT_TURN", + "truncated": False, + "user_turn_id": 3, + }, + ) + + # Close scene 2. + client2 = MockLLMClient(canned=[canned]) + await apply_scene_close_summary( + conn, + client2, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=2, + host_bot_id="bot_a", + ) + + # The second close's transcript holds only scene-2 markers. + assert len(captured_transcripts) == 2 + scene_two_transcript = captured_transcripts[1] + joined = " ".join(t.get("text", "") for t in scene_two_transcript) + assert "SCENE_TWO" in joined + assert "SCENE_ONE" not in joined