feat: regenerate covers interjection turns (T73.2)
Phase 2 T44 deferred interjection regenerate — when the original turn group included a follow-on interjection beat we left it untouched. Now regenerate redoes BOTH halves: - Detect a sibling interjection by looking up assistant_turn events pinned to the same user_turn_id with `interjection_of` set. - After streaming the new primary, run `detect_interjection` against the new primary text. - If True: stream a new interjection from the silent witness, append with `interjection_of=<new primary speaker_id>`, supersede the original interjection, and re-run memory + state-update for the new beat. - If False: supersede the original interjection without a replacement (back-pointer goes to the new primary so the row stays consistently hidden). Also broadcast a `turn_html_replace` event for the new interjection so the front-end can swap the prior interjection node in place (mirrors T73.1's primary swap). Tests: - `test_regenerate_with_interjection_redoes_both_turns`: classifier returns True; assert two new assistant_turns land for the same user_turn, second carries `interjection_of`, originals superseded. - `test_regenerate_drops_interjection_when_classifier_returns_false`: classifier returns False; assert one new assistant_turn (primary only) and the original interjection is superseded with no replacement. `interjection_of` carries the primary's *speaker_id* (matching the existing convention in chat/web/turns.py) rather than the event_id.
This commit is contained in:
@@ -273,6 +273,114 @@ def test_regenerate_404_when_assistant_turn_missing(client, tmp_path):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _seed_with_interjection_group(db_path):
|
||||
"""Seed a multi-entity scene with a (primary + interjection) group.
|
||||
|
||||
Returns ``(user_turn_id, primary_at_id, interjection_at_id)``.
|
||||
|
||||
The primary speaker is the host (bot_a); the silent witness who
|
||||
interjected is the guest (bot_b). Mirrors the convention in
|
||||
chat/web/turns.py — both assistant_turns share the same
|
||||
``user_turn_id`` and the interjection's payload carries
|
||||
``interjection_of=<primary speaker_id>``.
|
||||
"""
|
||||
with open_db(db_path) as conn:
|
||||
for bot_id, name, persona in (
|
||||
("bot_a", "BotA", "thoughtful"),
|
||||
("bot_b", "BotB", "loud"),
|
||||
):
|
||||
append_event(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
payload={
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": persona,
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "",
|
||||
"kickoff_prose": "",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": "chat_multi",
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1",
|
||||
"weather": "",
|
||||
},
|
||||
)
|
||||
for src, tgt in (
|
||||
("bot_a", "you"),
|
||||
("you", "bot_a"),
|
||||
("bot_b", "you"),
|
||||
("you", "bot_b"),
|
||||
("bot_a", "bot_b"),
|
||||
("bot_b", "bot_a"),
|
||||
):
|
||||
append_event(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src,
|
||||
"target_id": tgt,
|
||||
"chat_id": "chat_multi",
|
||||
},
|
||||
)
|
||||
for entity_id in ("you", "bot_a", "bot_b"):
|
||||
append_event(
|
||||
conn,
|
||||
kind="activity_change",
|
||||
payload={
|
||||
"entity_id": entity_id,
|
||||
"posture": "sitting",
|
||||
"action": {"verb": "talking"},
|
||||
"attention": "",
|
||||
"holding": [],
|
||||
"status": {},
|
||||
},
|
||||
)
|
||||
ut_id = append_event(
|
||||
conn,
|
||||
kind="user_turn",
|
||||
payload={
|
||||
"chat_id": "chat_multi",
|
||||
"prose": "hello",
|
||||
"segments": [],
|
||||
},
|
||||
)
|
||||
primary_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": "chat_multi",
|
||||
"speaker_id": "bot_a",
|
||||
"text": "Original primary.",
|
||||
"truncated": False,
|
||||
"user_turn_id": ut_id,
|
||||
},
|
||||
)
|
||||
interjection_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": "chat_multi",
|
||||
"speaker_id": "bot_b",
|
||||
"text": "Original interjection!",
|
||||
"truncated": False,
|
||||
"user_turn_id": ut_id,
|
||||
"interjection_of": "bot_a",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
return ut_id, primary_id, interjection_id
|
||||
|
||||
|
||||
def test_regenerate_broadcasts_turn_html_over_sse(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
@@ -353,3 +461,204 @@ def test_regenerate_broadcasts_turn_html_over_sse(
|
||||
# Sanity: every publish targeted this chat.
|
||||
for cid, _ev in published:
|
||||
assert cid == "chat_bot_a"
|
||||
|
||||
|
||||
def test_regenerate_with_interjection_redoes_both_turns(tmp_path, monkeypatch):
|
||||
"""T73.2: when the original turn group included an interjection, both
|
||||
the primary and the interjection are regenerated.
|
||||
|
||||
Setup: 3-entity scene (host BotA + guest BotB + you) with a prior
|
||||
(primary by BotA + interjection by BotB) group. Mock the
|
||||
interjection classifier to return ``should_interject=True`` so the
|
||||
follow-on regenerates too.
|
||||
|
||||
Assert: 2 new assistant_turns exist for the same user_turn_id, the
|
||||
second carrying ``interjection_of`` pointing at the new primary's
|
||||
speaker_id. Both originals are superseded.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.services import regenerate as regenerate_module
|
||||
from chat.services.interjection import InterjectionDecision
|
||||
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, primary_id, interjection_id = _seed_with_interjection_group(db_path)
|
||||
|
||||
# Stub detect_interjection so the classifier "fires" with new prose.
|
||||
async def _stub_should_interject(*_args, **_kwargs):
|
||||
return InterjectionDecision(should_interject=True, reason="fired")
|
||||
|
||||
monkeypatch.setattr(
|
||||
regenerate_module, "detect_interjection", _stub_should_interject
|
||||
)
|
||||
|
||||
# Canned queue:
|
||||
# 1. New primary narrative stream.
|
||||
# 2-7. Six state-update classifier calls (one per directed pair
|
||||
# across host/you/guest = 6 pairs) for the primary pass.
|
||||
# 8. New interjection narrative stream.
|
||||
# 9-14. Six state-update classifier calls for the post-interjection
|
||||
# pass.
|
||||
state_canned = json.dumps(
|
||||
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||
)
|
||||
canned: list[str] = []
|
||||
canned.append("New primary text.")
|
||||
canned.extend([state_canned] * 6)
|
||||
canned.append("New interjection text!")
|
||||
canned.extend([state_canned] * 6)
|
||||
mock_client = MockLLMClient(canned=list(canned))
|
||||
|
||||
settings = Settings(featherless_api_key="test")
|
||||
|
||||
with open_db(db_path) as conn:
|
||||
new_text = asyncio.run(
|
||||
regenerate_assistant_turn(
|
||||
conn,
|
||||
mock_client,
|
||||
settings=settings,
|
||||
chat_id="chat_multi",
|
||||
original_assistant_event_id=primary_id,
|
||||
)
|
||||
)
|
||||
assert new_text == "New primary text."
|
||||
|
||||
# Both originals are superseded.
|
||||
primary_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,)
|
||||
).fetchone()[0]
|
||||
interjection_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?",
|
||||
(interjection_id,),
|
||||
).fetchone()[0]
|
||||
assert primary_super is not None
|
||||
assert interjection_super is not None
|
||||
|
||||
# Two NEW assistant_turn events exist (the regenerated primary
|
||||
# and the regenerated interjection), both pinned to the same
|
||||
# user_turn_id as the originals.
|
||||
cur = conn.execute(
|
||||
"SELECT id, payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) "
|
||||
"ORDER BY id",
|
||||
(primary_id, interjection_id),
|
||||
).fetchall()
|
||||
assert len(cur) == 2
|
||||
new_primary_id, new_primary_payload_json = cur[0]
|
||||
new_interjection_id, new_interjection_payload_json = cur[1]
|
||||
new_primary_payload = json.loads(new_primary_payload_json)
|
||||
new_interjection_payload = json.loads(new_interjection_payload_json)
|
||||
|
||||
assert new_primary_payload["text"] == "New primary text."
|
||||
assert new_primary_payload["speaker_id"] == "bot_a"
|
||||
assert new_primary_payload["user_turn_id"] == ut_id
|
||||
assert new_primary_payload["regenerated_from"] == primary_id
|
||||
assert "interjection_of" not in new_primary_payload
|
||||
|
||||
assert new_interjection_payload["text"] == "New interjection text!"
|
||||
assert new_interjection_payload["speaker_id"] == "bot_b"
|
||||
assert new_interjection_payload["user_turn_id"] == ut_id
|
||||
assert new_interjection_payload["regenerated_from"] == interjection_id
|
||||
# interjection_of links to the new primary's speaker (matches
|
||||
# the existing convention in chat/web/turns.py).
|
||||
assert new_interjection_payload["interjection_of"] == "bot_a"
|
||||
|
||||
# The originals' supersede pointers reach the new ones.
|
||||
assert primary_super == new_primary_id
|
||||
assert interjection_super == new_interjection_id
|
||||
|
||||
|
||||
def test_regenerate_drops_interjection_when_classifier_returns_false(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""T73.2: when the original group included an interjection but the
|
||||
classifier returns False this time, the new group is primary-only.
|
||||
|
||||
The original interjection is still superseded (we don't leave it
|
||||
visible in the timeline alongside a regenerated primary it no longer
|
||||
follows from), but no replacement assistant_turn is appended.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.services import regenerate as regenerate_module
|
||||
from chat.services.interjection import InterjectionDecision
|
||||
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, primary_id, interjection_id = _seed_with_interjection_group(db_path)
|
||||
|
||||
async def _stub_no_interject(*_args, **_kwargs):
|
||||
return InterjectionDecision(
|
||||
should_interject=False, reason="quiet"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
regenerate_module, "detect_interjection", _stub_no_interject
|
||||
)
|
||||
|
||||
# Canned queue: primary narrative + 6 state-update calls. No
|
||||
# interjection stream because the classifier short-circuits.
|
||||
state_canned = json.dumps(
|
||||
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||
)
|
||||
canned: list[str] = ["New primary text."] + [state_canned] * 6
|
||||
mock_client = MockLLMClient(canned=list(canned))
|
||||
|
||||
settings = Settings(featherless_api_key="test")
|
||||
|
||||
with open_db(db_path) as conn:
|
||||
new_text = asyncio.run(
|
||||
regenerate_assistant_turn(
|
||||
conn,
|
||||
mock_client,
|
||||
settings=settings,
|
||||
chat_id="chat_multi",
|
||||
original_assistant_event_id=primary_id,
|
||||
)
|
||||
)
|
||||
assert new_text == "New primary text."
|
||||
|
||||
# Original primary superseded by the new primary.
|
||||
primary_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,)
|
||||
).fetchone()[0]
|
||||
# Original interjection ALSO superseded — we don't leave a
|
||||
# dangling beat attached to a regenerated primary that no longer
|
||||
# warrants a follow-on. Back-pointer goes to the new primary.
|
||||
interjection_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?",
|
||||
(interjection_id,),
|
||||
).fetchone()[0]
|
||||
assert primary_super is not None
|
||||
assert interjection_super is not None
|
||||
assert interjection_super == primary_super # both point at new primary
|
||||
|
||||
# Exactly ONE new assistant_turn — the primary; no replacement
|
||||
# interjection.
|
||||
cur = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) "
|
||||
"AND superseded_by IS NULL",
|
||||
(primary_id, interjection_id),
|
||||
).fetchall()
|
||||
assert len(cur) == 1
|
||||
new_primary_payload = json.loads(cur[0][0])
|
||||
assert new_primary_payload["text"] == "New primary text."
|
||||
assert "interjection_of" not in new_primary_payload
|
||||
|
||||
Reference in New Issue
Block a user