0de4d1252c
Cosmetic-only renumbering of the event-lifecycle detection block in ``regenerate_assistant_turn`` from ``# 10.`` to ``# 9a.`` — mirrors the ``# 8a.`` shape in ``chat.web.turns.post_turn``. The block was already in the correct structural position (immediately after the interjection branch); only the numbering and comment reflected an earlier draft where it read as a final step rather than the post-interjection / pre-(absent)-scene-close slot. No behavioural change. All 9 regenerate tests + 18 turn_flow tests pass without modification.
759 lines
32 KiB
Python
759 lines
32 KiB
Python
"""Regenerate flow (T29).
|
|
|
|
The user clicks "Regenerate" on the latest ``assistant_turn``. The UI
|
|
puts the prior ``user_turn`` into inline edit mode and submits to
|
|
:func:`regenerate_assistant_turn` either:
|
|
|
|
- with **no edit** — we re-run the narrative against the original user
|
|
prose and append a fresh ``assistant_turn`` superseding the old one;
|
|
- with **edited prose** — we additionally append a ``user_turn_edit``
|
|
event capturing the new prose, mark the original ``user_turn`` as
|
|
superseded by the edit, then run the narrative against the edited
|
|
prose.
|
|
|
|
Per Requirements §10.2 superseded events are *kept in the log* — the
|
|
display layer hides them. This is what makes rewinding to before a
|
|
regenerate cheap: we just clear ``superseded_by`` on the old row.
|
|
|
|
The supersede update is one of the rare "direct DB write" exceptions
|
|
documented in the plan: we manipulate metadata fields on the canonical
|
|
event_log row itself rather than projecting through a handler.
|
|
|
|
Phase 1 simplifications (per the plan's "bound it" guidance):
|
|
|
|
- Significance pass is *not* re-run on regenerate. The original score
|
|
remains attached to the prior memory. The state-update pass *is* re-run
|
|
so affinity/trust/knowledge reflect the new output.
|
|
- 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.
|
|
*(T73.1 closed this gap — see Phase 2.5 changes below.)*
|
|
|
|
Phase 2 changes (T44):
|
|
|
|
- Multi-entity prompt assembly: ``guest_id`` is forwarded to the
|
|
prompt assembler so the regenerated narrative sees the same
|
|
guest-aware context the original turn did.
|
|
- Multi-witness memory write: ``record_turn_memory_for_present`` fans
|
|
out one ``memory_written`` event per witness when a guest is present.
|
|
- Multi-pair state-update: ``compute_state_updates_for_present`` emits
|
|
one ``edge_update`` per directed pair across present entities. With
|
|
three present that's six edges instead of two.
|
|
- Interjection regeneration is **deferred to Phase 2.5**. Regenerate
|
|
only re-streams the addressee turn for v2; ``detect_interjection``
|
|
is not invoked here. If the prior turn fired an interjection it
|
|
remains attached to the original assistant_turn (which is superseded
|
|
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
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from sqlite3 import Connection
|
|
|
|
from chat.config import Settings
|
|
from chat.eventlog.log import append_and_apply, append_event
|
|
from chat.services.event_lifecycle import detect_event_transitions
|
|
from chat.services.event_promotion import promote_completed_event
|
|
from chat.services.interjection import detect_interjection
|
|
from chat.services.memory_write import record_turn_memory_for_present
|
|
from chat.services.multi_state_update import compute_state_updates_for_present
|
|
from chat.services.prompt import assemble_narrative_prompt
|
|
from chat.services.turn_common import (
|
|
gather_prior_edges,
|
|
read_recent_dialogue,
|
|
)
|
|
from chat.state.edges import get_edge
|
|
from chat.state.entities import get_bot, get_you
|
|
from chat.state.events import list_active_events
|
|
from chat.state.world import active_scene, get_chat
|
|
from chat.web.pubsub import publish
|
|
from chat.web.render import render_turn_html
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
async def regenerate_assistant_turn(
|
|
conn: Connection,
|
|
client,
|
|
*,
|
|
settings: Settings,
|
|
chat_id: str,
|
|
original_assistant_event_id: int,
|
|
edited_user_prose: str | None = None,
|
|
) -> str:
|
|
"""Regenerate the assistant turn linked to ``original_assistant_event_id``.
|
|
|
|
When ``edited_user_prose`` is provided the original user_turn is also
|
|
superseded by a fresh ``user_turn_edit`` event capturing the new
|
|
prose. Returns the new assistant text.
|
|
|
|
Raises :class:`ValueError` when the chat or the assistant_turn event
|
|
cannot be found — the FastAPI route translates this to 404.
|
|
|
|
.. note::
|
|
**Lifecycle-rollback limitation (T83.4, Phase 4 follow-up).**
|
|
When the superseded turn already produced lifecycle transitions
|
|
(``event_started`` / ``event_completed`` / ``event_cancelled``),
|
|
this function does NOT roll those rows back before re-running
|
|
``detect_event_transitions`` against the regenerated text. A
|
|
regenerate-after-completion can therefore double-emit promotion
|
|
artifacts if the new text re-completes the same event. Phase 3.5
|
|
only documents the gap and emits a 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. See the ``# T83.4`` block below for the warning emit.
|
|
"""
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise ValueError("chat not found")
|
|
host_bot_id = chat["host_bot_id"]
|
|
host_bot = get_bot(conn, host_bot_id) or {
|
|
"id": host_bot_id,
|
|
"name": "bot",
|
|
"persona": "",
|
|
}
|
|
|
|
# Phase 2: surface the guest (if any) so the prompt assembler and
|
|
# downstream multi-entity passes see the same shape post_turn does.
|
|
# Phase 2 T47 made bot_reset cascade-clear ``chat.guest_bot_id`` when
|
|
# the referenced bot is purged (verified by tests/test_reset.py), so
|
|
# we trust the column here: it's either a valid bot id or NULL.
|
|
guest_bot_id = chat.get("guest_bot_id")
|
|
guest_bot: dict | None = (
|
|
get_bot(conn, guest_bot_id) if guest_bot_id is not None else None
|
|
)
|
|
|
|
# 1. Locate the original assistant_turn event.
|
|
row = conn.execute(
|
|
"SELECT payload_json FROM event_log "
|
|
"WHERE id = ? AND kind = 'assistant_turn'",
|
|
(original_assistant_event_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise ValueError("assistant_turn event not found")
|
|
original_assistant_payload = json.loads(row[0])
|
|
original_user_turn_id = original_assistant_payload.get("user_turn_id")
|
|
|
|
# T83.4: scan for downstream lifecycle transitions emitted by the
|
|
# superseded turn — they're not being rolled back (see method
|
|
# docstring). Heuristic: any ``event_started`` / ``event_completed``
|
|
# / ``event_cancelled`` event_log row with id strictly greater than
|
|
# the original assistant_turn's id was emitted as part of (or after)
|
|
# that turn's processing. Lifecycle events don't carry ``chat_id``
|
|
# in their payload (their payload references an ``event_id`` FK to
|
|
# the ``events`` table, which holds chat_id), so we join through
|
|
# ``events`` to scope to this chat.
|
|
#
|
|
# A WARNING log surfaces the affected event ids so operators can
|
|
# spot double-emit cases until the Phase 4 rollback pass lands.
|
|
unrolled_lifecycle = conn.execute(
|
|
"SELECT el.id, el.kind FROM event_log AS el "
|
|
"JOIN events AS ev "
|
|
" ON ev.event_id = json_extract(el.payload_json, '$.event_id') "
|
|
"WHERE el.kind IN ("
|
|
" 'event_started', 'event_completed', 'event_cancelled'"
|
|
" ) "
|
|
" AND ev.chat_id = ? "
|
|
" AND el.id > ? "
|
|
"ORDER BY el.id ASC",
|
|
(chat_id, original_assistant_event_id),
|
|
).fetchall()
|
|
if unrolled_lifecycle:
|
|
_log.warning(
|
|
"regenerate_assistant_turn: %d lifecycle transition(s) from "
|
|
"superseded turn %s are NOT being rolled back (Phase 4 "
|
|
"follow-up). Affected event ids: %s",
|
|
len(unrolled_lifecycle),
|
|
original_assistant_event_id,
|
|
[r[0] for r in unrolled_lifecycle],
|
|
)
|
|
|
|
# 1a. Look up any sibling interjection beat in the same turn group
|
|
# (T73.2). The original group is (primary + optional interjection),
|
|
# both pinned to the same ``user_turn_id``. The interjection has a
|
|
# populated ``interjection_of`` field in its payload — its speaker is
|
|
# the silent witness (the bot that wasn't the primary addressee).
|
|
# Filter on ``superseded_by IS NULL`` so prior regenerates of this
|
|
# group don't reappear as siblings.
|
|
#
|
|
# T83.3: push the chat_id filter into SQL via ``json_extract`` so
|
|
# the query doesn't scan every assistant_turn row across the whole
|
|
# database. ``LIMIT 50`` bounds worst-case work even when chat_id
|
|
# isn't selective (e.g. a single chat with many turns) — we only
|
|
# need the one matching sibling. Mirrors the SQL pattern in
|
|
# ``chat.web.meanwhile._last_meanwhile_speaker``.
|
|
original_interjection_event_id: int | None = None
|
|
original_interjection_payload: dict | None = None
|
|
if original_user_turn_id is not None:
|
|
sibling_cur = conn.execute(
|
|
"SELECT id, payload_json FROM event_log "
|
|
"WHERE kind = 'assistant_turn' "
|
|
" AND id != ? "
|
|
" AND superseded_by IS NULL "
|
|
" AND json_extract(payload_json, '$.chat_id') = ? "
|
|
"ORDER BY id DESC "
|
|
"LIMIT 50",
|
|
(original_assistant_event_id, chat_id),
|
|
)
|
|
for sib_id, sib_payload_json in sibling_cur.fetchall():
|
|
sib_payload = json.loads(sib_payload_json)
|
|
if sib_payload.get("user_turn_id") != original_user_turn_id:
|
|
continue
|
|
if not sib_payload.get("interjection_of"):
|
|
continue
|
|
original_interjection_event_id = sib_id
|
|
original_interjection_payload = sib_payload
|
|
break
|
|
# Phase 2 v2 regenerates only the addressee turn — preserve whichever
|
|
# bot the original turn was attributed to, falling back to the host
|
|
# for legacy rows that pre-date multi-entity support.
|
|
speaker_bot_id = original_assistant_payload.get("speaker_id") or host_bot_id
|
|
if speaker_bot_id == host_bot_id:
|
|
speaker_bot = host_bot
|
|
elif guest_bot is not None and speaker_bot_id == guest_bot.get("id"):
|
|
speaker_bot = guest_bot
|
|
else:
|
|
speaker_bot = get_bot(conn, speaker_bot_id) or host_bot
|
|
speaker_bot_id = speaker_bot.get("id", host_bot_id)
|
|
|
|
# 2. Determine the prose for the new prompt and (when edited) capture
|
|
# the user_turn_edit event up front so the new event ids exist before
|
|
# we link them from the assistant_turn payload.
|
|
new_user_event_id: int | None = None
|
|
if edited_user_prose is not None:
|
|
new_user_event_id = append_event(
|
|
conn,
|
|
kind="user_turn_edit",
|
|
payload={
|
|
"chat_id": chat_id,
|
|
"prose": edited_user_prose,
|
|
"supersedes_user_turn_id": original_user_turn_id,
|
|
},
|
|
)
|
|
if original_user_turn_id is not None:
|
|
conn.execute(
|
|
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
|
(new_user_event_id, original_user_turn_id),
|
|
)
|
|
prose_for_prompt = edited_user_prose
|
|
else:
|
|
original_user_row = conn.execute(
|
|
"SELECT payload_json FROM event_log WHERE id = ?",
|
|
(original_user_turn_id,),
|
|
).fetchone() if original_user_turn_id is not None else None
|
|
if original_user_row is not None:
|
|
prose_for_prompt = json.loads(original_user_row[0]).get("prose", "")
|
|
else:
|
|
prose_for_prompt = ""
|
|
|
|
# 3. Build the recent-dialogue slice. Exclude the original
|
|
# assistant_turn explicitly (we haven't superseded it yet — that
|
|
# update lands at the end so the new event_id is known) and use the
|
|
# standard ``superseded_by IS NULL AND hidden = 0`` filter so any
|
|
# prior regenerates also drop out. T83.2: shared helper handles the
|
|
# SQL + filtering; we post-process to map speaker ids to display
|
|
# names for the prompt.
|
|
you_entity = get_you(conn) or {"name": "you", "persona": ""}
|
|
you_name = you_entity.get("name", "you")
|
|
raw_recent = read_recent_dialogue(
|
|
conn,
|
|
chat_id,
|
|
limit=20,
|
|
exclude_event_id=original_assistant_event_id,
|
|
)
|
|
recent: list[dict] = []
|
|
for entry in raw_recent:
|
|
spk = entry.get("speaker", "bot")
|
|
if spk == "you":
|
|
recent.append({"speaker": you_name, "text": entry.get("text", "")})
|
|
continue
|
|
if spk == host_bot_id:
|
|
spk_name = host_bot.get("name", "bot")
|
|
elif guest_bot is not None and spk == guest_bot.get("id"):
|
|
spk_name = guest_bot.get("name", "bot")
|
|
else:
|
|
spk_name = host_bot.get("name", "bot")
|
|
recent.append({"speaker": spk_name, "text": entry.get("text", "")})
|
|
|
|
# 4. Assemble the narrative prompt. ``recent`` already excludes the
|
|
# current user prose, which we pass through ``user_turn_prose``.
|
|
# Phase 2: forward ``guest_id`` so the prompt sees the third party.
|
|
messages = assemble_narrative_prompt(
|
|
conn,
|
|
chat_id=chat_id,
|
|
speaker_bot_id=speaker_bot_id,
|
|
user_turn_prose=prose_for_prompt or None,
|
|
recent_dialogue=recent,
|
|
budget_soft=settings.narrative_budget_soft,
|
|
budget_hard=settings.narrative_budget_hard,
|
|
guest_id=guest_bot_id,
|
|
)
|
|
|
|
# 5. Stream the new narrative. T83.1: register the streaming Task in
|
|
# the chat-keyed in-flight registry so POST /chats/<id>/turns/cancel
|
|
# can call ``.cancel()`` on a mid-regenerate stream. We import the
|
|
# underscore name from turns.py deliberately — same single-process
|
|
# registry the cancel route reads, mirrors the meanwhile registration
|
|
# pattern in chat/web/meanwhile.py.
|
|
from chat.web.turns import _in_flight_tasks # noqa: PLC0415
|
|
|
|
accumulated: list[str] = []
|
|
|
|
async def _stream_primary() -> None:
|
|
async for chunk in client.stream(
|
|
messages,
|
|
model=settings.narrative_model,
|
|
max_tokens=settings.narrative_max_tokens,
|
|
temperature=settings.narrative_temperature,
|
|
):
|
|
accumulated.append(chunk)
|
|
await publish(
|
|
chat_id,
|
|
{"event": "token", "text": chunk, "speaker_id": speaker_bot_id},
|
|
)
|
|
|
|
stream_task = asyncio.create_task(_stream_primary())
|
|
_in_flight_tasks[chat_id] = stream_task
|
|
try:
|
|
await stream_task
|
|
finally:
|
|
# Always unregister so a subsequent turn / regenerate can register
|
|
# a fresh task. Mirrors the cleanup in turns.py::post_turn.
|
|
_in_flight_tasks.pop(chat_id, None)
|
|
new_text = "".join(accumulated)
|
|
|
|
# 6. Append the new assistant_turn event. ``user_turn_id`` points at
|
|
# the edit event when one was created, otherwise the original. The
|
|
# ``regenerated_from`` field is the back-pointer the UI uses for an
|
|
# "originally said …" affordance.
|
|
new_assistant_event_id = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": chat_id,
|
|
"speaker_id": speaker_bot_id,
|
|
"text": new_text,
|
|
"truncated": False,
|
|
"user_turn_id": (
|
|
new_user_event_id
|
|
if new_user_event_id is not None
|
|
else original_user_turn_id
|
|
),
|
|
"regenerated_from": original_assistant_event_id,
|
|
},
|
|
)
|
|
|
|
# 7. Mark the original assistant_turn as superseded by the new one.
|
|
conn.execute(
|
|
"UPDATE event_log SET superseded_by = ? WHERE 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
|
|
# for every directed pair across present entities). Significance is
|
|
# intentionally skipped on regenerate (the prior score remains
|
|
# attached to the prior memory). Phase 2.5 will add interjection
|
|
# regeneration; v2 leaves any prior interjection beat in place.
|
|
scene = active_scene(conn, chat_id)
|
|
record_turn_memory_for_present(
|
|
conn,
|
|
chat_id=chat_id,
|
|
host_bot_id=host_bot_id,
|
|
guest_bot_id=guest_bot_id,
|
|
narrative_text=new_text,
|
|
scene_id=scene["id"] if scene else None,
|
|
chat_clock_at=chat.get("time"),
|
|
)
|
|
|
|
last_at = chat.get("time")
|
|
speaker_name = (
|
|
speaker_bot.get("name", "bot") if speaker_bot is not None else "bot"
|
|
)
|
|
recent_for_update = recent + [
|
|
{"speaker": speaker_name, "text": new_text}
|
|
]
|
|
|
|
# Build present-entity inputs for the multi-pair state-update pass.
|
|
# Host first preserves the Phase 1 directed-pair order (host->you,
|
|
# then you->host) so existing canned-response fixtures still line up.
|
|
present_ids: list[str] = [host_bot_id, "you"]
|
|
present_names: dict[str, str] = {
|
|
host_bot_id: host_bot.get("name", "bot"),
|
|
"you": you_name,
|
|
}
|
|
personas: dict[str, str] = {
|
|
host_bot_id: host_bot.get("persona") or "",
|
|
"you": you_entity.get("persona") or "",
|
|
}
|
|
if guest_bot is not None and guest_bot_id is not None:
|
|
present_ids.append(guest_bot_id)
|
|
present_names[guest_bot_id] = guest_bot.get("name", "bot")
|
|
personas[guest_bot_id] = guest_bot.get("persona") or ""
|
|
|
|
# T83.2: shared helper builds the directed-pair edge dict.
|
|
prior_edges = gather_prior_edges(conn, present_ids)
|
|
|
|
state_updates = await compute_state_updates_for_present(
|
|
client,
|
|
classifier_model=settings.classifier_model,
|
|
present_ids=present_ids,
|
|
present_names=present_names,
|
|
personas=personas,
|
|
prior_edges=prior_edges,
|
|
recent_dialogue=recent_for_update,
|
|
timeout_s=settings.classifier_timeout_s,
|
|
)
|
|
for src_id, tgt_id, update in state_updates:
|
|
append_and_apply(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": src_id,
|
|
"target_id": tgt_id,
|
|
"chat_id": chat_id,
|
|
"affinity_delta": update.affinity_delta,
|
|
"trust_delta": update.trust_delta,
|
|
"knowledge_facts": update.knowledge_facts,
|
|
"last_interaction_at": last_at,
|
|
"last_interaction_chat_id": chat_id,
|
|
},
|
|
)
|
|
|
|
# 9. Interjection regenerate branch (T73.2). When the original
|
|
# assistant_turn group included a follow-on interjection beat we need
|
|
# to revisit that beat against the regenerated primary. Three outcomes:
|
|
#
|
|
# - No original interjection: nothing to do; we already short-circuit
|
|
# above by leaving ``original_interjection_event_id`` as None.
|
|
# - Original interjection + classifier returns True: stream a fresh
|
|
# interjection from the silent witness, append it (with
|
|
# ``interjection_of`` linking to the new primary speaker), and
|
|
# supersede the original interjection's row. Also re-run memory
|
|
# + state-update so the second beat moves edges + writes memories.
|
|
# - Original interjection + classifier returns False: supersede the
|
|
# original interjection without appending a replacement. The
|
|
# regenerated group becomes "primary only" because the new primary
|
|
# no longer warrants a follow-on. No memory / state work needed
|
|
# for the absent beat.
|
|
#
|
|
# ``superseded_by`` on the original interjection's row points at the
|
|
# *new primary* in the no-replacement case (rather than NULL or a
|
|
# nonexistent id) so the row is consistently hidden by the standard
|
|
# ``superseded_by IS NULL`` timeline filter and the back-pointer
|
|
# leads somewhere meaningful for an "originally said …" affordance.
|
|
if original_interjection_event_id is not None and guest_bot is not None:
|
|
# Identify the silent witness from the original interjection's
|
|
# speaker_id (which is the bot that interjected last time). When
|
|
# we regenerate we keep the *same pair of present entities*, so
|
|
# the silent witness is whichever bot isn't the new primary
|
|
# speaker — derive it from present rather than reusing the prior
|
|
# speaker_id verbatim, in case the regenerated primary swapped
|
|
# who held the floor.
|
|
if speaker_bot_id == host_bot_id:
|
|
silent_witness = guest_bot
|
|
else:
|
|
silent_witness = host_bot
|
|
silent_witness_id = silent_witness.get("id")
|
|
|
|
edge_w_to_addr = get_edge(conn, silent_witness_id, speaker_bot_id) or {
|
|
"affinity": 50,
|
|
"trust": 50,
|
|
"summary": "",
|
|
}
|
|
edge_w_to_you = get_edge(conn, silent_witness_id, "you") or {
|
|
"affinity": 50,
|
|
"trust": 50,
|
|
"summary": "",
|
|
}
|
|
|
|
decision = await detect_interjection(
|
|
client,
|
|
classifier_model=settings.classifier_model,
|
|
addressee_name=speaker_bot.get("name", "bot"),
|
|
addressee_just_said=new_text,
|
|
silent_witness_name=silent_witness.get("name", "bot"),
|
|
silent_witness_persona=silent_witness.get("persona") or "",
|
|
silent_witness_edge_to_addressee=edge_w_to_addr,
|
|
silent_witness_edge_to_you=edge_w_to_you,
|
|
you_just_said=prose_for_prompt or "",
|
|
timeout_s=settings.classifier_timeout_s,
|
|
)
|
|
|
|
if decision.should_interject:
|
|
# Re-read recent so the just-appended primary is in the
|
|
# prompt. T83.2: shared helper + the same id->name mapping
|
|
# as the primary read above.
|
|
raw_interject = read_recent_dialogue(conn, chat_id, limit=20)
|
|
interject_recent: list[dict] = []
|
|
for entry in raw_interject:
|
|
spk = entry.get("speaker", "bot")
|
|
if spk == "you":
|
|
interject_recent.append(
|
|
{"speaker": you_name, "text": entry.get("text", "")}
|
|
)
|
|
continue
|
|
if spk == host_bot_id:
|
|
spk_name = host_bot.get("name", "bot")
|
|
elif spk == guest_bot.get("id"):
|
|
spk_name = guest_bot.get("name", "bot")
|
|
else:
|
|
spk_name = "bot"
|
|
interject_recent.append(
|
|
{"speaker": spk_name, "text": entry.get("text", "")}
|
|
)
|
|
if interject_recent and interject_recent[-1].get("speaker") == you_name:
|
|
interject_recent = interject_recent[:-1]
|
|
|
|
interject_messages = assemble_narrative_prompt(
|
|
conn,
|
|
chat_id=chat_id,
|
|
speaker_bot_id=silent_witness_id,
|
|
addressee=speaker_bot_id,
|
|
user_turn_prose=prose_for_prompt or None,
|
|
recent_dialogue=interject_recent,
|
|
budget_soft=settings.narrative_budget_soft,
|
|
budget_hard=settings.narrative_budget_hard,
|
|
guest_id=guest_bot_id,
|
|
)
|
|
|
|
interject_accumulated: list[str] = []
|
|
|
|
async def _stream_interjection() -> None:
|
|
async for chunk in client.stream(
|
|
interject_messages,
|
|
model=settings.narrative_model,
|
|
max_tokens=settings.narrative_max_tokens,
|
|
temperature=settings.narrative_temperature,
|
|
):
|
|
interject_accumulated.append(chunk)
|
|
await publish(
|
|
chat_id,
|
|
{
|
|
"event": "token",
|
|
"text": chunk,
|
|
"speaker_id": silent_witness_id,
|
|
},
|
|
)
|
|
|
|
# T83.1: register the interjection sub-stream in the same
|
|
# in-flight registry so /turns/cancel collapses it too.
|
|
interject_task = asyncio.create_task(_stream_interjection())
|
|
_in_flight_tasks[chat_id] = interject_task
|
|
try:
|
|
await interject_task
|
|
finally:
|
|
_in_flight_tasks.pop(chat_id, None)
|
|
interject_text = "".join(interject_accumulated)
|
|
|
|
new_interjection_event_id = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": chat_id,
|
|
"speaker_id": silent_witness_id,
|
|
"text": interject_text,
|
|
"truncated": False,
|
|
"user_turn_id": (
|
|
new_user_event_id
|
|
if new_user_event_id is not None
|
|
else original_user_turn_id
|
|
),
|
|
"regenerated_from": original_interjection_event_id,
|
|
"interjection_of": speaker_bot_id,
|
|
},
|
|
)
|
|
|
|
# Supersede the original interjection by the new one.
|
|
conn.execute(
|
|
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
|
(new_interjection_event_id, original_interjection_event_id),
|
|
)
|
|
|
|
# Broadcast a replace event so connected tabs swap the prior
|
|
# interjection node in-place (mirrors T73.1's primary swap).
|
|
interject_html = render_turn_html(
|
|
silent_witness.get("name", "bot"), interject_text, role="bot"
|
|
)
|
|
await publish(
|
|
chat_id,
|
|
{
|
|
"event": "turn_html_replace",
|
|
"data": interject_html,
|
|
"turn_id": new_interjection_event_id,
|
|
"supersedes_id": original_interjection_event_id,
|
|
},
|
|
)
|
|
|
|
# Memory write for the new interjection beat (one event per
|
|
# present witness).
|
|
record_turn_memory_for_present(
|
|
conn,
|
|
chat_id=chat_id,
|
|
host_bot_id=host_bot_id,
|
|
guest_bot_id=guest_bot_id,
|
|
narrative_text=interject_text,
|
|
scene_id=scene["id"] if scene else None,
|
|
chat_clock_at=chat.get("time"),
|
|
)
|
|
|
|
# Re-run the multi-pair state-update with the post-interjection
|
|
# dialogue tail so deltas land on the post-primary baseline.
|
|
recent_post_interject = recent_for_update + [
|
|
{
|
|
"speaker": silent_witness.get("name", "bot"),
|
|
"text": interject_text,
|
|
}
|
|
]
|
|
# T83.2: shared helper handles the directed-pair edge dict.
|
|
prior_edges_post = gather_prior_edges(conn, present_ids)
|
|
|
|
state_updates_post = await compute_state_updates_for_present(
|
|
client,
|
|
classifier_model=settings.classifier_model,
|
|
present_ids=present_ids,
|
|
present_names=present_names,
|
|
personas=personas,
|
|
prior_edges=prior_edges_post,
|
|
recent_dialogue=recent_post_interject,
|
|
timeout_s=settings.classifier_timeout_s,
|
|
)
|
|
for src_id, tgt_id, update in state_updates_post:
|
|
append_and_apply(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": src_id,
|
|
"target_id": tgt_id,
|
|
"chat_id": chat_id,
|
|
"affinity_delta": update.affinity_delta,
|
|
"trust_delta": update.trust_delta,
|
|
"knowledge_facts": update.knowledge_facts,
|
|
"last_interaction_at": last_at,
|
|
"last_interaction_chat_id": chat_id,
|
|
},
|
|
)
|
|
else:
|
|
# Classifier said "no follow-on this time" — supersede the
|
|
# original interjection without a replacement. Point the
|
|
# back-pointer at the new primary so the row is consistently
|
|
# hidden by the standard timeline filter.
|
|
conn.execute(
|
|
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
|
(new_assistant_event_id, original_interjection_event_id),
|
|
)
|
|
|
|
# 9a. Event-lifecycle detection (Phase 3, T61). T83.5 cosmetic
|
|
# ordering: mirrors ``chat.web.turns.post_turn``'s 8a block — runs
|
|
# AFTER the interjection branch (and AFTER the post-interjection
|
|
# state-update + memory passes) so the classifier sees the same
|
|
# narrative-text input post_turn does. Numbering uses ``9a`` to
|
|
# match post_turn's ``8a`` shape (the interjection branch is step 9
|
|
# in regenerate vs step 8 in post_turn; lifecycle is the immediate
|
|
# follow-on in both). Behaviour identical to the prior ``step 10``
|
|
# placement — the block was already structurally last in regenerate
|
|
# because there's no scene-close pass here.
|
|
#
|
|
# Classify whether any active events transitioned in the regenerated
|
|
# narrative and append the corresponding event_started /
|
|
# event_completed / event_cancelled. ``promote_completed_event``
|
|
# runs inline after a completion so promotion artifacts land in the
|
|
# same regenerate path.
|
|
#
|
|
# T83.4 follow-up: when a regenerate replaces a turn that had
|
|
# already produced event transitions, those original transitions
|
|
# are NOT undone here (Phase 4 work). A WARNING log earlier in this
|
|
# function names the affected event_log ids — see the T83.4 block
|
|
# near the function entry.
|
|
new_active_events = list_active_events(conn, chat_id)
|
|
if new_active_events:
|
|
lifecycle_decision = await detect_event_transitions(
|
|
client,
|
|
classifier_model=settings.classifier_model,
|
|
narrative_text=new_text,
|
|
active_events=new_active_events,
|
|
timeout_s=settings.classifier_timeout_s,
|
|
)
|
|
for transition in lifecycle_decision.transitions:
|
|
if transition.new_status == "active":
|
|
append_and_apply(
|
|
conn,
|
|
kind="event_started",
|
|
payload={
|
|
"event_id": transition.event_id,
|
|
"started_at": chat.get("time"),
|
|
},
|
|
)
|
|
elif transition.new_status == "completed":
|
|
append_and_apply(
|
|
conn,
|
|
kind="event_completed",
|
|
payload={
|
|
"event_id": transition.event_id,
|
|
"completed_at": chat.get("time"),
|
|
},
|
|
)
|
|
promote_completed_event(
|
|
conn,
|
|
event_id=transition.event_id,
|
|
chat_id=chat_id,
|
|
chat_clock_at=chat.get("time"),
|
|
)
|
|
elif transition.new_status == "cancelled":
|
|
append_and_apply(
|
|
conn,
|
|
kind="event_cancelled",
|
|
payload={
|
|
"event_id": transition.event_id,
|
|
"completed_at": chat.get("time"),
|
|
},
|
|
)
|
|
|
|
return new_text
|
|
|
|
|
|
__all__ = ["regenerate_assistant_turn"]
|