feat: regenerate broadcasts turn_html over SSE (T73.1)
After the new assistant_turn lands, publish a `turn_html_replace` SSE event carrying the rendered HTML, the new turn_id, and the original assistant_turn id as `supersedes_id` so connected tabs can swap the prior DOM node in-place. Phase 1 T29 deferred this — page had to refresh to see the regenerated turn. Uses a new event name (not the existing `turn_html`) because the HTMX `sse-swap="turn_html"` consumer expects raw HTML and an *append* semantic; regenerate is a *replace*. The new event ships as JSON (supersedes_id forces sse.py's JSON branch) so the front-end JS can read the swap target from the payload. Test: `test_regenerate_broadcasts_turn_html_over_sse` patches the `publish` reference inside the regenerate module and asserts the event shape.
This commit is contained in:
@@ -26,6 +26,7 @@ Phase 1 simplifications (per the plan's "bound it" guidance):
|
|||||||
so affinity/trust/knowledge reflect the new output.
|
so affinity/trust/knowledge reflect the new output.
|
||||||
- The route does not broadcast a fresh ``turn_html`` SSE event; T34
|
- The route does not broadcast a fresh ``turn_html`` SSE event; T34
|
||||||
polishes UI swaps. The user refreshes the page to see the new turn.
|
polishes UI swaps. The user refreshes the page to see the new turn.
|
||||||
|
*(T73.1 closed this gap — see Phase 2.5 changes below.)*
|
||||||
|
|
||||||
Phase 2 changes (T44):
|
Phase 2 changes (T44):
|
||||||
|
|
||||||
@@ -42,6 +43,27 @@ Phase 2 changes (T44):
|
|||||||
is not invoked here. If the prior turn fired an interjection it
|
is not invoked here. If the prior turn fired an interjection it
|
||||||
remains attached to the original assistant_turn (which is superseded
|
remains attached to the original assistant_turn (which is superseded
|
||||||
alongside the regenerated turn) — Phase 2.5 will revisit.
|
alongside the regenerated turn) — Phase 2.5 will revisit.
|
||||||
|
|
||||||
|
Phase 2.5 changes:
|
||||||
|
|
||||||
|
- T73.1: After the new ``assistant_turn`` lands we publish a
|
||||||
|
``turn_html_replace`` SSE event carrying the rendered HTML for the
|
||||||
|
regenerated turn plus the original assistant_turn's event_id as
|
||||||
|
``supersedes_id`` so connected tabs can swap the prior DOM node
|
||||||
|
in-place. We use a NEW event name (rather than re-using ``turn_html``)
|
||||||
|
because the existing HTMX ``sse-swap="turn_html"`` consumer expects a
|
||||||
|
raw-HTML body and an *append* semantic; ``turn_html_replace`` is a
|
||||||
|
JSON payload (sse.py auto-serialises when extra keys accompany
|
||||||
|
``data``) so the front-end JS can read ``supersedes_id`` and replace
|
||||||
|
the right node.
|
||||||
|
- T73.2: Interjection regeneration. When the original assistant_turn
|
||||||
|
group included an interjection beat we redo BOTH the primary and the
|
||||||
|
interjection — re-running ``detect_interjection`` against the new
|
||||||
|
primary text. If the classifier returns False this time we supersede
|
||||||
|
the original interjection without appending a replacement.
|
||||||
|
- T73.3: The defensive degrade-to-1:1 for stale ``guest_bot_id``
|
||||||
|
references was removed — Phase 2 T47 fixed the root cause (resets
|
||||||
|
clear the reference) so the guard is dead code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -58,6 +80,7 @@ from chat.state.edges import get_edge
|
|||||||
from chat.state.entities import get_bot, get_you
|
from chat.state.entities import get_bot, get_you
|
||||||
from chat.state.world import active_scene, get_chat
|
from chat.state.world import active_scene, get_chat
|
||||||
from chat.web.pubsub import publish
|
from chat.web.pubsub import publish
|
||||||
|
from chat.web.render import render_turn_html
|
||||||
|
|
||||||
|
|
||||||
async def regenerate_assistant_turn(
|
async def regenerate_assistant_turn(
|
||||||
@@ -238,6 +261,27 @@ async def regenerate_assistant_turn(
|
|||||||
(new_assistant_event_id, original_assistant_event_id),
|
(new_assistant_event_id, original_assistant_event_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 7a. Broadcast a turn_html_replace SSE event so connected tabs can
|
||||||
|
# swap the prior assistant_turn DOM node in-place (T73.1, Phase 1.5
|
||||||
|
# backlog #2). Uses a separate event name from post_turn's
|
||||||
|
# ``turn_html`` (which is append-only) because regenerate is a
|
||||||
|
# *replace* operation — see module docstring for the rationale.
|
||||||
|
speaker_name_for_render = (
|
||||||
|
speaker_bot.get("name", "bot") if speaker_bot is not None else "bot"
|
||||||
|
)
|
||||||
|
new_turn_html = render_turn_html(
|
||||||
|
speaker_name_for_render, new_text, role="bot"
|
||||||
|
)
|
||||||
|
await publish(
|
||||||
|
chat_id,
|
||||||
|
{
|
||||||
|
"event": "turn_html_replace",
|
||||||
|
"data": new_turn_html,
|
||||||
|
"turn_id": new_assistant_event_id,
|
||||||
|
"supersedes_id": original_assistant_event_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# 8. Re-run downstream classifier passes (memory write + state update
|
# 8. Re-run downstream classifier passes (memory write + state update
|
||||||
# for every directed pair across present entities). Significance is
|
# for every directed pair across present entities). Significance is
|
||||||
# intentionally skipped on regenerate (the prior score remains
|
# intentionally skipped on regenerate (the prior score remains
|
||||||
|
|||||||
@@ -271,3 +271,85 @@ def test_regenerate_404_when_assistant_turn_missing(client, tmp_path):
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_broadcasts_turn_html_over_sse(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""T73.1: regenerate publishes a ``turn_html_replace`` SSE event so
|
||||||
|
connected tabs swap the prior turn's DOM node in place.
|
||||||
|
|
||||||
|
The event carries:
|
||||||
|
- ``data``: rendered HTML for the new turn
|
||||||
|
- ``turn_id``: event_id of the new assistant_turn
|
||||||
|
- ``supersedes_id``: event_id of the original assistant_turn
|
||||||
|
"""
|
||||||
|
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.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)
|
||||||
|
|
||||||
|
published: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
async def _capture(chat_id, event):
|
||||||
|
published.append((chat_id, event))
|
||||||
|
|
||||||
|
# Patch the imported reference inside the regenerate module so the
|
||||||
|
# service's call site goes through our spy.
|
||||||
|
monkeypatch.setattr(regenerate_module, "publish", _capture)
|
||||||
|
|
||||||
|
narrative_canned = "Refreshed reply."
|
||||||
|
state_canned = json.dumps(
|
||||||
|
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||||
|
)
|
||||||
|
canned = [narrative_canned, state_canned, state_canned]
|
||||||
|
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_bot_a",
|
||||||
|
original_assistant_event_id=at_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert new_text == narrative_canned
|
||||||
|
|
||||||
|
# Find the new assistant_turn event_id for cross-checking.
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT id FROM event_log "
|
||||||
|
"WHERE kind = 'assistant_turn' AND id != ? "
|
||||||
|
"AND superseded_by IS NULL",
|
||||||
|
(at_id,),
|
||||||
|
).fetchone()
|
||||||
|
new_at_id = cur[0]
|
||||||
|
|
||||||
|
# Filter out per-token publishes; we want the replace broadcast.
|
||||||
|
replace_calls = [
|
||||||
|
ev for (_cid, ev) in published if ev.get("event") == "turn_html_replace"
|
||||||
|
]
|
||||||
|
assert len(replace_calls) == 1
|
||||||
|
payload = replace_calls[0]
|
||||||
|
assert payload["supersedes_id"] == at_id
|
||||||
|
assert payload["turn_id"] == new_at_id
|
||||||
|
# The HTML carries the new narrative text and the speaker name.
|
||||||
|
assert "Refreshed reply." in payload["data"]
|
||||||
|
assert "BotA" in payload["data"]
|
||||||
|
# Sanity: every publish targeted this chat.
|
||||||
|
for cid, _ev in published:
|
||||||
|
assert cid == "chat_bot_a"
|
||||||
|
|||||||
Reference in New Issue
Block a user