fix: scope thread detection transcript to closing scene (T80.2)
apply_scene_close_summary fed detect_threads the chat-wide last-50 turns. When a chat has accumulated multiple scenes' worth of dialogue, that bleeds prior-scene turns into the second close's classifier prompt and risks mis-attributing threads (closing one that opened earlier, re-opening one that already closed). Add an optional ``since_event_id`` kwarg to ``_read_recent_dialogue`` that lower-bounds by event_log id, plus a ``_scene_opened_event_id`` helper that resolves the scene-open event for a given scene_id. Wire both into the thread-detection call site so its scene_transcript holds only the closing scene's turns. The per-POV summarizer keeps the chat-wide approximation it had before — that's intentional. Adds test_thread_detection_uses_scene_scoped_transcript.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user