chore: document regenerate lifecycle-rollback limitation with warning log (T83.4)
When a regenerate replaces an assistant_turn that already produced lifecycle transitions (``event_started`` / ``event_completed`` / ``event_cancelled``), those transitions are NOT rolled back before ``detect_event_transitions`` re-runs against the new text. A regenerate-after-completion can therefore double-emit promotion artifacts. Phase 3.5 first cut (per the task plan): documentation + WARNING log naming the affected event_log ids. The actual undo pass is invasive (re-projection / inverse-handler dispatch) and is deferred to Phase 4. Implementation: - TODO docstring block at the top of ``regenerate_assistant_turn``. - Module-level ``_log = logging.getLogger(__name__)``. - Scan immediately after the original assistant_turn row is located: joins event_log lifecycle rows to the events table on event_id so we can scope by chat (lifecycle payloads carry only ``event_id``, not ``chat_id``). Filter ``id > original_assistant_event_id`` as the positional linkage to "transitions emitted as part of (or after) this turn's processing." Decision (asked in the brief): the scan uses the ``id > original`` heuristic rather than scanning for explicit references. Lifecycle event payloads do not carry a back-pointer to the assistant_turn that triggered them — the linkage is positional in the event log. A tighter linkage would require either adding a payload field on lifecycle events (cross-cutting schema change) or threading the just-appended assistant_turn id into ``detect_event_transitions``'s emit calls (narrow but still cross-cutting). The positional heuristic is loose but conservative: a turn that emits no lifecycle events produces no warning, and the warning's purpose is operator-visible breadcrumbs not an exact rollback set. Test: test_regenerate_with_prior_lifecycle_logs_warning seeds a turn that produced ``event_started`` + ``event_completed`` rows and asserts the WARNING fires with the expected ids.
This commit is contained in:
@@ -664,6 +664,101 @@ def test_regenerate_drops_interjection_when_classifier_returns_false(
|
||||
assert "interjection_of" not in new_primary_payload
|
||||
|
||||
|
||||
def test_regenerate_with_prior_lifecycle_logs_warning(tmp_path, monkeypatch, caplog):
|
||||
"""T83.4: when the superseded assistant_turn already produced
|
||||
lifecycle transitions (event_started / event_completed /
|
||||
event_cancelled), regenerate emits a WARNING naming the un-rolled-
|
||||
back transitions. Phase 3.5 documents the gap; the actual rollback
|
||||
is Phase 4 work.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.services.regenerate import regenerate_assistant_turn
|
||||
|
||||
db_path = tmp_path / "test.db"
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('featherless_api_key = "test"\n')
|
||||
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||
monkeypatch.setenv("CHAT_DB_PATH", str(db_path))
|
||||
apply_migrations(db_path)
|
||||
|
||||
_ut_id, at_id = _seed_with_one_turn(db_path)
|
||||
|
||||
# After the assistant_turn lands, simulate that the turn flow
|
||||
# produced an event_completed transition. ``append_and_apply`` is
|
||||
# the standard path so the events projection updates.
|
||||
with open_db(db_path) as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_x",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "story_event",
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_x",
|
||||
"started_at": "2026-04-30T19:00:00+00:00",
|
||||
},
|
||||
)
|
||||
completed_id = append_and_apply(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": "evt_x",
|
||||
"completed_at": "2026-04-30T19:30:00+00:00",
|
||||
},
|
||||
)
|
||||
assert completed_id is not None
|
||||
|
||||
state_canned = json.dumps(
|
||||
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||
)
|
||||
mock_client = MockLLMClient(
|
||||
canned=["Refreshed reply.", state_canned, state_canned]
|
||||
)
|
||||
settings = Settings(featherless_api_key="test")
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="chat.services.regenerate")
|
||||
|
||||
with open_db(db_path) as conn:
|
||||
asyncio.run(
|
||||
regenerate_assistant_turn(
|
||||
conn,
|
||||
mock_client,
|
||||
settings=settings,
|
||||
chat_id="chat_bot_a",
|
||||
original_assistant_event_id=at_id,
|
||||
)
|
||||
)
|
||||
|
||||
# The warning records the count and at least one of the affected
|
||||
# event_log ids (event_started + event_completed = at minimum 2).
|
||||
warnings = [
|
||||
r for r in caplog.records if r.levelname == "WARNING"
|
||||
]
|
||||
matching = [w for w in warnings if "lifecycle transition" in w.getMessage()]
|
||||
assert matching, (
|
||||
"expected a WARNING about un-rolled-back lifecycle transitions; "
|
||||
f"got: {[w.getMessage() for w in warnings]}"
|
||||
)
|
||||
msg = matching[0].getMessage()
|
||||
# Reference the original superseded turn's id and the event_completed
|
||||
# row's id.
|
||||
assert str(at_id) in msg
|
||||
assert str(completed_id) in msg
|
||||
|
||||
|
||||
def test_regenerate_sibling_lookup_scoped_to_chat(tmp_path, monkeypatch):
|
||||
"""T83.3: regenerate's sibling-interjection lookup is scoped to the
|
||||
chat being regenerated.
|
||||
|
||||
Reference in New Issue
Block a user