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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user