From d123684f9aab906267e9600594f48af4b4489b94 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:46:20 -0400 Subject: [PATCH] fix: guard scene close key-quote suffix against re-close bloat (T80.1) Re-running apply_scene_close_summary on the same scene previously caused recursive bloat: _build_key_quotes_suffix sourced quote text from memories.pov_summary, which after the first close already carried a "Key quotes:" suffix. The next close would then quote the quotes, nesting deeper each time. Strip any existing suffix from candidate text before truncating to 200 chars in the suffix builder, and from the fresh classifier output before composing the new value in _summarize_and_apply_for_witness so the rewrite replaces rather than stacks. Adds test_scene_close_re_run_does_not_double_suffix. --- chat/services/scene_summarize.py | 37 +++++++++++++++- tests/test_per_pov_summary.py | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py index 386cf51..49fb375 100644 --- a/chat/services/scene_summarize.py +++ b/chat/services/scene_summarize.py @@ -213,7 +213,11 @@ async def _summarize_and_apply_for_witness( # Empty default -> skip the memory rewrite; the seeded # per-turn pov_summary stays in place. continue - new_value = pov.summary + key_quotes_suffix + # T80.1: a prior close may have already appended a Key quotes + # suffix to this row's pov_summary. Strip it here so the fresh + # rewrite replaces the existing suffix rather than stacking a + # second one on top. + new_value = _strip_key_quotes_suffix(pov.summary) + key_quotes_suffix append_and_apply( conn, kind="manual_edit", @@ -263,6 +267,31 @@ async def _summarize_and_apply_for_witness( return pov +# T80.1: header marker shared by the suffix builder and the +# witness-write strip step. Any text starting with this marker is treated +# as a previously-appended Key quotes suffix and stripped before reuse so +# repeated scene closes don't compose recursive bloat. +_KEY_QUOTES_HEADER = "\n\nKey quotes:\n" + + +def _strip_key_quotes_suffix(text: str) -> str: + """Remove a previously-appended Key quotes suffix from ``text``. + + Returns ``text`` unchanged when the marker is absent, or the prefix + up to (but not including) the marker when present. Used in two + places: (1) when sourcing quote text from a memory row that may + already carry the suffix from a prior close, and (2) when computing + the per-POV rewrite's prior_value so the new write replaces — rather + than stacks on — the old suffix. + """ + if not text: + return text + idx = text.find(_KEY_QUOTES_HEADER) + if idx >= 0: + return text[:idx] + return text + + def _build_key_quotes_suffix(conn: Connection, scene_id: int) -> str: """If the scene's max-turn-significance is >= 2, build the "Key quotes:" suffix from the top-3 highest-significance memory rows @@ -274,6 +303,10 @@ def _build_key_quotes_suffix(conn: Connection, scene_id: int) -> str: per-turn narrative seeded by T21, since this helper is called BEFORE the per-POV rewrite. Texts are truncated to 200 chars to bound memory row growth across many witnesses. + + T80.1: candidate text is run through :func:`_strip_key_quotes_suffix` + first so a re-close (whose source memories already carry a suffix from + the prior close) doesn't quote a quote. """ row = conn.execute( "SELECT MAX(significance) FROM memories WHERE scene_id = ?", @@ -288,7 +321,7 @@ def _build_key_quotes_suffix(conn: Connection, scene_id: int) -> str: (scene_id,), ) quotes = [ - (r[0] or "")[:200] + _strip_key_quotes_suffix(r[0] or "")[:200] for r in cur.fetchall() ] if not quotes: diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py index 6984b5c..9be3db1 100644 --- a/tests/test_per_pov_summary.py +++ b/tests/test_per_pov_summary.py @@ -1418,3 +1418,75 @@ def test_consumed_digest_does_not_render_again(tmp_path): body2 = msgs2[0].content assert "Meanwhile while you were away:" not in body2 assert digest_text not in body2 + + +# --------------------------------------------------------------------------- +# T80: scene_summarize polish bundle. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_scene_close_re_run_does_not_double_suffix(tmp_path): + """T80.1: re-running ``apply_scene_close_summary`` on the same scene + must NOT stack a second "Key quotes:" suffix on each pov_summary. The + builder strips any existing suffix from candidate text before + composing the new one, and the per-POV write replaces (not appends + to) the existing suffix. + """ + db = tmp_path / "t.db" + apply_migrations(db) + canned = json.dumps( + { + "summary": "BotA had a heavy talk with you.", + "knowledge_facts": [], + "relationship_summary": "Things shifted.", + } + ) + no_threads = json.dumps({"candidates": []}) + with open_db(db) as conn: + _seed_single_bot_scene_no_memory(conn) + # Significance >= 2 triggers the Key quotes suffix path. + _seed_memory(conn, pov_summary="Maya quote one", significance=3) + _seed_memory(conn, pov_summary="Maya quote two", significance=2) + project(conn) + + # First close. + client = MockLLMClient(canned=[canned, no_threads]) + await apply_scene_close_summary( + conn, + client, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=1, + host_bot_id="bot_a", + ) + + rows = conn.execute( + "SELECT pov_summary FROM memories WHERE scene_id = 1" + ).fetchall() + assert rows + for (pov,) in rows: + assert pov.count("Key quotes:") == 1 + + # Second close on the same scene with fresh canned responses. + client2 = MockLLMClient(canned=[canned, no_threads]) + await apply_scene_close_summary( + conn, + client2, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=1, + host_bot_id="bot_a", + ) + + rows2 = conn.execute( + "SELECT pov_summary FROM memories WHERE scene_id = 1" + ).fetchall() + assert rows2 + for (pov,) in rows2: + # Still exactly ONE "Key quotes:" suffix — no recursive bloat. + assert pov.count("Key quotes:") == 1 + # And no nested-quote artifacts (the suffix wasn't sourced + # from a row whose text already contained the suffix). + inner_count = pov.count("Key quotes:") + assert inner_count == 1