d833bbc3e7
The recent-dialogue read and the directed-pair edge gather were
duplicated between ``chat.services.regenerate`` and ``chat.web.turns``.
Extracted into ``chat.services.turn_common`` with two helpers:
- ``read_recent_dialogue(conn, chat_id, *, limit, exclude_event_id)``:
oldest-first ``[{speaker, text}]`` over user_turn / user_turn_edit /
assistant_turn rows, with the standard ``superseded_by IS NULL AND
hidden = 0`` filter. ``exclude_event_id`` covers regenerate's need to
drop the original assistant_turn before its supersede UPDATE lands.
- ``gather_prior_edges(conn, present_ids)``: ``{(src, tgt): edge}`` over
every directed pair across ``present_ids``, with the schema default
50/50 baseline for missing rows.
``chat.web.turns._read_recent_dialogue`` becomes a thin delegate so the
chat-detail template and other in-module callers keep their import
shape; ``_gather_state_update_inputs`` now calls into the shared edge
gather. ``regenerate_assistant_turn`` calls both helpers in three call
sites (primary + post-interjection edges, primary + interjection
recent reads), still post-processing speaker ids to display names for
its prompts.
Decision: ``chat.services.scene_summarize._read_recent_dialogue`` is
left in place — it has a ``since_event_id`` clamp (T80.2) and excludes
``user_turn_edit`` deliberately. Folding it into the shared helper
would either silently change its read shape or require a second flag,
both more invasive than the duplication. Documented in the new module
docstring.
Tests: tests/test_turn_common.py covers chronological ordering,
supersede / other-chat / exclude_event_id filtering, and prior-edge
default-fallback. Existing 6 regenerate + 18 turn_flow tests pass
unchanged.
216 lines
7.0 KiB
Python
216 lines
7.0 KiB
Python
"""Shared turn helpers (T83.2).
|
|
|
|
``chat.services.turn_common`` extracts two snippets that were duplicated
|
|
between ``chat.web.turns`` and ``chat.services.regenerate``: the recent
|
|
user-side / assistant_turn read, and the directed-pair edge gather for
|
|
the multi-pair state-update pass. These tests pin the helpers' behavior
|
|
independently of either call site.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from chat.db.connection import open_db
|
|
from chat.db.migrate import apply_migrations
|
|
from chat.eventlog.log import append_event
|
|
from chat.eventlog.projector import project
|
|
from chat.services.turn_common import gather_prior_edges, read_recent_dialogue
|
|
|
|
|
|
def _seed_basic_chat(db_path):
|
|
"""Seed bot + chat + a couple of edges + one round of user/assistant
|
|
turns. Returns ``(user_turn_id, assistant_turn_id)``.
|
|
"""
|
|
apply_migrations(db_path)
|
|
with open_db(db_path) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="bot_authored",
|
|
payload={
|
|
"id": "bot_a",
|
|
"name": "BotA",
|
|
"persona": "thoughtful",
|
|
"voice_samples": [],
|
|
"traits": [],
|
|
"backstory": "",
|
|
"initial_relationship_to_you": "",
|
|
"kickoff_prose": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="chat_created",
|
|
payload={
|
|
"id": "chat_a",
|
|
"host_bot_id": "bot_a",
|
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
|
"narrative_anchor": "Day 1",
|
|
"weather": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": "bot_a",
|
|
"target_id": "you",
|
|
"chat_id": "chat_a",
|
|
"affinity_delta": 7,
|
|
"trust_delta": 3,
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": "you",
|
|
"target_id": "bot_a",
|
|
"chat_id": "chat_a",
|
|
"affinity_delta": 2,
|
|
"trust_delta": 1,
|
|
},
|
|
)
|
|
ut_id = append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "chat_a",
|
|
"prose": "hello",
|
|
"segments": [],
|
|
},
|
|
)
|
|
at_id = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": "chat_a",
|
|
"speaker_id": "bot_a",
|
|
"text": "Original.",
|
|
"truncated": False,
|
|
"user_turn_id": ut_id,
|
|
},
|
|
)
|
|
project(conn)
|
|
return ut_id, at_id
|
|
|
|
|
|
def test_read_recent_dialogue_returns_chronological_pairs(tmp_path):
|
|
"""``read_recent_dialogue`` returns oldest-first ``{speaker, text}``
|
|
entries scoped to the requested chat. Speaker is "you" for user-side
|
|
rows and the assistant_turn's ``speaker_id`` for bot rows.
|
|
"""
|
|
db = tmp_path / "test.db"
|
|
_seed_basic_chat(db)
|
|
|
|
with open_db(db) as conn:
|
|
out = read_recent_dialogue(conn, "chat_a", limit=10)
|
|
|
|
assert out == [
|
|
{"speaker": "you", "text": "hello"},
|
|
{"speaker": "bot_a", "text": "Original."},
|
|
]
|
|
|
|
|
|
def test_read_recent_dialogue_filters_superseded_and_other_chats(tmp_path):
|
|
"""Superseded rows drop out (regenerate-aware). Rows scoped to a
|
|
different chat are also filtered. ``exclude_event_id`` excludes a
|
|
specific row even when it isn't superseded yet (regenerate uses this
|
|
to drop the original assistant_turn before the supersede UPDATE
|
|
lands).
|
|
"""
|
|
db = tmp_path / "test.db"
|
|
ut_id, at_id = _seed_basic_chat(db)
|
|
|
|
with open_db(db) as conn:
|
|
# Append a second user/assistant pair.
|
|
ut_id2 = append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "chat_a",
|
|
"prose": "how are you",
|
|
"segments": [],
|
|
},
|
|
)
|
|
at_id2 = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": "chat_a",
|
|
"speaker_id": "bot_a",
|
|
"text": "Second.",
|
|
"truncated": False,
|
|
"user_turn_id": ut_id2,
|
|
},
|
|
)
|
|
# And a row scoped to a different chat — must NOT appear.
|
|
append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "other_chat",
|
|
"prose": "should be filtered",
|
|
"segments": [],
|
|
},
|
|
)
|
|
# Mark the first assistant_turn as superseded — must drop out.
|
|
conn.execute(
|
|
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
|
(at_id2, at_id),
|
|
)
|
|
|
|
out = read_recent_dialogue(conn, "chat_a", limit=10)
|
|
# First (superseded) assistant turn dropped; "other_chat" rows
|
|
# filtered; first user_turn still present.
|
|
speakers = [(e["speaker"], e["text"]) for e in out]
|
|
assert speakers == [
|
|
("you", "hello"),
|
|
("you", "how are you"),
|
|
("bot_a", "Second."),
|
|
]
|
|
|
|
# exclude_event_id drops at_id2 even though it's not superseded.
|
|
out2 = read_recent_dialogue(
|
|
conn, "chat_a", limit=10, exclude_event_id=at_id2
|
|
)
|
|
speakers2 = [(e["speaker"], e["text"]) for e in out2]
|
|
assert ("bot_a", "Second.") not in speakers2
|
|
assert ("you", "how are you") in speakers2
|
|
|
|
# Ensure ut_id is still part of the dataset (sanity for the seed).
|
|
assert ut_id is not None
|
|
|
|
|
|
def test_gather_prior_edges_fills_missing_with_default(tmp_path):
|
|
"""``gather_prior_edges`` returns one entry per directed pair across
|
|
``present_ids``. Missing rows fall back to the schema default
|
|
50/50 baseline; existing rows carry their stored values.
|
|
"""
|
|
db = tmp_path / "test.db"
|
|
_seed_basic_chat(db)
|
|
|
|
with open_db(db) as conn:
|
|
out = gather_prior_edges(conn, ["bot_a", "you"])
|
|
|
|
# 2 entities -> 2 directed pairs (a->b and b->a, no self-pairs).
|
|
assert set(out.keys()) == {("bot_a", "you"), ("you", "bot_a")}
|
|
bot_to_you = out[("bot_a", "you")]
|
|
you_to_bot = out[("you", "bot_a")]
|
|
# Both edges seeded with deltas — they must reflect the projected
|
|
# affinity/trust (not the default 50/50).
|
|
assert bot_to_you["affinity"] == 57 # 50 + 7
|
|
assert bot_to_you["trust"] == 53 # 50 + 3
|
|
assert you_to_bot["affinity"] == 52
|
|
assert you_to_bot["trust"] == 51
|
|
|
|
# A pair with no row yet falls back to 50/50.
|
|
with open_db(db) as conn:
|
|
out_with_missing = gather_prior_edges(
|
|
conn, ["bot_a", "you", "ghost_bot"]
|
|
)
|
|
# 3 entities -> 6 directed pairs.
|
|
assert len(out_with_missing) == 6
|
|
fallback = out_with_missing[("bot_a", "ghost_bot")]
|
|
assert fallback["affinity"] == 50
|
|
assert fallback["trust"] == 50
|
|
assert fallback["summary"] == ""
|