21 Commits

Author SHA1 Message Date
Joseph Doherty 60ac33a787 merge: T44 multi-entity turn flow with interjection support 2026-04-26 16:22:11 -04:00
Joseph Doherty c86b0df411 feat: T44 multi-entity turn flow with interjection support
Rewrites post_turn for the multi-entity world:

- Addressee detection via case-insensitive whole-word match against the
  guest name; defaults to host on no-match or both-match.
- Multi-entity prompt assembly: forwards guest_id so the prompt sees
  the third party's activity / edges / group-node.
- Multi-witness memory write: record_turn_memory_for_present writes one
  memory per present bot witness when a guest is in the room.
- Multi-pair state-update: compute_state_updates_for_present emits one
  edge_update per directed pair (6 with a guest, 2 without).
- Interjection branch (T39): when a guest is present and the primary
  beat completes, the silent witness may follow on. detect_interjection
  decides; on True we stream a second narrative as the witness, append a
  second assistant_turn linked to the same user_turn_id, and re-run the
  multi-pair state update + memory write for the follow-on beat. Cancel
  collapses both halves; a cancelled interjection skips its downstream
  passes so we don't classifier-spam against a half-formed beat.
- Scene-close runs after both beats so apply_scene_close_summary sees
  the full closing scene; T45's guest-aware summarizer handles per-POV
  rewrites for each present witness.

regenerate.py mirrors the prompt / memory / state-update changes for
1:1 and multi-entity scenes. Per the Phase 2 spec, interjection
regeneration is deferred to Phase 2.5 — regenerate only re-streams the
addressee turn for v2.

Tests: adds 5 cases to tests/test_turn_flow.py covering the no-guest
regression, multi-bot without interjection, multi-bot with interjection,
scene-close per-POV rewrites, and addressee routing on a named-bot
prose. Each test pins its own canned MockLLMClient queue with the call
shape documented in the docstring.
2026-04-26 16:18:38 -04:00
Joseph Doherty 44c8735b27 merge: T45 per-POV summaries on close for each present witness 2026-04-26 16:08:54 -04:00
Joseph Doherty 9b601650fb merge: T43 multi-entity prompt assembly 2026-04-26 16:08:54 -04:00
Joseph Doherty fcb111310a feat: multi-entity prompt assembly with guest activity, edges, group node 2026-04-26 16:07:15 -04:00
Joseph Doherty 4e240347b4 feat: per-POV summaries on close for each present witness 2026-04-26 16:06:05 -04:00
Joseph Doherty a90647dddb merge: T42 drawer guest add/remove + render 2026-04-26 16:01:17 -04:00
Joseph Doherty bb83d97088 feat: drawer guest add/remove + render 2026-04-26 15:59:48 -04:00
Joseph Doherty f24ffb8e4f merge: T41 multi-witness memory write helper 2026-04-26 15:54:25 -04:00
Joseph Doherty 9d80b9ae2b merge: T40 multi-entity state-update coordinator 2026-04-26 15:54:25 -04:00
Joseph Doherty 77b42f1ea5 merge: T39 interjection classifier service 2026-04-26 15:54:25 -04:00
Joseph Doherty e7793f2441 feat: multi-witness memory write helper 2026-04-26 15:52:48 -04:00
Joseph Doherty 4ec56dd475 feat: multi-entity state-update coordinator 2026-04-26 15:51:58 -04:00
Joseph Doherty 6a92253ae7 feat: interjection classifier service 2026-04-26 15:51:29 -04:00
Joseph Doherty 22db9f3554 test: bump schema_version assertion to 8 after 0008_group_node migration 2026-04-26 15:49:25 -04:00
Joseph Doherty 6b726b2a4a merge: T38 relationship-seed service 2026-04-26 15:49:03 -04:00
Joseph Doherty e58cdbd527 merge: T37 guest_added/guest_removed event handlers 2026-04-26 15:49:03 -04:00
Joseph Doherty b69a74e69b merge: T36 group_node schema + projector handlers 2026-04-26 15:49:03 -04:00
Joseph Doherty c6b3531c64 feat: relationship-seed service for first-co-appearance prompt 2026-04-26 15:47:12 -04:00
Joseph Doherty a0d7debce5 feat: group_node schema + projector handlers 2026-04-26 15:46:16 -04:00
Joseph Doherty a1b4e251c5 feat: guest_added / guest_removed event handlers 2026-04-26 15:46:09 -04:00
24 changed files with 3704 additions and 285 deletions
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE group_node (
chat_id TEXT PRIMARY KEY,
members_json TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
dynamic TEXT NOT NULL DEFAULT '',
threads_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+100
View File
@@ -0,0 +1,100 @@
"""Interjection classifier service (T39).
Per Requirements §6.2, when a guest is present and the addressee bot has
just spoken, the *non-addressee* bot may follow on with a brief
interjection beat. This service decides whether that interjection
fires. Conservative bias: most turns return ``should_interject=False``
— the addressee has the floor and an interjection is the exception.
Trigger ``True`` only when the silent witness's character, given their
persona and edges, would plausibly speak up: jealousy, surprise, strong
agreement worth voicing, correcting a factual falsehood, urgency.
T44 (turn flow) calls this and, on ``True``, generates the brief
follow-on response as the silent witness. Classifier failure falls back
to ``should_interject=False`` with ``reason="fallback"`` so the chat
keeps moving (§3.3 graceful-degradation rule); callers that care can
distinguish a real "no" from a degraded "no" by the reason string.
"""
from __future__ import annotations
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class InterjectionDecision(BaseModel):
"""Whether the silent witness interjects, plus a short reason.
Defaults are a deliberate no-op: ``should_interject=False`` with an
empty reason. The classifier-failure fallback uses
``reason="fallback"`` so it's distinguishable from a real "no".
"""
should_interject: bool = False
reason: str = ""
_SYSTEM = (
"You decide whether a silent witness character interjects after the "
"addressee character finishes speaking. STRONGLY default to false — "
"the addressee has the floor and most turns should NOT have an "
"interjection. Only return true when the silent witness's character, "
"given their persona and edges, would plausibly speak up: jealousy, "
"surprise, strong agreement worth voicing, correcting a factual "
"falsehood, urgency. Output strict JSON matching the schema."
)
async def detect_interjection(
client: LLMClient,
*,
classifier_model: str,
addressee_name: str,
addressee_just_said: str,
silent_witness_name: str,
silent_witness_persona: str,
silent_witness_edge_to_addressee: dict, # {affinity, trust, summary}
silent_witness_edge_to_you: dict,
you_just_said: str,
timeout_s: float = 30.0,
) -> InterjectionDecision:
"""Decide whether the silent witness bot interjects after the addressee
finishes speaking.
The two ``silent_witness_edge_*`` dicts carry the silent witness's
directed edges toward the addressee and toward the user ("you"),
each shaped ``{affinity: int, trust: int, summary: str}``. Missing
keys fall back to a 50/50 baseline with an empty summary so this
function tolerates partially-populated edge state without raising.
"""
user = (
f"You said: {you_just_said}\n\n"
f"{addressee_name} just said: {addressee_just_said}\n\n"
f"Silent witness: {silent_witness_name}\n"
f"Persona: {silent_witness_persona}\n"
f"Edge {silent_witness_name} -> {addressee_name}: "
f"affinity={silent_witness_edge_to_addressee.get('affinity', 50)}, "
f"trust={silent_witness_edge_to_addressee.get('trust', 50)}, "
f"summary={silent_witness_edge_to_addressee.get('summary', '')}\n"
f"Edge {silent_witness_name} -> you: "
f"affinity={silent_witness_edge_to_you.get('affinity', 50)}, "
f"trust={silent_witness_edge_to_you.get('trust', 50)}, "
f"summary={silent_witness_edge_to_you.get('summary', '')}\n\n"
f"Should {silent_witness_name} interject?"
)
return await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=InterjectionDecision,
default=InterjectionDecision(
should_interject=False, reason="fallback"
),
timeout_s=timeout_s,
)
__all__ = ["InterjectionDecision", "detect_interjection"]
+100
View File
@@ -76,3 +76,103 @@ def record_turn_memory(
).fetchone() ).fetchone()
memory_id = row[0] if row else None memory_id = row[0] if row else None
return event_id, memory_id return event_id, memory_id
def _write_one_memory(
conn: Connection,
*,
owner_id: str,
chat_id: str,
narrative_text: str,
witness_you: int,
witness_host: int,
witness_guest: int,
scene_id: int | None,
chat_clock_at: str | None,
source: str,
significance: int,
) -> tuple[int, int | None]:
"""Append a single ``memory_written`` event for ``owner_id`` and return
``(event_id, memory_id)`` for the projected row."""
payload: dict = {
"owner_id": owner_id,
"chat_id": chat_id,
"pov_summary": narrative_text,
"witness_you": witness_you,
"witness_host": witness_host,
"witness_guest": witness_guest,
"source": source,
"reliability": 1.0,
"significance": significance,
"pinned": 0,
"auto_pinned": 0,
}
if scene_id is not None:
payload["scene_id"] = scene_id
if chat_clock_at is not None:
payload["chat_clock_at"] = chat_clock_at
event_id = append_and_apply(conn, kind="memory_written", payload=payload)
row = conn.execute(
"SELECT id FROM memories "
"WHERE owner_id = ? AND chat_id = ? "
"ORDER BY id DESC LIMIT 1",
(owner_id, chat_id),
).fetchone()
memory_id = row[0] if row else None
return event_id, memory_id
def record_turn_memory_for_present(
conn: Connection,
*,
chat_id: str,
host_bot_id: str,
guest_bot_id: str | None,
narrative_text: str,
scene_id: int | None = None,
chat_clock_at: str | None = None,
source: str = "direct",
significance: int = 1,
) -> dict[str, tuple[int, int | None]]:
"""Write a ``memory_written`` event for each present bot witness.
Host is always written. Guest is written iff ``guest_bot_id is not
None``. Witness flags are ``[you=1, host=1, guest=1]`` when a guest
is present, ``[you=1, host=1, guest=0]`` otherwise.
Returns a mapping ``{bot_id: (event_id, memory_id)}`` so callers can
look up the freshly-projected memory id per owner without re-querying
the database.
"""
witness_guest = 1 if guest_bot_id is not None else 0
result: dict[str, tuple[int, int | None]] = {}
result[host_bot_id] = _write_one_memory(
conn,
owner_id=host_bot_id,
chat_id=chat_id,
narrative_text=narrative_text,
witness_you=1,
witness_host=1,
witness_guest=witness_guest,
scene_id=scene_id,
chat_clock_at=chat_clock_at,
source=source,
significance=significance,
)
if guest_bot_id is not None:
result[guest_bot_id] = _write_one_memory(
conn,
owner_id=guest_bot_id,
chat_id=chat_id,
narrative_text=narrative_text,
witness_you=1,
witness_host=1,
witness_guest=1,
scene_id=scene_id,
chat_clock_at=chat_clock_at,
source=source,
significance=significance,
)
return result
+62
View File
@@ -0,0 +1,62 @@
"""Multi-entity state-update coordinator (T40).
Wraps single-pair compute_state_update to run state updates for ALL
directed pairs of present entities. With 3 present entities (you, host,
guest) that's 6 directed pairs. With 2 present (you, host) it's 2 pairs.
Calls run sequentially to respect Featherless's 2-connection cap (the
client-level semaphore would serialize them anyway, but doing it here
keeps the failure surface clean — a hung pair doesn't queue behind
itself).
"""
from __future__ import annotations
from chat.llm.client import LLMClient
from chat.services.state_update import StateUpdate, compute_state_update
async def compute_state_updates_for_present(
client: LLMClient,
*,
classifier_model: str,
present_ids: list[str],
present_names: dict[str, str],
personas: dict[str, str],
prior_edges: dict[tuple[str, str], dict],
recent_dialogue: list[dict],
timeout_s: float = 30.0,
) -> list[tuple[str, str, StateUpdate]]:
"""Run compute_state_update for every directed pair (src != tgt) over
``present_ids``. Returns list of ``(source_id, target_id, update)``
tuples in the natural iteration order over ``present_ids x present_ids``.
A single failing pair falls back to the schema-default StateUpdate
(zero deltas, empty facts) inside ``compute_state_update``; the batch
keeps going.
"""
out: list[tuple[str, str, StateUpdate]] = []
for src in present_ids:
for tgt in present_ids:
if src == tgt:
continue
edge = prior_edges.get((src, tgt), {})
update = await compute_state_update(
client,
model=classifier_model,
source_id=src,
target_id=tgt,
source_name=present_names.get(src, src),
source_persona=personas.get(src, "") or "",
target_name=present_names.get(tgt, tgt),
prior_affinity=int(edge.get("affinity", 50)),
prior_trust=int(edge.get("trust", 50)),
prior_summary=edge.get("summary", "") or "",
recent_dialogue=recent_dialogue,
timeout_s=timeout_s,
)
out.append((src, tgt, update))
return out
__all__ = ["compute_state_updates_for_present"]
+124 -34
View File
@@ -37,6 +37,7 @@ import tiktoken
from chat.llm.client import Message from chat.llm.client import Message
from chat.state.edges import get_edge, list_edges_for from chat.state.edges import get_edge, list_edges_for
from chat.state.entities import get_bot, get_you from chat.state.entities import get_bot, get_you
from chat.state.group_node import get_group_node
from chat.state.memory import search_memories from chat.state.memory import search_memories
from chat.state.world import ( from chat.state.world import (
active_scene, active_scene,
@@ -206,6 +207,26 @@ def _build_previous_scene_block(pov_summary: str | None) -> str | None:
return "PREVIOUS SCENE SUMMARY:\n" + pov_summary return "PREVIOUS SCENE SUMMARY:\n" + pov_summary
def _build_group_node_block(group_node: dict | None) -> str | None:
"""Render the group-node summary + dynamic as a SHOULD-tier block.
Used only in 3-entity scenes (you + host + guest). Returns None when
the row is missing or both summary and dynamic are empty.
"""
if not group_node:
return None
summary = (group_node.get("summary") or "").strip()
dynamic = (group_node.get("dynamic") or "").strip()
if not summary and not dynamic:
return None
lines = ["Group dynamic:"]
if summary:
lines.append(f"- Summary: {summary}")
if dynamic:
lines.append(f"- Dynamic: {dynamic}")
return "\n".join(lines)
def _closing_instruction(speaker_name: str, addressee_name: str) -> str: def _closing_instruction(speaker_name: str, addressee_name: str) -> str:
return ( return (
f"Continue the scene as {speaker_name}, in their voice, responding " f"Continue the scene as {speaker_name}, in their voice, responding "
@@ -287,6 +308,7 @@ def assemble_narrative_prompt(
budget_soft: int = 6000, budget_soft: int = 6000,
budget_hard: int = 8000, budget_hard: int = 8000,
encoding_name: str = "cl100k_base", encoding_name: str = "cl100k_base",
guest_id: str | None = None,
) -> list[Message]: ) -> list[Message]:
"""Assemble the narrative prompt for ``speaker_bot_id`` to respond. """Assemble the narrative prompt for ``speaker_bot_id`` to respond.
@@ -313,6 +335,15 @@ def assemble_narrative_prompt(
if chat is None: if chat is None:
raise ValueError(f"chat_id {chat_id!r} not found") raise ValueError(f"chat_id {chat_id!r} not found")
# Auto-detect guest from chat state when caller didn't pass one.
# Phase 1 chats have ``guest_bot_id is None``; the auto-detect is a
# no-op there and the function behaves exactly as before.
if guest_id is None:
guest_id = chat.get("guest_bot_id")
# A speaker addressing themself as guest doesn't add a third party.
if guest_id is not None and guest_id == speaker_bot_id:
guest_id = None
you = get_you(conn) you = get_you(conn)
addressee_id, addressee_name = _resolve_addressee(conn, addressee, you) addressee_id, addressee_name = _resolve_addressee(conn, addressee, you)
@@ -325,9 +356,10 @@ def assemble_narrative_prompt(
addressee_name, addressee_name,
) )
# Activity for present entities. Phase 1: you + speaker bot. (When a # Activity for present entities. Core (MUST): you + speaker bot.
# guest is added in Phase 1+, callers that know about it can pass # Phase 2 (SHOULD-tier): when a third party (guest) is present in
# extra activities via a future hook; for now we keep it strict.) # the chat, append their activity in a separate block so it can be
# trimmed independently under tight budget.
activities: list[dict] = [] activities: list[dict] = []
you_act = get_activity(conn, "you") you_act = get_activity(conn, "you")
if you_act is not None: if you_act is not None:
@@ -341,6 +373,34 @@ def assemble_narrative_prompt(
activities.append(bot_act) activities.append(bot_act)
activity_block = _build_activity_block(activities) activity_block = _build_activity_block(activities)
# SHOULD-tier guest activity extension (Phase 2 / Task 43).
guest_activity_block: str | None = None
if guest_id is not None:
guest_act = get_activity(conn, guest_id)
if guest_act is not None:
guest_act = dict(guest_act)
guest_bot = get_bot(conn, guest_id)
guest_act["_display_name"] = (
guest_bot["name"] if guest_bot else guest_id
)
guest_activity_block = _build_activity_block([guest_act])
# SHOULD-tier group-node block (Phase 2 / Task 43): rendered only
# when the group_node row is present AND it covers all three of
# you + host + guest (per the Task 43 spec).
group_node_block: str | None = None
if guest_id is not None:
gn = get_group_node(conn, chat_id)
if gn is not None:
members = set(gn.get("members") or [])
host_id = chat.get("host_bot_id")
required = {"you"}
if host_id is not None:
required.add(host_id)
required.add(guest_id)
if required.issubset(members):
group_node_block = _build_group_node_block(gn)
container = None container = None
if chat.get("active_scene_id"): if chat.get("active_scene_id"):
scene = get_scene(conn, chat["active_scene_id"]) scene = get_scene(conn, chat["active_scene_id"])
@@ -421,6 +481,8 @@ def assemble_narrative_prompt(
include_previous_scene: bool, include_previous_scene: bool,
include_memories_top_k: int, include_memories_top_k: int,
dialogue_keep: int, dialogue_keep: int,
include_guest_activity: bool = True,
include_group_node: bool = True,
) -> tuple[str, int, list[dict]]: ) -> tuple[str, int, list[dict]]:
# dialogue: keep the last `dialogue_keep` turns verbatim; older # dialogue: keep the last `dialogue_keep` turns verbatim; older
# turns become an "earlier:" placeholder line. # turns become an "earlier:" placeholder line.
@@ -447,6 +509,8 @@ def assemble_narrative_prompt(
other_edges_block if include_other_edges else None, other_edges_block if include_other_edges else None,
scene_block, scene_block,
activity_block, activity_block,
guest_activity_block if include_guest_activity else None,
group_node_block if include_group_node else None,
prev_block, prev_block,
memories_block, memories_block,
dialogue_block, dialogue_block,
@@ -463,12 +527,25 @@ def assemble_narrative_prompt(
nice_memories_k = min(4, len(memory_summaries)) nice_memories_k = min(4, len(memory_summaries))
include_prev = previous_scene_summary is not None include_prev = previous_scene_summary is not None
include_other = other_edges_block is not None include_other = other_edges_block is not None
include_guest_activity = guest_activity_block is not None
include_group_node = group_node_block is not None
def _build(*, prev: bool, mem_k: int, dlg: int, other: bool,
guest_act: bool, group: bool) -> tuple[str, int]:
body, total, _ = assemble( body, total, _ = assemble(
include_other_edges=include_other, include_other_edges=other,
include_previous_scene=include_prev, include_previous_scene=prev,
include_memories_top_k=nice_memories_k, include_memories_top_k=mem_k,
dialogue_keep=nice_dialogue_keep, dialogue_keep=dlg,
include_guest_activity=guest_act,
include_group_node=group,
)
return body, total
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, guest_act=include_guest_activity,
group=include_group_node,
) )
# If under soft, we're done. # If under soft, we're done.
@@ -478,34 +555,31 @@ def assemble_narrative_prompt(
# Drop NICE in order: previous scene → memories beyond top-2 → # Drop NICE in order: previous scene → memories beyond top-2 →
# older dialogue turns (collapse to 4). # older dialogue turns (collapse to 4).
if include_prev: if include_prev:
body, total, _ = assemble(
include_other_edges=include_other,
include_previous_scene=False,
include_memories_top_k=nice_memories_k,
dialogue_keep=nice_dialogue_keep,
)
include_prev = False include_prev = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, guest_act=include_guest_activity,
group=include_group_node,
)
if total <= budget_soft: if total <= budget_soft:
return _emit(body, user_turn_prose) return _emit(body, user_turn_prose)
if nice_memories_k > 2: if nice_memories_k > 2:
nice_memories_k = 2 nice_memories_k = 2
body, total, _ = assemble( body, total = _build(
include_other_edges=include_other, prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
include_previous_scene=False, other=include_other, guest_act=include_guest_activity,
include_memories_top_k=nice_memories_k, group=include_group_node,
dialogue_keep=nice_dialogue_keep,
) )
if total <= budget_soft: if total <= budget_soft:
return _emit(body, user_turn_prose) return _emit(body, user_turn_prose)
if nice_dialogue_keep > baseline_keep: if nice_dialogue_keep > baseline_keep:
nice_dialogue_keep = baseline_keep nice_dialogue_keep = baseline_keep
body, total, _ = assemble( body, total = _build(
include_other_edges=include_other, prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
include_previous_scene=False, other=include_other, guest_act=include_guest_activity,
include_memories_top_k=nice_memories_k, group=include_group_node,
dialogue_keep=nice_dialogue_keep,
) )
if total <= budget_soft: if total <= budget_soft:
return _emit(body, user_turn_prose) return _emit(body, user_turn_prose)
@@ -513,21 +587,37 @@ def assemble_narrative_prompt(
# Drop more NICE until we're under hard: memories all the way to 0. # Drop more NICE until we're under hard: memories all the way to 0.
while nice_memories_k > 0 and total > budget_hard: while nice_memories_k > 0 and total > budget_hard:
nice_memories_k = max(0, nice_memories_k - 1) nice_memories_k = max(0, nice_memories_k - 1)
body, total, _ = assemble( body, total = _build(
include_other_edges=include_other, prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
include_previous_scene=False, other=include_other, guest_act=include_guest_activity,
include_memories_top_k=nice_memories_k, group=include_group_node,
dialogue_keep=nice_dialogue_keep, )
# Drop SHOULD-tier blocks in order: guest activity → group node →
# other edges. (Guest activity goes first per Task 43 spec — it's
# the most expendable additive context.)
if include_guest_activity and total > budget_hard:
include_guest_activity = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, guest_act=include_guest_activity,
group=include_group_node,
)
if include_group_node and total > budget_hard:
include_group_node = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, guest_act=include_guest_activity,
group=include_group_node,
) )
# Drop SHOULD: other edges.
if include_other and total > budget_hard: if include_other and total > budget_hard:
include_other = False include_other = False
body, total, _ = assemble( body, total = _build(
include_other_edges=False, prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
include_previous_scene=False, other=include_other, guest_act=include_guest_activity,
include_memories_top_k=nice_memories_k, group=include_group_node,
dialogue_keep=nice_dialogue_keep,
) )
if total > budget_hard: if total > budget_hard:
+97 -59
View File
@@ -26,6 +26,22 @@ 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.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,9 +51,9 @@ from sqlite3 import Connection
from chat.config import Settings from chat.config import Settings
from chat.eventlog.log import append_and_apply, append_event from chat.eventlog.log import append_and_apply, append_event
from chat.services.memory_write import record_turn_memory 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.prompt import assemble_narrative_prompt
from chat.services.state_update import compute_state_update
from chat.state.edges import get_edge 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
@@ -72,6 +88,16 @@ async def regenerate_assistant_turn(
"persona": "", "persona": "",
} }
# Phase 2: surface the guest (if any) so the prompt assembler and
# downstream multi-entity passes see the same shape post_turn does.
guest_bot_id = chat.get("guest_bot_id")
guest_bot: dict | None = None
if guest_bot_id is not None:
guest_bot = get_bot(conn, guest_bot_id)
if guest_bot is None:
# Stale guest reference — degrade to single-bot regenerate.
guest_bot_id = None
# 1. Locate the original assistant_turn event. # 1. Locate the original assistant_turn event.
row = conn.execute( row = conn.execute(
"SELECT payload_json FROM event_log " "SELECT payload_json FROM event_log "
@@ -82,6 +108,17 @@ async def regenerate_assistant_turn(
raise ValueError("assistant_turn event not found") raise ValueError("assistant_turn event not found")
original_assistant_payload = json.loads(row[0]) original_assistant_payload = json.loads(row[0])
original_user_turn_id = original_assistant_payload.get("user_turn_id") original_user_turn_id = original_assistant_payload.get("user_turn_id")
# 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 # 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 # the user_turn_edit event up front so the new event ids exist before
@@ -137,20 +174,26 @@ async def regenerate_assistant_turn(
if kind in ("user_turn", "user_turn_edit"): if kind in ("user_turn", "user_turn_edit"):
recent.append({"speaker": you_name, "text": p.get("prose", "")}) recent.append({"speaker": you_name, "text": p.get("prose", "")})
else: else:
recent.append( spk = p.get("speaker_id", "bot")
{"speaker": host_bot.get("name", "bot"), "text": p.get("text", "")} spk_name = host_bot.get("name", "bot")
) 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")
recent.append({"speaker": spk_name, "text": p.get("text", "")})
# 4. Assemble the narrative prompt. ``recent`` already excludes the # 4. Assemble the narrative prompt. ``recent`` already excludes the
# current user prose, which we pass through ``user_turn_prose``. # 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( messages = assemble_narrative_prompt(
conn, conn,
chat_id=chat_id, chat_id=chat_id,
speaker_bot_id=host_bot_id, speaker_bot_id=speaker_bot_id,
user_turn_prose=prose_for_prompt or None, user_turn_prose=prose_for_prompt or None,
recent_dialogue=recent, recent_dialogue=recent,
budget_soft=settings.narrative_budget_soft, budget_soft=settings.narrative_budget_soft,
budget_hard=settings.narrative_budget_hard, budget_hard=settings.narrative_budget_hard,
guest_id=guest_bot_id,
) )
# 5. Stream the new narrative. # 5. Stream the new narrative.
@@ -164,7 +207,7 @@ async def regenerate_assistant_turn(
accumulated.append(chunk) accumulated.append(chunk)
await publish( await publish(
chat_id, chat_id,
{"event": "token", "text": chunk, "speaker_id": host_bot_id}, {"event": "token", "text": chunk, "speaker_id": speaker_bot_id},
) )
new_text = "".join(accumulated) new_text = "".join(accumulated)
@@ -177,7 +220,7 @@ async def regenerate_assistant_turn(
kind="assistant_turn", kind="assistant_turn",
payload={ payload={
"chat_id": chat_id, "chat_id": chat_id,
"speaker_id": host_bot_id, "speaker_id": speaker_bot_id,
"text": new_text, "text": new_text,
"truncated": False, "truncated": False,
"user_turn_id": ( "user_turn_id": (
@@ -196,84 +239,79 @@ async def regenerate_assistant_turn(
) )
# 8. Re-run downstream classifier passes (memory write + state update # 8. Re-run downstream classifier passes (memory write + state update
# for both directed edges). Significance is intentionally skipped on # for every directed pair across present entities). Significance is
# regenerate (the prior score remains attached to the prior memory). # 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) scene = active_scene(conn, chat_id)
record_turn_memory( record_turn_memory_for_present(
conn, conn,
chat_id=chat_id, chat_id=chat_id,
host_bot_id=host_bot_id, host_bot_id=host_bot_id,
guest_bot_id=guest_bot_id,
narrative_text=new_text, narrative_text=new_text,
scene_id=scene["id"] if scene else None, scene_id=scene["id"] if scene else None,
chat_clock_at=chat.get("time"), chat_clock_at=chat.get("time"),
) )
last_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 + [ recent_for_update = recent + [
{"speaker": host_bot.get("name", "bot"), "text": new_text} {"speaker": speaker_name, "text": new_text}
] ]
edge_b2y = get_edge(conn, host_bot_id, "you") or { # Build present-entity inputs for the multi-pair state-update pass.
"affinity": 50, # Host first preserves the Phase 1 directed-pair order (host->you,
"trust": 50, # then you->host) so existing canned-response fixtures still line up.
"summary": "", present_ids: list[str] = [host_bot_id, "you"]
present_names: dict[str, str] = {
host_bot_id: host_bot.get("name", "bot"),
"you": you_name,
} }
update_b2y = await compute_state_update( personas: dict[str, str] = {
client, host_bot_id: host_bot.get("persona") or "",
model=settings.classifier_model, "you": you_entity.get("persona") or "",
source_id=host_bot_id, }
target_id="you", if guest_bot is not None and guest_bot_id is not None:
source_name=host_bot.get("name", "bot"), present_ids.append(guest_bot_id)
source_persona=host_bot.get("persona", "") or "", present_names[guest_bot_id] = guest_bot.get("name", "bot")
target_name=you_name, personas[guest_bot_id] = guest_bot.get("persona") or ""
prior_affinity=edge_b2y["affinity"],
prior_trust=edge_b2y["trust"],
prior_summary=edge_b2y.get("summary", "") or "",
recent_dialogue=recent_for_update,
)
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": host_bot_id,
"target_id": "you",
"chat_id": chat_id,
"affinity_delta": update_b2y.affinity_delta,
"trust_delta": update_b2y.trust_delta,
"knowledge_facts": update_b2y.knowledge_facts,
"last_interaction_at": last_at,
"last_interaction_chat_id": chat_id,
},
)
edge_y2b = get_edge(conn, "you", host_bot_id) or { prior_edges: dict[tuple[str, str], dict] = {}
for src in present_ids:
for tgt in present_ids:
if src == tgt:
continue
edge = get_edge(conn, src, tgt) or {
"affinity": 50, "affinity": 50,
"trust": 50, "trust": 50,
"summary": "", "summary": "",
} }
update_y2b = await compute_state_update( prior_edges[(src, tgt)] = edge
state_updates = await compute_state_updates_for_present(
client, client,
model=settings.classifier_model, classifier_model=settings.classifier_model,
source_id="you", present_ids=present_ids,
target_id=host_bot_id, present_names=present_names,
source_name=you_name, personas=personas,
source_persona=you_entity.get("persona", "") or "", prior_edges=prior_edges,
target_name=host_bot.get("name", "bot"),
prior_affinity=edge_y2b["affinity"],
prior_trust=edge_y2b["trust"],
prior_summary=edge_y2b.get("summary", "") or "",
recent_dialogue=recent_for_update, recent_dialogue=recent_for_update,
timeout_s=settings.classifier_timeout_s,
) )
for src_id, tgt_id, update in state_updates:
append_and_apply( append_and_apply(
conn, conn,
kind="edge_update", kind="edge_update",
payload={ payload={
"source_id": "you", "source_id": src_id,
"target_id": host_bot_id, "target_id": tgt_id,
"chat_id": chat_id, "chat_id": chat_id,
"affinity_delta": update_y2b.affinity_delta, "affinity_delta": update.affinity_delta,
"trust_delta": update_y2b.trust_delta, "trust_delta": update.trust_delta,
"knowledge_facts": update_y2b.knowledge_facts, "knowledge_facts": update.knowledge_facts,
"last_interaction_at": last_at, "last_interaction_at": last_at,
"last_interaction_chat_id": chat_id, "last_interaction_chat_id": chat_id,
}, },
+107
View File
@@ -0,0 +1,107 @@
"""Parse user-supplied "have they met?" prose into per-direction seed
content for two bots' edges (T38).
Per Requirements §5.2, when two bots first co-appear in a chat, the user
is offered a small drawer asking "Have they met before? If yes, write a
short prose seed describing how." That prose lands here and is parsed
into a :class:`RelationshipSeed` whose two halves populate the
``botA -> botB`` and ``botB -> botA`` edges respectively (summary,
initial knowledge facts, and small affinity/trust deltas around the
default 50/50 baseline).
The two directions can differ — A may know more about B than B knows
about A, or A may trust B less than the reverse — so the schema carries
both halves independently.
Empty/whitespace-only prose short-circuits to a default
``RelationshipSeed`` (all zeroes, empty strings); the caller treats
that as "they haven't met" and writes no edge content. The wrapper uses
:func:`chat.llm.classify.classify` with ``default=RelationshipSeed()``
so a flapping classifier degrades to the same no-op rather than
blocking the chat-creation flow (§3.3 graceful-degradation rule).
T42 (the inter-bot relationship drawer) calls this from the route layer.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class RelationshipSeed(BaseModel):
"""Structured per-direction seed for two bots' edges.
Defaults are a deliberate no-op: empty summaries, empty knowledge
lists, zero deltas. Both the empty-prose short-circuit and the
classifier-failure fallback return this default so the caller can
treat them identically.
"""
a_to_b_summary: str = ""
a_to_b_knowledge_facts: list[str] = Field(default_factory=list)
a_to_b_affinity_delta: int = 0 # signed, -10..+10 typical
a_to_b_trust_delta: int = 0
b_to_a_summary: str = ""
b_to_a_knowledge_facts: list[str] = Field(default_factory=list)
b_to_a_affinity_delta: int = 0
b_to_a_trust_delta: int = 0
_SYSTEM = (
"You parse a short prose seed describing how two characters know each "
"other into structured per-direction edge content. For each direction "
"(A -> B, B -> A) extract: summary (one sentence from that POV), "
"knowledge_facts (list of factual claims that direction can carry "
"into future scenes), affinity_delta (-10..+10 — small adjustments to "
"the default 50/50 baseline), trust_delta (-10..+10). Default deltas "
"to 0 when prose is neutral. The two directions can differ — A may "
"trust B more than B trusts A. Output strict JSON matching the schema."
)
async def seed_inter_bot_edges(
client: LLMClient,
*,
classifier_model: str,
bot_a_id: str,
bot_a_name: str,
bot_b_id: str,
bot_b_name: str,
relationship_prose: str,
timeout_s: float = 30.0,
) -> RelationshipSeed:
"""Parse user-supplied prose into structured edge content for both
directed pairs.
Empty/whitespace prose short-circuits to an empty
:class:`RelationshipSeed` (the caller treats this as "they haven't
met" and writes no edge content). Classifier failure also returns
the default — see module docstring for the rationale.
The ``bot_a_id`` / ``bot_b_id`` arguments are accepted for symmetry
with the caller (T42's drawer route uses them when emitting
``edge_update`` events); they're embedded in the prompt alongside
the names so the classifier can disambiguate when names collide.
"""
if not relationship_prose or not relationship_prose.strip():
return RelationshipSeed()
user = (
f"Bot A: {bot_a_name} (id={bot_a_id})\n"
f"Bot B: {bot_b_name} (id={bot_b_id})\n\n"
f"Prose seed:\n{relationship_prose.strip()}"
)
return await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=RelationshipSeed,
default=RelationshipSeed(),
timeout_s=timeout_s,
)
__all__ = ["RelationshipSeed", "seed_inter_bot_edges"]
+127 -37
View File
@@ -156,64 +156,50 @@ def _read_recent_dialogue(
return out return out
async def apply_scene_close_summary( async def _summarize_and_apply_for_witness(
conn: Connection, conn: Connection,
client: LLMClient, client: LLMClient,
*, *,
classifier_model: str, classifier_model: str,
chat_id: str, chat_id: str,
scene_id: int, scene_id: int,
host_bot_id: str, bot_id: str,
timeout_s: float = 10.0, you_name: str,
dialogue: list[dict],
timeout_s: float,
) -> ScenePOVSummary: ) -> ScenePOVSummary:
"""Drive the per-POV summary pipeline after ``scene_closed``. """Run :func:`summarize_scene` for one bot witness and apply the
three projected updates (memory pov_summary rewrite, edge summary
overwrite, edge knowledge_facts append).
Steps (Phase 1, single-bot): Tolerant of missing pieces in the same way Phase 1 was: no memory
1. Gather the closing scene's dialogue from the event_log. row -> skip the rewrite; no edge row -> skip the edge_summary write
2. Run :func:`summarize_scene` for the host bot. (the empty-default classifier output simply yields no rewrites).
3. Rewrite each scene-bound memory's ``pov_summary`` via
``manual_edit`` (target_kind ``memory_pov_summary``), capturing
the prior value for §6.4 reversibility.
4. Update the bot->you edge summary via ``manual_edit`` with the
new ``edge_summary`` target_kind. v1 combines prior + new by
concatenation — the classifier's ``relationship_summary`` is
already phrased as a continuation.
5. Append any new knowledge_facts to the same edge via
``edge_update``.
Tolerant of missing pieces: no memories -> skip step 3 silently;
no edge row -> skip step 4; empty knowledge_facts -> skip step 5.
The classifier's empty default flows through harmlessly.
""" """
# Local imports to keep the module-level surface tight and avoid
# any chance of a circular dep through chat.state.*.
from chat.state.edges import get_edge from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you from chat.state.entities import get_bot
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id, "persona": ""} bot = get_bot(conn, bot_id) or {"name": bot_id, "persona": ""}
you_entity = get_you(conn) or {"name": "you", "persona": ""}
dialogue = _read_recent_dialogue(conn, chat_id) edge_b2y = get_edge(conn, bot_id, "you")
edge_b2y = get_edge(conn, host_bot_id, "you")
prior_summary = (edge_b2y or {}).get("summary", "") or "" prior_summary = (edge_b2y or {}).get("summary", "") or ""
pov = await summarize_scene( pov = await summarize_scene(
client, client,
model=classifier_model, model=classifier_model,
bot_name=host_bot.get("name", host_bot_id), bot_name=bot.get("name", bot_id),
bot_persona=host_bot.get("persona", "") or "", bot_persona=bot.get("persona", "") or "",
you_name=you_entity.get("name", "you") or "you", you_name=you_name,
prior_edge_summary=prior_summary, prior_edge_summary=prior_summary,
dialogue=dialogue, dialogue=dialogue,
timeout_s=timeout_s, timeout_s=timeout_s,
) )
# Update memories belonging to the closed scene for the host bot. # Update memories belonging to the closed scene for this witness.
cur = conn.execute( cur = conn.execute(
"SELECT id, pov_summary FROM memories " "SELECT id, pov_summary FROM memories "
"WHERE scene_id = ? AND owner_id = ?", "WHERE scene_id = ? AND owner_id = ?",
(scene_id, host_bot_id), (scene_id, bot_id),
) )
for memory_id, prior_pov in cur.fetchall(): for memory_id, prior_pov in cur.fetchall():
if not pov.summary: if not pov.summary:
@@ -231,7 +217,7 @@ async def apply_scene_close_summary(
}, },
) )
# Update the bot->you edge summary if we have an edge row and a # Update this bot->you edge summary if we have an edge row and a
# non-empty relationship_summary to merge. # non-empty relationship_summary to merge.
if edge_b2y is not None and pov.relationship_summary: if edge_b2y is not None and pov.relationship_summary:
new_summary = ( new_summary = (
@@ -245,7 +231,7 @@ async def apply_scene_close_summary(
payload={ payload={
"target_kind": "edge_summary", "target_kind": "edge_summary",
"target_id": { "target_id": {
"source_id": host_bot_id, "source_id": bot_id,
"target_id": "you", "target_id": "you",
}, },
"prior_value": prior_summary, "prior_value": prior_summary,
@@ -253,13 +239,13 @@ async def apply_scene_close_summary(
}, },
) )
# Append knowledge_facts to the bot->you edge if present. # Append knowledge_facts to this bot->you edge if present.
if pov.knowledge_facts: if pov.knowledge_facts:
append_and_apply( append_and_apply(
conn, conn,
kind="edge_update", kind="edge_update",
payload={ payload={
"source_id": host_bot_id, "source_id": bot_id,
"target_id": "you", "target_id": "you",
"chat_id": chat_id, "chat_id": chat_id,
"knowledge_facts": list(pov.knowledge_facts), "knowledge_facts": list(pov.knowledge_facts),
@@ -267,3 +253,107 @@ async def apply_scene_close_summary(
) )
return pov return pov
async def apply_scene_close_summary(
conn: Connection,
client: LLMClient,
*,
classifier_model: str,
chat_id: str,
scene_id: int,
host_bot_id: str,
timeout_s: float = 10.0,
) -> ScenePOVSummary:
"""Drive the per-POV summary pipeline after ``scene_closed``.
Phase 1 (single-bot) behavior — the host bot is summarized once and
the result drives memory + edge rewrites — is preserved exactly when
the chat has no guest. T45 extends this to fan out across each
present bot witness when a guest is also in the room:
1. Gather the closing scene's dialogue from the event_log.
2. For each present witness (host + guest if any), run
:func:`summarize_scene` once with that witness's persona and
their own prior ``bot -> you`` edge summary.
3. For each witness independently:
a. Rewrite each scene-bound memory's ``pov_summary`` via
``manual_edit`` (target_kind ``memory_pov_summary``).
b. Update that witness's ``bot -> you`` edge summary via
``manual_edit`` (target_kind ``edge_summary``). v2 combines
prior + classifier ``relationship_summary`` by simple
concatenation.
c. Append any ``knowledge_facts`` to the same edge via
``edge_update``.
4. If a ``group_node`` row exists for this chat, append a
``group_node_updated`` event whose ``summary`` is the naive
per-POV concat ``f"{name}: {summary}\\n\\n..."``. A true
LLM-merged group view is deferred to Phase 2.5; ``dynamic``
is left empty here for v2 (Phase 3 polishes it).
The host's :class:`ScenePOVSummary` is returned to preserve the
Phase 1 callers' contract.
"""
# Local imports to keep the module-level surface tight and avoid
# any chance of a circular dep through chat.state.*.
from chat.state.entities import get_bot, get_you
from chat.state.group_node import get_group_node
from chat.state.world import get_chat
you_entity = get_you(conn) or {"name": "you", "persona": ""}
you_name = you_entity.get("name", "you") or "you"
chat = get_chat(conn, chat_id) or {}
guest_bot_id = chat.get("guest_bot_id")
dialogue = _read_recent_dialogue(conn, chat_id)
host_pov = await _summarize_and_apply_for_witness(
conn,
client,
classifier_model=classifier_model,
chat_id=chat_id,
scene_id=scene_id,
bot_id=host_bot_id,
you_name=you_name,
dialogue=dialogue,
timeout_s=timeout_s,
)
guest_pov: ScenePOVSummary | None = None
if guest_bot_id is not None:
guest_pov = await _summarize_and_apply_for_witness(
conn,
client,
classifier_model=classifier_model,
chat_id=chat_id,
scene_id=scene_id,
bot_id=guest_bot_id,
you_name=you_name,
dialogue=dialogue,
timeout_s=timeout_s,
)
# Group node update: naive per-POV concat for v2. Only fires when
# both POVs ran (i.e. the guest is present) and a group_node row
# exists for this chat.
if guest_pov is not None and get_group_node(conn, chat_id) is not None:
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id}
guest_bot = get_bot(conn, guest_bot_id) or {"name": guest_bot_id}
host_name = host_bot.get("name", host_bot_id) or host_bot_id
guest_name = guest_bot.get("name", guest_bot_id) or guest_bot_id
group_summary = (
f"{host_name}: {host_pov.summary}\n\n"
f"{guest_name}: {guest_pov.summary}"
)
append_and_apply(
conn,
kind="group_node_updated",
payload={
"chat_id": chat_id,
"summary": group_summary,
"dynamic": "",
},
)
return host_pov
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
@on("group_node_initialized")
def _apply_group_node_initialized(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT OR REPLACE INTO group_node "
"(chat_id, members_json, summary, dynamic, threads_json) "
"VALUES (?, ?, ?, ?, ?)",
(
p["chat_id"],
json.dumps(p["members"]),
p.get("summary", ""),
p.get("dynamic", ""),
json.dumps(p.get("threads", [])),
),
)
@on("group_node_updated")
def _apply_group_node_updated(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE group_node SET summary = ?, dynamic = ?, updated_at = datetime('now') "
"WHERE chat_id = ?",
(p.get("summary", ""), p.get("dynamic", ""), p["chat_id"]),
)
def get_group_node(conn: Connection, chat_id: str) -> dict | None:
row = conn.execute(
"SELECT chat_id, members_json, summary, dynamic, threads_json, updated_at "
"FROM group_node WHERE chat_id = ?",
(chat_id,),
).fetchone()
if not row:
return None
return {
"chat_id": row[0],
"members": json.loads(row[1]),
"summary": row[2],
"dynamic": row[3],
"threads": json.loads(row[4]),
"updated_at": row[5],
}
+18
View File
@@ -29,6 +29,24 @@ def _apply_chat_created(conn: Connection, e: Event) -> None:
) )
@on("guest_added")
def _apply_guest_added(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE chats SET guest_bot_id = ? WHERE id = ?",
(p["guest_bot_id"], p["chat_id"]),
)
@on("guest_removed")
def _apply_guest_removed(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE chats SET guest_bot_id = NULL WHERE id = ?",
(p["chat_id"],),
)
@on("container_created") @on("container_created")
def _apply_container_created(conn: Connection, e: Event) -> None: def _apply_container_created(conn: Connection, e: Event) -> None:
p = e.payload p = e.payload
+95
View File
@@ -43,6 +43,101 @@
{% endfor %} {% endfor %}
</section> </section>
{% if guest_bot %}
<section class="drawer-section">
<h3>Guest</h3>
<p><strong>{{ guest_bot.name }}</strong></p>
{% if guest_activity %}
<p>{{ guest_activity.posture or "—" }} / {{ (guest_activity.action or {}).verb or "—" }}</p>
{% if guest_activity.attention %}<p class="muted">attention: {{ guest_activity.attention }}</p>{% endif %}
{% if guest_activity.holding %}<p class="muted">holding: {{ guest_activity.holding|join(", ") }}</p>{% endif %}
{% else %}
<p class="muted">No activity recorded.</p>
{% endif %}
{% if edge_h2g %}
<div class="edge-row">
<strong>{{ host_bot.name }} &rarr; {{ guest_bot.name }}</strong>
<p>Affinity: {{ edge_h2g.affinity }}/100 &middot; Trust: {{ edge_h2g.trust }}/100</p>
{% if edge_h2g.knowledge %}
<details><summary>Knowledge ({{ edge_h2g.knowledge|length }})</summary>
<ul>{% for fact in edge_h2g.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
</details>
{% endif %}
</div>
{% endif %}
{% if edge_g2h %}
<div class="edge-row">
<strong>{{ guest_bot.name }} &rarr; {{ host_bot.name }}</strong>
<p>Affinity: {{ edge_g2h.affinity }}/100 &middot; Trust: {{ edge_g2h.trust }}/100</p>
{% if edge_g2h.knowledge %}
<details><summary>Knowledge ({{ edge_g2h.knowledge|length }})</summary>
<ul>{% for fact in edge_g2h.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
</details>
{% endif %}
</div>
{% endif %}
{% if edge_y2g %}
<div class="edge-row">
<strong>you &rarr; {{ guest_bot.name }}</strong>
<p>Affinity: {{ edge_y2g.affinity }}/100 &middot; Trust: {{ edge_y2g.trust }}/100</p>
</div>
{% endif %}
{% if edge_g2y %}
<div class="edge-row">
<strong>{{ guest_bot.name }} &rarr; you</strong>
<p>Affinity: {{ edge_g2y.affinity }}/100 &middot; Trust: {{ edge_g2y.trust }}/100</p>
</div>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/guest/remove"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Remove guest</button>
</form>
</section>
{% else %}
<section class="drawer-section">
<h3>Add guest</h3>
{% if available_guests %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/guest/add"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Bot:
<select name="guest_bot_id" required>
{% for b in available_guests %}
<option value="{{ b.id }}">{{ b.name }}</option>
{% endfor %}
</select>
</label>
<label>
Have they met before? Describe how (leave blank if not):
<textarea name="relationship_prose" rows="3"
placeholder="e.g. Old college friends who studied physics together."></textarea>
</label>
<button type="submit">Add guest</button>
</form>
{% else %}
<p class="muted">No other bots authored yet.</p>
{% endif %}
</section>
{% endif %}
{% if group_node %}
<section class="drawer-section">
<h3>Group</h3>
{% if group_node.summary %}
<p>{{ group_node.summary }}</p>
{% else %}
<p class="muted">No group summary yet.</p>
{% endif %}
{% if group_node.dynamic %}
<p class="muted">Dynamic: {{ group_node.dynamic }}</p>
{% endif %}
</section>
{% endif %}
<section class="drawer-section"> <section class="drawer-section">
<h3>Edges</h3> <h3>Edges</h3>
{% if edge_b2y %} {% if edge_b2y %}
+221 -1
View File
@@ -32,9 +32,11 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply from chat.eventlog.log import append_and_apply
from chat.services.relationship_seed import seed_inter_bot_edges
from chat.services.scene_summarize import apply_scene_close_summary from chat.services.scene_summarize import apply_scene_close_summary
from chat.state.edges import get_edge 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, list_bots
from chat.state.group_node import get_group_node
from chat.state.memory import get_pinned from chat.state.memory import get_pinned
from chat.state.world import active_scene, get_activity, get_chat, get_container from chat.state.world import active_scene, get_activity, get_chat, get_container
from chat.web.bots import get_conn from chat.web.bots import get_conn
@@ -78,6 +80,32 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
edge_b2y = get_edge(conn, chat["host_bot_id"], "you") edge_b2y = get_edge(conn, chat["host_bot_id"], "you")
edge_y2b = get_edge(conn, "you", chat["host_bot_id"]) edge_y2b = get_edge(conn, "you", chat["host_bot_id"])
# T42: guest + group context. Empty defaults keep the template happy
# when no guest is present (the relevant sections render conditionally).
guest_bot = None
guest_activity = None
edge_h2g = None
edge_g2h = None
edge_y2g = None
edge_g2y = None
available_guests: list[dict] = []
group_node = None
if chat.get("guest_bot_id"):
guest_bot_id = chat["guest_bot_id"]
guest_bot = get_bot(conn, guest_bot_id)
guest_activity = get_activity(conn, guest_bot_id)
edge_h2g = get_edge(conn, chat["host_bot_id"], guest_bot_id)
edge_g2h = get_edge(conn, guest_bot_id, chat["host_bot_id"])
edge_y2g = get_edge(conn, "you", guest_bot_id)
edge_g2y = get_edge(conn, guest_bot_id, "you")
else:
# Candidates for the "Add guest" dropdown — every authored bot
# except the host (and "you", which is implicit, never a bot row).
available_guests = [
b for b in list_bots(conn) if b["id"] != chat["host_bot_id"]
]
group_node = get_group_node(conn, chat_id)
# Recent memories from host's POV (witness_host = 1), most recent first. # Recent memories from host's POV (witness_host = 1), most recent first.
# Raw query keeps this read self-contained — no projector helper exposes # Raw query keeps this read self-contained — no projector helper exposes
# "latest N for an owner" yet and the drawer is the only consumer. # "latest N for an owner" yet and the drawer is the only consumer.
@@ -117,6 +145,14 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
"bot_activity": bot_activity, "bot_activity": bot_activity,
"edge_b2y": edge_b2y, "edge_b2y": edge_b2y,
"edge_y2b": edge_y2b, "edge_y2b": edge_y2b,
"guest_bot": guest_bot,
"guest_activity": guest_activity,
"edge_h2g": edge_h2g,
"edge_g2h": edge_g2h,
"edge_y2g": edge_y2g,
"edge_g2y": edge_g2y,
"available_guests": available_guests,
"group_node": group_node,
"recent_memories": recent_memories, "recent_memories": recent_memories,
"pinned": pinned, "pinned": pinned,
"pin_cap": PIN_CAP, "pin_cap": PIN_CAP,
@@ -304,3 +340,187 @@ async def toggle_memory_pin(
}, },
) )
return await drawer(chat_id, request, conn) return await drawer(chat_id, request, conn)
# --- T42 guest add/remove -------------------------------------------------
#
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
# ``chats.guest_bot_id``, two ``edge_update`` events seeded from the
# user-supplied prose (skipped when the prose is empty / the seed comes back
# default), and a ``group_node_initialized`` if no row exists yet — three
# entities now share the chat so the §8.4 group node becomes meaningful.
#
# Removing a guest first emits ``scene_closed`` for the active scene (so any
# host -> you scene closes cleanly with the guest still in scope) before
# clearing the guest_bot_id; per spec the next user message implicitly opens
# a fresh you+host scene via Phase 1's mid-chat reset behavior.
def _seed_is_default(seed) -> bool:
"""Treat a seed as a no-op when both summaries are empty AND both
delta pairs are zero AND both fact lists are empty.
"""
return (
not seed.a_to_b_summary
and not seed.b_to_a_summary
and seed.a_to_b_affinity_delta == 0
and seed.a_to_b_trust_delta == 0
and seed.b_to_a_affinity_delta == 0
and seed.b_to_a_trust_delta == 0
and not seed.a_to_b_knowledge_facts
and not seed.b_to_a_knowledge_facts
)
@router.post(
"/chats/{chat_id}/drawer/guest/add",
response_class=HTMLResponse,
)
async def add_guest(
chat_id: str,
request: Request,
guest_bot_id: str = Form(...),
relationship_prose: str = Form(""),
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
if chat.get("guest_bot_id") is not None:
raise HTTPException(
status_code=400,
detail="a guest is already present in this chat",
)
if guest_bot_id == chat["host_bot_id"]:
raise HTTPException(
status_code=400, detail="guest must differ from host"
)
guest_bot = get_bot(conn, guest_bot_id)
if guest_bot is None:
raise HTTPException(
status_code=404, detail=f"guest bot not found: {guest_bot_id}"
)
host_bot = get_bot(conn, chat["host_bot_id"])
if host_bot is None:
raise HTTPException(
status_code=404,
detail=f"host bot not found: {chat['host_bot_id']}",
)
settings = request.app.state.settings
seed = await seed_inter_bot_edges(
client,
classifier_model=settings.classifier_model,
bot_a_id=chat["host_bot_id"],
bot_a_name=host_bot["name"],
bot_b_id=guest_bot_id,
bot_b_name=guest_bot["name"],
relationship_prose=relationship_prose,
timeout_s=settings.classifier_timeout_s,
)
append_and_apply(
conn,
kind="guest_added",
payload={"chat_id": chat_id, "guest_bot_id": guest_bot_id},
)
# Emit edge_update only when the seed carries content. Empty prose
# short-circuits inside ``seed_inter_bot_edges`` to a default seed,
# so this skips the two extra log entries on the no-prose path.
# NOTE: ``_apply_edge_update`` does not accept a ``summary`` field —
# per-direction summary is set via the per-pov scene-close path
# (T27), not direct edge_update. We therefore drop seed.*_summary
# here; the deltas + knowledge_facts are what materializes.
if not _seed_is_default(seed):
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": chat["host_bot_id"],
"target_id": guest_bot_id,
"chat_id": chat_id,
"affinity_delta": seed.a_to_b_affinity_delta,
"trust_delta": seed.a_to_b_trust_delta,
"knowledge_facts": seed.a_to_b_knowledge_facts,
"last_interaction_at": chat.get("time"),
"last_interaction_chat_id": chat_id,
},
)
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": guest_bot_id,
"target_id": chat["host_bot_id"],
"chat_id": chat_id,
"affinity_delta": seed.b_to_a_affinity_delta,
"trust_delta": seed.b_to_a_trust_delta,
"knowledge_facts": seed.b_to_a_knowledge_facts,
"last_interaction_at": chat.get("time"),
"last_interaction_chat_id": chat_id,
},
)
# Three entities now share the chat (you, host, guest) — initialize
# the group node row if Wave 1's reader doesn't see one yet.
if get_group_node(conn, chat_id) is None:
append_and_apply(
conn,
kind="group_node_initialized",
payload={
"chat_id": chat_id,
"members": ["you", chat["host_bot_id"], guest_bot_id],
"summary": "",
"dynamic": "",
"threads": [],
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/guest/remove",
response_class=HTMLResponse,
)
async def remove_guest(
chat_id: str,
request: Request,
conn=Depends(get_conn),
):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
if chat.get("guest_bot_id") is None:
raise HTTPException(
status_code=400, detail="no guest present in this chat"
)
# Close the active scene (if any) before flipping guest_bot_id so
# the scene record carries the guest as a participant.
scene = active_scene(conn, chat_id)
if scene is not None:
append_and_apply(
conn,
kind="scene_closed",
payload={
"scene_id": scene["id"],
"ended_at": chat.get("time"),
"significance": 0,
},
)
append_and_apply(
conn,
kind="guest_removed",
payload={"chat_id": chat_id},
)
return await drawer(chat_id, request, conn)
+410 -129
View File
@@ -1,32 +1,47 @@
"""POST ``/chats/<id>/turns`` — narrative turn flow with SSE streaming. """POST ``/chats/<id>/turns`` — narrative turn flow with SSE streaming.
The turn flow strings together the pieces built in T17 (turn parser), T18 The turn flow strings together the pieces built in T17 (turn parser), T18
(prompt assembler), and T16 (SSE channel): (prompt assembler), and T16 (SSE channel). Phase 2 (T44) extends it to
multi-entity scenes with optional guest support and a follow-on
interjection beat.
1. Parse the user's prose with the classifier into typed segments. 1. Parse the user's prose with the classifier into typed segments.
2. Append a ``user_turn`` event capturing both the original prose and the 2. Append a ``user_turn`` event capturing both the original prose and the
parsed segments. parsed segments.
3. Append a placeholder ``assistant_turn_started`` marker so observers know 3. Append a placeholder ``assistant_turn_started`` marker so observers know
a response is in flight. a response is in flight.
4. Build the narrative prompt, dropping OOC segments before they reach the 4. Detect the addressee (host vs. guest) from the prose using a simple
bot (per Requirements §6.1 the OOC convention is for the author to talk word-boundary substring match — see :func:`_detect_addressee_id`.
to the system, not to the in-fiction bot). 5. Build the narrative prompt for the addressee, dropping OOC segments
5. Stream tokens from the LLM, broadcasting each chunk over the chat's SSE before they reach the bot (per Requirements §6.1 the OOC convention is
for the author to talk to the system, not to the in-fiction bot).
6. Stream tokens from the LLM, broadcasting each chunk over the chat's SSE
channel as a ``token`` event so any subscribed browser tab sees them channel as a ``token`` event so any subscribed browser tab sees them
arrive in real time. arrive in real time.
6. On stream complete, append an ``assistant_turn`` event with the full 7. On stream complete, append an ``assistant_turn`` event with the full
text and ``truncated=False``. Then run a post-turn state-update pass text and ``truncated=False``. Then run a post-turn state-update pass
(Requirements §3.4): one classifier call per directed edge between (Requirements §3.4): one classifier call per directed edge between
present entities, each producing an ``edge_update`` event with present entities, each producing an ``edge_update`` event with
affinity/trust/knowledge deltas. Finally publish a ``turn_html`` affinity/trust/knowledge deltas.
event with a ready-to-swap HTML fragment so HTMX's SSE extension can 8. When a guest is present, run the interjection classifier (§6.2). If it
append it to the timeline without a page reload. fires we stream a second narrative as the silent witness, append a
7. Return ``204 No Content`` — the SSE channel is the real conveyor of second ``assistant_turn`` event linked to the same ``user_turn_id``,
and re-run memory + state-update for the interjector. The same
in-flight task covers both halves so cancel collapses both.
9. Scene-close detection runs after the (primary + optional interjection)
beats land so the close summary sees the full closing scene. T45's
guest-aware ``apply_scene_close_summary`` writes per-POV summaries for
each present witness.
10. Publish a ``turn_html`` event for each turn so HTMX's SSE extension
can append it to the timeline without a page reload.
11. Return ``204 No Content`` — the SSE channel is the real conveyor of
state, not the POST response body. state, not the POST response body.
Errors during streaming flip the assistant_turn's ``truncated`` flag to Errors during streaming flip the assistant_turn's ``truncated`` flag to
``True`` and we still commit what we received. ``asyncio.CancelledError`` ``True`` and we still commit what we received. ``asyncio.CancelledError``
is treated identically and re-raised after recording the partial turn. is treated identically and re-raised after recording the partial turn.
A cancellation mid-interjection skips the interjector's state/memory
follow-up so we don't run classifiers against a half-formed beat.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,18 +49,20 @@ from __future__ import annotations
import asyncio import asyncio
import html import html
import json import json
import re
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from chat.eventlog.log import append_and_apply, append_event from chat.eventlog.log import append_and_apply, append_event
from chat.services.background import SignificanceJob from chat.services.background import SignificanceJob
from chat.services.memory_write import record_turn_memory 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.prompt import assemble_narrative_prompt
from chat.services.rewind import compute_rewind_preview, execute_rewind from chat.services.rewind import compute_rewind_preview, execute_rewind
from chat.services.scene_close import detect_scene_close from chat.services.scene_close import detect_scene_close
from chat.services.scene_summarize import apply_scene_close_summary from chat.services.scene_summarize import apply_scene_close_summary
from chat.services.state_update import compute_state_update
from chat.services.turn_parse import ParsedTurn, parse_turn from chat.services.turn_parse import ParsedTurn, parse_turn
from chat.state.edges import get_edge 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
@@ -114,6 +131,84 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
return out return out
def _detect_addressee_id(
prose: str, host_bot: dict, guest_bot: dict | None
) -> str:
"""Return the bot id of the addressee for ``prose``.
Phase 2 v1 uses a simple case-insensitive whole-word match. The host
is the default — addressee flips to guest only when the guest's name
appears in the prose AND the host's does not. If both names match
or neither matches, the host keeps the floor. This bias keeps the
primary speaker stable across ambiguous prose; the interjection
branch (later in the turn flow) is how the silent witness gets a word
in edgewise when warranted.
"""
if guest_bot is None:
return host_bot["id"]
host_name = host_bot.get("name") or ""
guest_name = guest_bot.get("name") or ""
host_match = bool(
host_name
and re.search(rf"\b{re.escape(host_name)}\b", prose, re.IGNORECASE)
)
guest_match = bool(
guest_name
and re.search(rf"\b{re.escape(guest_name)}\b", prose, re.IGNORECASE)
)
if guest_match and not host_match:
return guest_bot["id"]
return host_bot["id"]
def _gather_state_update_inputs(
conn,
*,
host_bot: dict,
guest_bot: dict | None,
you_entity: dict,
) -> tuple[list[str], dict[str, str], dict[str, str], dict[tuple[str, str], dict]]:
"""Collect ``(present_ids, present_names, personas, prior_edges)`` for
a multi-entity state-update pass.
Phase 2 v1 always pairs ``you`` with the host and (when present) the
guest. ``prior_edges`` falls back to the schema default 50/50 baseline
when no row exists yet — that mirrors the Phase 1 single-pair flow.
Order matters: the host comes first so the directed-pair iteration
in :func:`compute_state_updates_for_present` matches the Phase 1
sequence (host->you, then you->host). Existing tests pin the canned-
response queue to that order — keeping it stable means we don't
have to reshuffle test fixtures across the Phase 2 cutover.
"""
present_ids: list[str] = [host_bot["id"], "you"]
present_names: dict[str, str] = {
host_bot["id"]: host_bot["name"],
"you": you_entity.get("name") or "you",
}
personas: dict[str, str] = {
host_bot["id"]: host_bot.get("persona") or "",
"you": you_entity.get("persona") or "",
}
if guest_bot is not None:
present_ids.append(guest_bot["id"])
present_names[guest_bot["id"]] = guest_bot["name"]
personas[guest_bot["id"]] = guest_bot.get("persona") or ""
prior_edges: dict[tuple[str, str], dict] = {}
for src in present_ids:
for tgt in present_ids:
if src == tgt:
continue
edge = get_edge(conn, src, tgt) or {
"affinity": 50,
"trust": 50,
"summary": "",
}
prior_edges[(src, tgt)] = edge
return present_ids, present_names, personas, prior_edges
@router.post("/chats/{chat_id}/turns") @router.post("/chats/{chat_id}/turns")
async def post_turn( async def post_turn(
chat_id: str, chat_id: str,
@@ -137,6 +232,15 @@ async def post_turn(
detail=f"host bot not found: {chat['host_bot_id']}", detail=f"host bot not found: {chat['host_bot_id']}",
) )
guest_bot = None
guest_bot_id = chat.get("guest_bot_id")
if guest_bot_id is not None:
guest_bot = get_bot(conn, guest_bot_id)
# If the chat references a deleted guest we degrade to single-bot
# rather than 404 — the chat is still usable as a 1:1.
if guest_bot is None:
guest_bot_id = None
settings = request.app.state.settings settings = request.app.state.settings
# 1. Parse turn (classifier). # 1. Parse turn (classifier).
@@ -156,7 +260,16 @@ async def post_turn(
}, },
) )
# 3. Append assistant_turn_started placeholder. ``user_turn``, # 3. Determine the addressee. Done before assistant_turn_started so the
# placeholder reflects the bot the user is actually talking to (host
# in 1:1, host-or-guest in multi-entity).
addressee_id = _detect_addressee_id(prose, host_bot, guest_bot)
addressee_bot = (
guest_bot if (guest_bot is not None and addressee_id == guest_bot["id"])
else host_bot
)
# 4. Append assistant_turn_started placeholder. ``user_turn``,
# ``assistant_turn_started``, and ``assistant_turn`` have no registered # ``assistant_turn_started``, and ``assistant_turn`` have no registered
# projector handlers — they live in the event_log purely for transcript # projector handlers — they live in the event_log purely for transcript
# rendering — so we don't call ``project`` here. (Re-projecting now would # rendering — so we don't call ``project`` here. (Re-projecting now would
@@ -166,12 +279,15 @@ async def post_turn(
kind="assistant_turn_started", kind="assistant_turn_started",
payload={ payload={
"chat_id": chat_id, "chat_id": chat_id,
"speaker_id": host_bot["id"], "speaker_id": addressee_bot["id"],
"user_turn_id": user_turn_event_id, "user_turn_id": user_turn_event_id,
}, },
) )
# 4. Build the narrative prompt. # 5. Build the narrative prompt for the addressee. ``guest_id`` is
# passed explicitly so the prompt assembler renders the guest's
# activity / group-node block when applicable. The assembler is
# tolerant of ``guest_id is None`` so this is a no-op for 1:1 chats.
recent = _read_recent_dialogue(conn, chat_id, limit=20) recent = _read_recent_dialogue(conn, chat_id, limit=20)
# Drop the just-appended user turn from ``recent`` — it's passed as # Drop the just-appended user turn from ``recent`` — it's passed as
# ``user_turn_prose`` to the assembler and would otherwise duplicate. # ``user_turn_prose`` to the assembler and would otherwise duplicate.
@@ -180,189 +296,327 @@ async def post_turn(
messages = assemble_narrative_prompt( messages = assemble_narrative_prompt(
conn, conn,
chat_id=chat_id, chat_id=chat_id,
speaker_bot_id=host_bot["id"], speaker_bot_id=addressee_bot["id"],
user_turn_prose=prompt_prose if prompt_prose else None, user_turn_prose=prompt_prose if prompt_prose else None,
recent_dialogue=recent, recent_dialogue=recent,
budget_soft=settings.narrative_budget_soft, budget_soft=settings.narrative_budget_soft,
budget_hard=settings.narrative_budget_hard, budget_hard=settings.narrative_budget_hard,
guest_id=guest_bot_id,
) )
# 5. Stream and accumulate tokens. The stream runs as a Task so the # 6. Stream and accumulate tokens. The stream runs as a Task so the
# /turns/cancel route can invoke ``Task.cancel()`` to abort it # /turns/cancel route can invoke ``Task.cancel()`` to abort it
# mid-stream. ``accumulated`` is a closure over the inner coroutine, # mid-stream. ``accumulated`` is a closure over the inner coroutine,
# so when the await on ``stream_task`` raises CancelledError below # so when the await on ``stream_task`` raises CancelledError below
# we still see whatever tokens were appended before cancellation. # we still see whatever tokens were appended before cancellation.
accumulated: list[str] = [] primary_accumulated: list[str] = []
truncated = False primary_truncated = False
cancelled = False cancelled = False
async def _stream() -> None: async def _stream_primary() -> None:
async for chunk in client.stream( async for chunk in client.stream(
messages, messages,
model=settings.narrative_model, model=settings.narrative_model,
max_tokens=settings.narrative_max_tokens, max_tokens=settings.narrative_max_tokens,
temperature=settings.narrative_temperature, temperature=settings.narrative_temperature,
): ):
accumulated.append(chunk) primary_accumulated.append(chunk)
await publish( await publish(
chat_id, chat_id,
{ {
"event": "token", "event": "token",
"text": chunk, "text": chunk,
"speaker_id": host_bot["id"], "speaker_id": addressee_bot["id"],
}, },
) )
stream_task = asyncio.create_task(_stream()) stream_task = asyncio.create_task(_stream_primary())
_in_flight_tasks[chat_id] = stream_task _in_flight_tasks[chat_id] = stream_task
try: try:
await stream_task await stream_task
except asyncio.CancelledError: except asyncio.CancelledError:
# Preserve the partial output before letting the cancellation # Preserve the partial output before letting the cancellation
# propagate so the transcript reflects what the user actually saw. # propagate so the transcript reflects what the user actually saw.
truncated = True primary_truncated = True
cancelled = True cancelled = True
except Exception: except Exception:
# Surface as a truncated turn rather than losing the partial output. # Surface as a truncated turn rather than losing the partial output.
truncated = True primary_truncated = True
finally: finally:
# Always unregister so a subsequent turn can register a fresh task. # Always unregister so a subsequent turn can register a fresh task.
_in_flight_tasks.pop(chat_id, None) _in_flight_tasks.pop(chat_id, None)
full_text = "".join(accumulated) primary_text = "".join(primary_accumulated)
# 6. Append the assistant_turn with the final text. (See note above on # 7. Append the assistant_turn with the final text. (See note above on
# why we skip ``project`` for these transcript-only event kinds.) # why we skip ``project`` for these transcript-only event kinds.)
append_event( append_event(
conn, conn,
kind="assistant_turn", kind="assistant_turn",
payload={ payload={
"chat_id": chat_id, "chat_id": chat_id,
"speaker_id": host_bot["id"], "speaker_id": addressee_bot["id"],
"text": full_text, "text": primary_text,
"truncated": truncated, "truncated": primary_truncated,
"user_turn_id": user_turn_event_id, "user_turn_id": user_turn_event_id,
}, },
) )
# 6a. Per-turn memory write (Plan §11.1, T21). Phase 1 single-bot: # 7a. Per-turn memory write (Plan §11.1, T21 / T41). With a guest
# only the host bot has a memory store, witness flags are # present this fans out to one ``memory_written`` event per witness
# ``[you=1, host=1, guest=0]``, and ``pov_summary`` is the raw # (host + guest); without a guest it preserves the Phase 1 single
# narrative text (T27 will rewrite at scene close). Significance # write keyed on the host. Witness flags are set inside the helper.
# defaults to 1; T22's async classifier pass will overwrite it.
scene = active_scene(conn, chat_id) scene = active_scene(conn, chat_id)
_event_id, memory_id = record_turn_memory( memory_results = record_turn_memory_for_present(
conn, conn,
chat_id=chat_id, chat_id=chat_id,
host_bot_id=host_bot["id"], host_bot_id=host_bot["id"],
narrative_text=full_text, guest_bot_id=guest_bot_id,
narrative_text=primary_text,
scene_id=scene["id"] if scene else None, scene_id=scene["id"] if scene else None,
chat_clock_at=chat.get("time"), chat_clock_at=chat.get("time"),
) )
# 6b. Post-turn state-update pass (Requirements §3.4). For Phase 1 # 7b. Post-turn state-update pass (Requirements §3.4 / T40). All
# the only present entities are ``you`` and ``host_bot`` so we run # directed pairs over the present entities — 2 pairs for 1:1, 6 for
# two classifier calls — one per directed edge — and append the # 3-entity scenes. Run sequentially via the inner helper which honors
# resulting ``edge_update`` events. The recent-dialogue slice is # the Featherless 2-conn cap.
# re-read here so the pass sees the just-appended assistant turn.
# We use ``append_and_apply`` (vs append + project) because the
# edge_update handler is *not* replay-safe: re-projecting prior
# events would re-apply their deltas on top of the live row.
recent_for_update = _read_recent_dialogue(conn, chat_id, limit=10)
you_entity = get_you(conn) or {"name": "you", "persona": ""} you_entity = get_you(conn) or {"name": "you", "persona": ""}
last_at = chat.get("time") last_at = chat.get("time")
recent_for_update = _read_recent_dialogue(conn, chat_id, limit=10)
edge_b2y = get_edge(conn, host_bot["id"], "you") or { present_ids, present_names, personas, prior_edges = (
"affinity": 50, _gather_state_update_inputs(
"trust": 50, conn,
"summary": "", host_bot=host_bot,
} guest_bot=guest_bot,
update_b2y = await compute_state_update( you_entity=you_entity,
client,
model=settings.classifier_model,
source_id=host_bot["id"],
target_id="you",
source_name=host_bot["name"],
source_persona=host_bot.get("persona", ""),
target_name=you_entity.get("name", "you"),
prior_affinity=edge_b2y["affinity"],
prior_trust=edge_b2y["trust"],
prior_summary=edge_b2y.get("summary", "") or "",
recent_dialogue=recent_for_update,
) )
)
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( append_and_apply(
conn, conn,
kind="edge_update", kind="edge_update",
payload={ payload={
"source_id": host_bot["id"], "source_id": src_id,
"target_id": "you", "target_id": tgt_id,
"chat_id": chat_id, "chat_id": chat_id,
"affinity_delta": update_b2y.affinity_delta, "affinity_delta": update.affinity_delta,
"trust_delta": update_b2y.trust_delta, "trust_delta": update.trust_delta,
"knowledge_facts": update_b2y.knowledge_facts, "knowledge_facts": update.knowledge_facts,
"last_interaction_at": last_at, "last_interaction_at": last_at,
"last_interaction_chat_id": chat_id, "last_interaction_chat_id": chat_id,
}, },
) )
edge_y2b = get_edge(conn, "you", host_bot["id"]) or { # 7c. Enqueue the async significance pass (Plan §11.1, T22). The
"affinity": 50,
"trust": 50,
"summary": "",
}
update_y2b = await compute_state_update(
client,
model=settings.classifier_model,
source_id="you",
target_id=host_bot["id"],
source_name=you_entity.get("name", "you"),
source_persona=you_entity.get("persona", "") or "",
target_name=host_bot["name"],
prior_affinity=edge_y2b["affinity"],
prior_trust=edge_y2b["trust"],
prior_summary=edge_y2b.get("summary", "") or "",
recent_dialogue=recent_for_update,
)
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": "you",
"target_id": host_bot["id"],
"chat_id": chat_id,
"affinity_delta": update_y2b.affinity_delta,
"trust_delta": update_y2b.trust_delta,
"knowledge_facts": update_y2b.knowledge_facts,
"last_interaction_at": last_at,
"last_interaction_chat_id": chat_id,
},
)
# 6c. Enqueue the async significance pass (Plan §11.1, T22). The
# worker scores the just-written memory 0-3, updates significance, # worker scores the just-written memory 0-3, updates significance,
# and auto-pins on score 3 with the §8.5 soft-cap eviction rule. # and auto-pins on score 3 with the §8.5 soft-cap eviction rule.
# Enqueued before the broadcast so it's outstanding by the time the # Phase 2 picks the host's memory id as the canonical input — guest
# client sees ``turn_html`` — but the worker is async, so the user # POV memories piggyback on the same significance score (the prose
# never blocks on it. # they record is identical for v2; per-POV rewrite happens at scene
# close in T45 and downstream-of-significance).
worker = getattr(request.app.state, "background_worker", None) worker = getattr(request.app.state, "background_worker", None)
if worker is not None and memory_id is not None: host_event_memory = memory_results.get(host_bot["id"])
host_memory_id = host_event_memory[1] if host_event_memory else None
if worker is not None and host_memory_id is not None:
worker.enqueue( worker.enqueue(
SignificanceJob( SignificanceJob(
memory_id=memory_id, memory_id=host_memory_id,
narrative_text=full_text, narrative_text=primary_text,
prior_dialogue=recent_for_update, prior_dialogue=recent_for_update,
host_bot_id=host_bot["id"], host_bot_id=host_bot["id"],
) )
) )
# 6d. Scene-close detection (Plan §7.2, T26). Runs AFTER assistant_turn # 8. Interjection branch (T39 / T44). Only fires when the chat has a
# so the bot's response is the closing scene's final beat — closing # guest AND the addressee was the bot we *can* interject for (i.e.
# before narrative would force the bot to speak "in no scene", which # not the lone bot in a 1:1 chat). The silent witness is whichever
# is awkward. Hard signals only in Phase 1: container change parsed # bot didn't get the addressee slot. We only run this when the
# from prose, or explicit "fade out" / "we're done here" patterns. # primary stream actually completed — a cancelled or errored primary
# On classifier failure the service returns ``should_close=False`` # short-circuits the follow-on so we don't classifier-spam against a
# so the turn flow keeps moving; the manual close button in the # half-formed beat.
# drawer is the always-available fallback. interjection_text: str | None = None
interjection_speaker_id: str | None = None
interjection_truncated = False
if (
guest_bot is not None
and not cancelled
and not primary_truncated
and primary_text.strip()
):
# Identify the silent witness — the bot that is NOT the addressee.
if addressee_id == host_bot["id"]:
silent_witness = guest_bot
else:
silent_witness = host_bot
edge_w_to_addr = get_edge(
conn, silent_witness["id"], addressee_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=addressee_bot["name"],
addressee_just_said=primary_text,
silent_witness_name=silent_witness["name"],
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,
timeout_s=settings.classifier_timeout_s,
)
if decision.should_interject:
interjection_speaker_id = silent_witness["id"]
# Re-read recent_dialogue so the just-appended assistant_turn
# (the addressee's beat) is in the prompt context.
interject_recent = _read_recent_dialogue(conn, chat_id, limit=20)
if interject_recent and interject_recent[-1].get("speaker") == "you":
interject_recent = interject_recent[:-1]
interject_messages = assemble_narrative_prompt(
conn,
chat_id=chat_id,
speaker_bot_id=silent_witness["id"],
addressee=addressee_bot["id"],
user_turn_prose=prompt_prose if prompt_prose else 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"],
},
)
interject_task = asyncio.create_task(_stream_interjection())
_in_flight_tasks[chat_id] = interject_task
try:
await interject_task
except asyncio.CancelledError:
interjection_truncated = True
cancelled = True
except Exception:
interjection_truncated = True
finally:
_in_flight_tasks.pop(chat_id, None)
interjection_text = "".join(interject_accumulated)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": silent_witness["id"],
"text": interjection_text,
"truncated": interjection_truncated,
"user_turn_id": user_turn_event_id,
"interjection_of": addressee_bot["id"],
},
)
# Skip the downstream classifier passes if the interjection
# was cancelled mid-stream — we don't want to score a partial
# beat the user never got to read in full.
if not interjection_truncated:
# Re-run the multi-pair state update — the interjector
# adding their voice plausibly shifts edges for everyone
# in the room. Idempotent enough for v2 (deltas accumulate;
# no stale state). Re-read recent so the just-appended
# interjection turn is in scope.
recent_post_interject = _read_recent_dialogue(
conn, chat_id, limit=10
)
# Re-fetch prior edges so deltas land on the post-primary
# state rather than the pre-turn baseline.
_, _, _, prior_edges_post = _gather_state_update_inputs(
conn,
host_bot=host_bot,
guest_bot=guest_bot,
you_entity=you_entity,
)
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,
},
)
# Memory write for the interjection beat — a second pair
# of memory_written events (host + guest POVs).
record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=host_bot["id"],
guest_bot_id=guest_bot_id,
narrative_text=interjection_text,
scene_id=scene["id"] if scene else None,
chat_clock_at=chat.get("time"),
)
# 9. Scene-close detection (Plan §7.2, T26). Runs AFTER assistant_turn
# and the optional interjection so the bots' responses are part of
# the closing scene's final beat — closing before narrative would
# force the bot to speak "in no scene", which is awkward. Hard
# signals only in Phase 1: container change parsed from prose, or
# explicit "fade out" / "we're done here" patterns. On classifier
# failure the service returns ``should_close=False`` so the turn
# flow keeps moving; the manual close button in the drawer is the
# always-available fallback.
# #
# Skip empty prose — no signal to classify and no point spending a # Skip empty prose — no signal to classify and no point spending a
# round-trip. Skip when there's no active scene (e.g. after a prior # round-trip. Skip when there's no active scene (e.g. after a prior
@@ -393,11 +647,12 @@ async def post_turn(
"significance": 0, "significance": 0,
}, },
) )
# T27: per-POV summary + edge summary update + knowledge # T27 / T45: per-POV summary + edge summary update + knowledge
# promotion. Runs synchronously after the close so the # promotion for each present witness (host always; guest when
# next turn (or a subsequent GET /chats/<id>) sees the # present). Runs synchronously after the close so the next
# rewritten memories and edge summary. Tolerates classifier # turn (or a subsequent GET /chats/<id>) sees the rewritten
# failure (returns the empty default and skips the writes). # memories and edge summaries. Tolerates classifier failure
# (returns the empty default and skips the writes).
await apply_scene_close_summary( await apply_scene_close_summary(
conn, conn,
client, client,
@@ -408,22 +663,48 @@ async def post_turn(
timeout_s=settings.classifier_timeout_s, timeout_s=settings.classifier_timeout_s,
) )
# 7. Broadcast a JSON completion event (for JS consumers) and an HTML # 10. Broadcast a JSON completion event (for JS consumers) and an HTML
# fragment event (for HTMX SSE swap-into-timeline). # fragment event (for HTMX SSE swap-into-timeline). One pair per
# written assistant_turn so the timeline ends up with both the
# primary and the interjection beat in the right order.
await publish( await publish(
chat_id, chat_id,
{ {
"event": "assistant_turn_complete", "event": "assistant_turn_complete",
"speaker_id": host_bot["id"], "speaker_id": addressee_bot["id"],
"text": full_text, "text": primary_text,
"truncated": truncated, "truncated": primary_truncated,
}, },
) )
assistant_html = _render_turn_html( primary_html = _render_turn_html(
host_bot["name"], full_text, role="bot" addressee_bot["name"], primary_text, role="bot"
) )
await publish( await publish(
chat_id, {"event": "turn_html", "data": assistant_html} chat_id, {"event": "turn_html", "data": primary_html}
)
if interjection_text is not None and interjection_speaker_id is not None:
# The interjector's display name is whichever bot wasn't the
# addressee — pull it from the in-scope variable directly.
interject_speaker_name = (
host_bot["name"]
if interjection_speaker_id == host_bot["id"]
else (guest_bot["name"] if guest_bot is not None else "bot")
)
await publish(
chat_id,
{
"event": "assistant_turn_complete",
"speaker_id": interjection_speaker_id,
"text": interjection_text,
"truncated": interjection_truncated,
},
)
interject_html = _render_turn_html(
interject_speaker_name, interjection_text, role="bot"
)
await publish(
chat_id, {"event": "turn_html", "data": interject_html}
) )
if cancelled: if cancelled:
+322
View File
@@ -0,0 +1,322 @@
"""T42: drawer guest add/remove + render.
The drawer grows a "Guest" section (when a guest bot is present in the
chat), a "Group" section sourced from the ``group_node`` row, an
"Add guest" form (visible while no guest is present), and a "Remove
guest" button (visible while one is). The two new POST endpoints emit
``guest_added`` / ``guest_removed`` events plus ancillary updates:
* ``POST /chats/{chat_id}/drawer/guest/add`` runs the relationship-seed
classifier (T38) over the user-supplied prose and emits an
``edge_update`` per direction when the seed comes back non-default.
It also seeds a ``group_node_initialized`` row when none exists yet.
* ``POST /chats/{chat_id}/drawer/guest/remove`` first emits
``scene_closed`` for the active scene so the host -> you scene closes
cleanly before the guest leaves.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from chat.app import app
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.llm.mock import MockLLMClient
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "config.toml"
cfg.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
db = tmp_path / "test.db"
monkeypatch.setenv("CHAT_DB_PATH", str(db))
with TestClient(app) as c:
if hasattr(app.state, "background_worker"):
app.state.background_worker.enabled = False
yield c
def _bot_payload(bot_id: str, name: str) -> dict:
return {
"id": bot_id,
"name": name,
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
}
def _seed_chat(db: Path, *, with_scene: bool = True) -> None:
"""Seed a chat hosted by ``bot_a`` (with ``bot_b`` authored as a
candidate guest) and, by default, an open scene so the
``guest_removed`` flow has something to close.
"""
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": ""},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
if with_scene:
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": None,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
project(conn)
def _override_llm(canned: list[str]):
"""Wire a ``MockLLMClient`` into the drawer's LLM dependency."""
from chat.web.kickoff import get_llm_client
app.dependency_overrides[get_llm_client] = lambda: MockLLMClient(
canned=list(canned)
)
def test_drawer_no_guest_omits_guest_section(client, tmp_path):
_seed_chat(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
# No guest-section header; the "Add guest" form should be visible instead.
assert "<h3>Guest</h3>" not in body
assert "Add guest" in body
def test_drawer_add_guest_seeds_edges_and_group_node(client, tmp_path):
_seed_chat(tmp_path / "test.db")
canned = json.dumps(
{
"a_to_b_summary": "old college friend",
"a_to_b_knowledge_facts": ["studied physics together"],
"a_to_b_affinity_delta": 4,
"a_to_b_trust_delta": -1,
"b_to_a_summary": "former roommate",
"b_to_a_knowledge_facts": ["lived together junior year"],
"b_to_a_affinity_delta": 3,
"b_to_a_trust_delta": 0,
}
)
_override_llm([canned])
try:
response = client.post(
"/chats/chat_bot_a/drawer/guest/add",
data={
"guest_bot_id": "bot_b",
"relationship_prose": (
"Alice and Bob met in college and studied physics together."
),
},
)
assert response.status_code == 200
finally:
app.dependency_overrides.clear()
with open_db(tmp_path / "test.db") as conn:
from chat.state.edges import get_edge
from chat.state.group_node import get_group_node
from chat.state.world import get_chat
chat = get_chat(conn, "chat_bot_a")
assert chat["guest_bot_id"] == "bot_b"
edge_a_to_b = get_edge(conn, "bot_a", "bot_b")
edge_b_to_a = get_edge(conn, "bot_b", "bot_a")
# Seed deltas applied around the 50/50 default.
assert edge_a_to_b["affinity"] == 54
assert edge_a_to_b["trust"] == 49
assert "studied physics together" in edge_a_to_b["knowledge"]
assert edge_b_to_a["affinity"] == 53
assert edge_b_to_a["trust"] == 50
assert "lived together junior year" in edge_b_to_a["knowledge"]
group = get_group_node(conn, "chat_bot_a")
assert group is not None
assert set(group["members"]) == {"you", "bot_a", "bot_b"}
def test_drawer_add_guest_empty_prose_skips_edge_update(client, tmp_path):
_seed_chat(tmp_path / "test.db")
# No canned responses: the seed function short-circuits on empty prose
# so no LLM call should happen.
_override_llm([])
try:
response = client.post(
"/chats/chat_bot_a/drawer/guest/add",
data={"guest_bot_id": "bot_b", "relationship_prose": " "},
)
assert response.status_code == 200
finally:
app.dependency_overrides.clear()
with open_db(tmp_path / "test.db") as conn:
from chat.state.world import get_chat
chat = get_chat(conn, "chat_bot_a")
assert chat["guest_bot_id"] == "bot_b"
# guest_added fires but no edge_update events between bot_a and bot_b.
added = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'guest_added'"
).fetchone()[0]
assert added == 1
edge_updates = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'edge_update'"
).fetchall()
for (payload_json,) in edge_updates:
payload = json.loads(payload_json)
pair = {payload.get("source_id"), payload.get("target_id")}
assert pair != {"bot_a", "bot_b"}, (
"no edge_update should be emitted between host and guest "
"when prose is empty"
)
def test_drawer_add_guest_when_already_present_returns_400(client, tmp_path):
_seed_chat(tmp_path / "test.db")
# Pre-attach a guest directly via append_and_apply so we don't replay
# the prior chat_created (which would violate UNIQUE on chats.id).
from chat.eventlog.log import append_and_apply
with open_db(tmp_path / "test.db") as conn:
append_and_apply(
conn,
kind="bot_authored",
payload=_bot_payload("bot_c", "BotC"),
)
append_and_apply(
conn,
kind="guest_added",
payload={"chat_id": "chat_bot_a", "guest_bot_id": "bot_b"},
)
_override_llm([])
try:
response = client.post(
"/chats/chat_bot_a/drawer/guest/add",
data={"guest_bot_id": "bot_c", "relationship_prose": ""},
)
assert response.status_code == 400
finally:
app.dependency_overrides.clear()
def test_drawer_remove_guest_clears_and_closes_scene(client, tmp_path):
_seed_chat(tmp_path / "test.db")
from chat.eventlog.log import append_and_apply
with open_db(tmp_path / "test.db") as conn:
append_and_apply(
conn,
kind="guest_added",
payload={"chat_id": "chat_bot_a", "guest_bot_id": "bot_b"},
)
response = client.post("/chats/chat_bot_a/drawer/guest/remove")
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
from chat.state.world import active_scene, get_chat
chat = get_chat(conn, "chat_bot_a")
assert chat["guest_bot_id"] is None
assert active_scene(conn, "chat_bot_a") is None
kinds = [
row[0]
for row in conn.execute(
"SELECT kind FROM event_log ORDER BY id"
).fetchall()
]
# scene_closed must precede guest_removed in the log.
assert "scene_closed" in kinds
assert "guest_removed" in kinds
assert kinds.index("scene_closed") < kinds.index("guest_removed")
def test_drawer_with_guest_renders_guest_and_group_sections(client, tmp_path):
_seed_chat(tmp_path / "test.db")
from chat.eventlog.log import append_and_apply
with open_db(tmp_path / "test.db") as conn:
append_and_apply(
conn,
kind="guest_added",
payload={"chat_id": "chat_bot_a", "guest_bot_id": "bot_b"},
)
# Activity for the guest so the section has content to render.
append_and_apply(
conn,
kind="activity_change",
payload={
"entity_id": "bot_b",
"posture": "leaning",
"action": {"verb": "smirking"},
"attention": "BotA",
},
)
# Edges in all four directions involving the guest.
for src, tgt in (("bot_a", "bot_b"), ("bot_b", "bot_a"), ("you", "bot_b"), ("bot_b", "you")):
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": src,
"target_id": tgt,
"chat_id": "chat_bot_a",
"affinity_delta": 1,
},
)
append_and_apply(
conn,
kind="group_node_initialized",
payload={
"chat_id": "chat_bot_a",
"members": ["you", "bot_a", "bot_b"],
"summary": "Three friends catching up over drinks.",
"dynamic": "warm and conspiratorial",
},
)
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
assert "<h3>Guest</h3>" in body
assert "BotB" in body
assert "smirking" in body
assert "<h3>Group</h3>" in body
assert "Three friends catching up over drinks." in body
assert "warm and conspiratorial" in body
# "Remove guest" button is visible when a guest is present.
assert "Remove guest" in body
+101
View File
@@ -0,0 +1,101 @@
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
import chat.state.entities # registers handlers
import chat.state.world # registers handlers
import chat.state.group_node # registers handlers
from chat.state.group_node import get_group_node
def _bot_payload(bot_id: str, name: str) -> dict:
return {
"id": bot_id,
"name": name,
"persona": "thoughtful, observant",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "coworker",
"kickoff_prose": "",
}
def _chat_payload(chat_id: str = "chat_bot_a") -> dict:
return {
"id": chat_id,
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1 evening",
"weather": "clear",
}
def test_group_node_initialized_creates_row(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(conn, kind="chat_created", payload=_chat_payload())
append_event(
conn,
kind="group_node_initialized",
payload={
"chat_id": "chat_bot_a",
"members": ["you", "bot_a", "bot_b"],
},
)
project(conn)
gn = get_group_node(conn, "chat_bot_a")
assert gn is not None
assert gn["chat_id"] == "chat_bot_a"
assert gn["members"] == ["you", "bot_a", "bot_b"]
assert gn["summary"] == ""
assert gn["dynamic"] == ""
assert gn["threads"] == []
def test_group_node_updated_changes_summary_and_dynamic(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(conn, kind="chat_created", payload=_chat_payload())
append_event(
conn,
kind="group_node_initialized",
payload={
"chat_id": "chat_bot_a",
"members": ["you", "bot_a", "bot_b"],
},
)
append_event(
conn,
kind="group_node_updated",
payload={
"chat_id": "chat_bot_a",
"summary": "Three coworkers chatting about the project.",
"dynamic": "Tense but cordial.",
},
)
project(conn)
gn = get_group_node(conn, "chat_bot_a")
assert gn is not None
assert gn["summary"] == "Three coworkers chatting about the project."
assert gn["dynamic"] == "Tense but cordial."
# Members preserved across update
assert gn["members"] == ["you", "bot_a", "bot_b"]
def test_get_group_node_returns_none_for_missing_chat(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
assert get_group_node(conn, "chat_missing") is None
+96
View File
@@ -0,0 +1,96 @@
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
import chat.state.entities # registers bot_authored handler
import chat.state.world # registers chat_created / guest_added / guest_removed
from chat.state.world import get_chat
def _bot_payload(bot_id: str, name: str) -> dict:
return {
"id": bot_id,
"name": name,
"persona": "...",
"voice_samples": ["sample"],
"traits": ["shy"],
"backstory": "...",
"initial_relationship_to_you": "coworker",
"kickoff_prose": "you stay late",
}
def _chat_payload(**overrides) -> dict:
payload = {
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1 evening",
"weather": "clear",
}
payload.update(overrides)
return payload
def test_guest_added_sets_guest_bot_id(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(conn, kind="chat_created", payload=_chat_payload())
append_event(conn, kind="guest_added", payload={
"chat_id": "chat_bot_a",
"guest_bot_id": "bot_b",
})
project(conn)
chat = get_chat(conn, "chat_bot_a")
assert chat is not None
assert chat["guest_bot_id"] == "bot_b"
def test_guest_removed_clears_guest_bot_id(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(conn, kind="chat_created", payload=_chat_payload())
append_event(conn, kind="guest_added", payload={
"chat_id": "chat_bot_a",
"guest_bot_id": "bot_b",
})
append_event(conn, kind="guest_removed", payload={
"chat_id": "chat_bot_a",
})
project(conn)
chat = get_chat(conn, "chat_bot_a")
assert chat is not None
assert chat["guest_bot_id"] is None
def test_guest_added_idempotent_overwrite(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_c", "BotC"))
append_event(conn, kind="chat_created", payload=_chat_payload())
append_event(conn, kind="guest_added", payload={
"chat_id": "chat_bot_a",
"guest_bot_id": "bot_b",
})
append_event(conn, kind="guest_added", payload={
"chat_id": "chat_bot_a",
"guest_bot_id": "bot_c",
})
project(conn)
chat = get_chat(conn, "chat_bot_a")
assert chat is not None
assert chat["guest_bot_id"] == "bot_c"
+89
View File
@@ -0,0 +1,89 @@
"""Tests for the interjection classifier service (T39).
Per Requirements §6.2, when a guest is present and the addressee bot has
just spoken, the *non-addressee* bot may interject with a brief follow-on
beat. The classifier wrapped here decides whether that interjection
should fire. The default bias is strongly toward False — the addressee
has the floor — so an interjection only fires when the silent witness's
character would plausibly speak up.
These tests cover:
* The classifier returning ``should_interject=True`` is honored.
* The classifier returning ``should_interject=False`` is honored.
* Repeated invalid JSON exhausts the classifier retries and falls back
to ``should_interject=False`` with ``reason="fallback"``.
"""
from __future__ import annotations
import json
import pytest
from chat.llm.mock import MockLLMClient
from chat.services.interjection import (
InterjectionDecision,
detect_interjection,
)
def _kwargs() -> dict:
"""Reasonable, non-empty kwargs for ``detect_interjection``."""
return dict(
classifier_model="x",
addressee_name="Alice",
addressee_just_said="I think we should leave now.",
silent_witness_name="Bob",
silent_witness_persona="Skeptical engineer, blunt, protective of the user.",
silent_witness_edge_to_addressee={
"affinity": 40,
"trust": 30,
"summary": "old rival; mild distrust",
},
silent_witness_edge_to_you={
"affinity": 70,
"trust": 80,
"summary": "long-time confidant",
},
you_just_said="Where do you both think we should go?",
)
@pytest.mark.asyncio
async def test_interjection_returns_true_when_classifier_decides_yes():
canned = json.dumps({"should_interject": True, "reason": "jealousy"})
mock = MockLLMClient(canned=[canned])
result = await detect_interjection(mock, **_kwargs())
assert isinstance(result, InterjectionDecision)
assert result.should_interject is True
assert result.reason == "jealousy"
@pytest.mark.asyncio
async def test_interjection_returns_false_when_classifier_decides_no():
canned = json.dumps(
{"should_interject": False, "reason": "addressee has the floor"}
)
mock = MockLLMClient(canned=[canned])
result = await detect_interjection(mock, **_kwargs())
assert isinstance(result, InterjectionDecision)
assert result.should_interject is False
assert result.reason == "addressee has the floor"
@pytest.mark.asyncio
async def test_interjection_falls_back_to_false_on_classifier_failure():
"""``classify`` retries 3 times; after all fail it returns the default.
The default carries ``should_interject=False`` and
``reason="fallback"`` so callers can tell a real "no" from a
classifier-degraded "no" if they ever care to.
"""
mock = MockLLMClient(
canned=["this is not json", "still not json", "still not json"]
)
result = await detect_interjection(mock, **_kwargs())
assert isinstance(result, InterjectionDecision)
assert result.should_interject is False
assert result.reason == "fallback"
+150 -1
View File
@@ -22,7 +22,7 @@ from chat.db.migrate import apply_migrations
from chat.eventlog.log import append_event from chat.eventlog.log import append_event
from chat.eventlog.projector import project from chat.eventlog.projector import project
from chat.llm.mock import MockLLMClient from chat.llm.mock import MockLLMClient
from chat.services.memory_write import record_turn_memory from chat.services.memory_write import record_turn_memory, record_turn_memory_for_present
import chat.state.entities # noqa: F401 - register handlers import chat.state.entities # noqa: F401 - register handlers
import chat.state.memory # noqa: F401 import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401 import chat.state.world # noqa: F401
@@ -295,3 +295,152 @@ def test_post_turn_writes_memory_for_host_bot(client, tmp_path):
assert w_guest == 0 assert w_guest == 0
assert source == "direct" assert source == "direct"
assert sig == 1 assert sig == 1
# ---------------------------------------------------------------------------
# T41: record_turn_memory_for_present — multi-witness helper.
# ---------------------------------------------------------------------------
def _seed_two_bots(db_path: Path) -> None:
"""Author host + guest bots and create a two-bot chat."""
with open_db(db_path) as conn:
for bot_id, name in (("bot_a", "BotA"), ("bot_b", "BotB")):
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": name,
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_ab",
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
project(conn)
def test_record_for_present_no_guest_writes_single_memory_with_witness_1_1_0(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
_seed_minimal(db)
with open_db(db) as conn:
result = record_turn_memory_for_present(
conn,
chat_id="chat_bot_a",
host_bot_id="bot_a",
guest_bot_id=None,
narrative_text="BotA glances out the window.",
scene_id=None,
chat_clock_at="2026-04-26T20:00:00+00:00",
)
# Returned dict has only the host key.
assert set(result.keys()) == {"bot_a"}
eid_h, mid_h = result["bot_a"]
assert eid_h > 0
assert mid_h is not None and mid_h > 0
rows = conn.execute(
"SELECT owner_id, witness_you, witness_host, witness_guest "
"FROM memories"
).fetchall()
assert len(rows) == 1
owner_id, w_you, w_host, w_guest = rows[0]
assert owner_id == "bot_a"
assert w_you == 1
assert w_host == 1
assert w_guest == 0
# Exactly one memory_written event was appended.
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written'"
)
assert cur.fetchone()[0] == 1
def test_record_for_present_with_guest_writes_two_memories_with_witness_1_1_1(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
_seed_two_bots(db)
with open_db(db) as conn:
result = record_turn_memory_for_present(
conn,
chat_id="chat_ab",
host_bot_id="bot_a",
guest_bot_id="bot_b",
narrative_text="BotA and BotB share a glance.",
scene_id=None,
chat_clock_at="2026-04-26T20:00:00+00:00",
)
# Returned dict has both keys.
assert set(result.keys()) == {"bot_a", "bot_b"}
eid_h, mid_h = result["bot_a"]
eid_g, mid_g = result["bot_b"]
assert eid_h > 0 and eid_g > 0
assert mid_h is not None and mid_h > 0
assert mid_g is not None and mid_g > 0
# Distinct event ids and memory ids.
assert eid_h != eid_g
assert mid_h != mid_g
rows = conn.execute(
"SELECT owner_id, witness_you, witness_host, witness_guest "
"FROM memories ORDER BY owner_id"
).fetchall()
assert len(rows) == 2
owners = {r[0] for r in rows}
assert owners == {"bot_a", "bot_b"}
# All rows should have witness mask [1, 1, 1].
for _owner, w_you, w_host, w_guest in rows:
assert w_you == 1
assert w_host == 1
assert w_guest == 1
# Two memory_written events were appended.
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written'"
)
assert cur.fetchone()[0] == 2
def test_record_for_present_dict_keys_match(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
_seed_two_bots(db)
with open_db(db) as conn:
# No guest: keys == {host_bot_id}.
result_no_guest = record_turn_memory_for_present(
conn,
chat_id="chat_ab",
host_bot_id="bot_a",
guest_bot_id=None,
narrative_text="Just BotA's POV.",
)
assert set(result_no_guest.keys()) == {"bot_a"}
# With guest: keys == {host_bot_id, guest_bot_id}.
result_with_guest = record_turn_memory_for_present(
conn,
chat_id="chat_ab",
host_bot_id="bot_a",
guest_bot_id="bot_b",
narrative_text="Both bots witness this.",
)
assert set(result_with_guest.keys()) == {"bot_a", "bot_b"}
+147
View File
@@ -0,0 +1,147 @@
"""Multi-entity state-update coordinator (T40).
Wraps the single-pair :func:`compute_state_update` to run state updates
for ALL directed pairs of present entities. With 3 present entities
(you, host, guest) that's 6 directed pairs; with 2 (you, host) it's 2.
Calls run sequentially to respect Featherless's 2-connection cap.
"""
from __future__ import annotations
import json
import pytest
from chat.llm.mock import MockLLMClient
from chat.services.multi_state_update import compute_state_updates_for_present
from chat.services.state_update import StateUpdate
def _canned_update(affinity: int, trust: int, facts: list[str] | None = None) -> str:
return json.dumps(
{
"affinity_delta": affinity,
"trust_delta": trust,
"knowledge_facts": facts or [],
}
)
@pytest.mark.asyncio
async def test_two_entities_returns_two_updates():
"""you + bot_a -> 2 directed pairs (you->bot_a, bot_a->you)."""
canned = [
_canned_update(2, 1, ["likes coffee"]), # you -> bot_a
_canned_update(1, 0, ["greets warmly"]), # bot_a -> you
]
mock = MockLLMClient(canned=canned)
results = await compute_state_updates_for_present(
mock,
classifier_model="x",
present_ids=["you", "bot_a"],
present_names={"you": "Me", "bot_a": "BotA"},
personas={"you": "", "bot_a": "thoughtful"},
prior_edges={
("you", "bot_a"): {"affinity": 50, "trust": 50, "summary": ""},
("bot_a", "you"): {"affinity": 50, "trust": 50, "summary": ""},
},
recent_dialogue=[
{"speaker": "you", "text": "hi"},
{"speaker": "BotA", "text": "Hello!"},
],
)
assert len(results) == 2
assert results[0][0] == "you"
assert results[0][1] == "bot_a"
assert isinstance(results[0][2], StateUpdate)
assert results[0][2].affinity_delta == 2
assert results[0][2].trust_delta == 1
assert results[0][2].knowledge_facts == ["likes coffee"]
assert results[1][0] == "bot_a"
assert results[1][1] == "you"
assert isinstance(results[1][2], StateUpdate)
assert results[1][2].affinity_delta == 1
assert results[1][2].trust_delta == 0
assert results[1][2].knowledge_facts == ["greets warmly"]
@pytest.mark.asyncio
async def test_three_entities_returns_six_updates():
"""you + bot_a + bot_b -> 6 directed pairs (no self-pairs)."""
canned = [_canned_update(i, 0) for i in range(6)]
mock = MockLLMClient(canned=canned)
results = await compute_state_updates_for_present(
mock,
classifier_model="x",
present_ids=["you", "bot_a", "bot_b"],
present_names={"you": "Me", "bot_a": "BotA", "bot_b": "BotB"},
personas={"you": "", "bot_a": "thoughtful", "bot_b": "cheerful"},
prior_edges={}, # all default to 50/50/""
recent_dialogue=[{"speaker": "you", "text": "hello all"}],
)
assert len(results) == 6
pairs = [(src, tgt) for src, tgt, _ in results]
# No self-pairs.
assert all(src != tgt for src, tgt in pairs)
# All 6 directed combinations present.
expected = {
("you", "bot_a"),
("you", "bot_b"),
("bot_a", "you"),
("bot_a", "bot_b"),
("bot_b", "you"),
("bot_b", "bot_a"),
}
assert set(pairs) == expected
# Every entry is a StateUpdate.
assert all(isinstance(u, StateUpdate) for _, _, u in results)
@pytest.mark.asyncio
async def test_failure_in_one_pair_does_not_kill_batch():
"""First pair fails all 3 classify retries -> default; second parses OK."""
canned = [
# Pair 1 (you -> bot_a): 3 malformed responses -> default StateUpdate.
"bad",
"still bad",
"nope",
# Pair 2 (bot_a -> you): valid JSON.
_canned_update(3, 2, ["was warm"]),
]
mock = MockLLMClient(canned=canned)
results = await compute_state_updates_for_present(
mock,
classifier_model="x",
present_ids=["you", "bot_a"],
present_names={"you": "Me", "bot_a": "BotA"},
personas={"you": "", "bot_a": "thoughtful"},
prior_edges={
("you", "bot_a"): {"affinity": 60, "trust": 40, "summary": "some prior"},
("bot_a", "you"): {"affinity": 50, "trust": 50, "summary": ""},
},
recent_dialogue=[{"speaker": "you", "text": "hi"}],
)
assert len(results) == 2
# First pair: default (zero-delta) StateUpdate.
src1, tgt1, update1 = results[0]
assert (src1, tgt1) == ("you", "bot_a")
assert update1.affinity_delta == 0
assert update1.trust_delta == 0
assert update1.knowledge_facts == []
# Second pair: parsed valid JSON.
src2, tgt2, update2 = results[1]
assert (src2, tgt2) == ("bot_a", "you")
assert update2.affinity_delta == 3
assert update2.trust_delta == 2
assert update2.knowledge_facts == ["was warm"]
+422
View File
@@ -258,3 +258,425 @@ async def test_apply_scene_close_summary_updates_memories_and_edge(tmp_path):
# Knowledge fact appended via edge_update. # Knowledge fact appended via edge_update.
assert any("deadline" in fact for fact in edge["knowledge"]) assert any("deadline" in fact for fact in edge["knowledge"])
# ---------------------------------------------------------------------------
# T45: per-POV summaries on close for each present witness.
# ---------------------------------------------------------------------------
def _bot_payload(bot_id: str, name: str, persona: str = "thoughtful") -> dict:
return {
"id": bot_id,
"name": name,
"persona": persona,
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
}
def _seed_single_bot_scene(conn) -> None:
"""Seed the canonical Phase 1 single-bot scene used by the regression test."""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (host)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Quick chat about the deadline",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "It's going to be okay.",
"truncated": False,
"user_turn_id": 1,
},
)
def _seed_two_bot_scene(conn, *, with_group_node: bool = False) -> None:
"""Seed a host+guest scene with bot_a (host) and bot_b (guest), plus a
memory row per bot owner so each per-POV update has something to rewrite,
and seeded directed edges from each bot to ``you`` so each edge_summary
update has a row to operate on. Optionally seeds the group_node row too.
"""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a", "bot_b"],
},
)
# Seed edges in both bot -> you directions so the edge_summary updates
# have rows to target.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_b",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
# One memory per witness, scene 1.
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (host)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_b",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (guest)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Three of us in the office.",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "Glad you're both here.",
"truncated": False,
"user_turn_id": 1,
},
)
if with_group_node:
append_event(
conn,
kind="group_node_initialized",
payload={
"chat_id": "chat_bot_a",
"members": ["you", "bot_a", "bot_b"],
"summary": "",
"dynamic": "",
"threads": [],
},
)
@pytest.mark.asyncio
async def test_close_with_no_guest_matches_phase1(tmp_path):
"""Regression: when guest_bot_id is None, the close summary path runs
summarize_scene exactly once and rewrites the host's memory + host->you
edge in place — same as Phase 1 behavior."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA helped you talk through the deadline anxiety.",
"knowledge_facts": ["Deadline next Friday."],
"relationship_summary": "BotA leaned in supportively.",
}
)
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
# canned has 2 entries to detect any over-call; the assertion below
# confirms only one was consumed.
client = MockLLMClient(canned=[canned, canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Exactly one classifier call -> exactly one canned entry consumed,
# leaving the second untouched.
assert len(client._canned) == 1
# Host memory rewritten with the per-POV summary content.
new_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
assert "BotA helped" in new_pov
# host->you edge summary rewritten with the relationship_summary.
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "supportively" in edge["summary"]
@pytest.mark.asyncio
async def test_close_with_guest_calls_summarize_twice(tmp_path):
"""When a guest is present, summarize_scene runs once per witness
(host + guest) and each bot's memory rewrite uses its own POV summary."""
db = tmp_path / "t.db"
apply_migrations(db)
host_canned = json.dumps(
{
"summary": "BotA noticed BotB warming up to you.",
"knowledge_facts": ["You sketched on the whiteboard."],
"relationship_summary": "BotA felt steady around you.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the office quieter than expected.",
"knowledge_facts": ["You prefer black coffee."],
"relationship_summary": "BotB warmed up to you a little.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Both canned entries consumed -> classifier ran twice.
assert client._canned == []
# Host memory carries the host's per-POV summary; guest memory
# carries the guest's.
host_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
guest_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_b' AND scene_id = 1"
).fetchone()[0]
assert "BotA noticed" in host_pov
assert "BotB found" in guest_pov
assert host_pov != guest_pov
@pytest.mark.asyncio
async def test_close_with_guest_updates_both_edges(tmp_path):
"""Both bot->you edges receive their own relationship_summary on close."""
db = tmp_path / "t.db"
apply_migrations(db)
host_canned = json.dumps(
{
"summary": "BotA noticed BotB warming up.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady around you.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB warmed to the office.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up to you a little.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
from chat.state.edges import get_edge
edge_h2y = get_edge(conn, "bot_a", "you")
edge_g2y = get_edge(conn, "bot_b", "you")
assert "steady" in edge_h2y["summary"]
assert "warmed up" in edge_g2y["summary"]
# Per-POV; the two edges did not collapse onto the same text.
assert edge_h2y["summary"] != edge_g2y["summary"]
@pytest.mark.asyncio
async def test_close_with_group_node_updates_group_summary(tmp_path):
"""When a group_node row exists, scene close emits group_node_updated
with a non-empty summary that mentions both bots' names (v2 naive
concat of per-POV summaries)."""
db = tmp_path / "t.db"
apply_migrations(db)
import chat.state.group_node # noqa: F401 -- register handlers
host_canned = json.dumps(
{
"summary": "BotA appreciated the calm.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the room friendly.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn, with_group_node=True)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
from chat.state.group_node import get_group_node
gn = get_group_node(conn, "chat_bot_a")
assert gn is not None
assert gn["summary"] # non-empty
# Naive concat surfaces both bot names in the group summary.
assert "BotA" in gn["summary"]
assert "BotB" in gn["summary"]
# Phase 2 v2 keeps dynamic empty (Phase 3 polishes).
assert gn["dynamic"] == ""
+241
View File
@@ -253,3 +253,244 @@ def test_must_exceeds_budget_hard_raises_value_error(tmp_path):
budget_soft=5, budget_soft=5,
budget_hard=10, budget_hard=10,
) )
# ---------------------------------------------------------------------------
# Task 43: multi-entity prompt assembly (guest_id support)
# ---------------------------------------------------------------------------
def _seed_with_guest(conn) -> None:
"""Seed a 3-entity scene: you (Sam) + host (Aria, bot_a) + guest (Iris, bot_b).
Group node row is initialized with summary + dynamic, edges in all
relevant directions are seeded, and activities are recorded for all
three entities.
"""
append_event(conn, kind="bot_authored", payload={
"id": "bot_a",
"name": "Aria",
"persona": "reserved coworker who notices things",
"voice_samples": ["I — sorry, I didn't mean to.", "Right. Of course."],
"traits": ["introverted", "observant"],
"backstory": "An archivist who joined the firm last spring.",
"initial_relationship_to_you": "coworker; mild crush; never voiced",
"kickoff_prose": "you stay late at the office",
})
append_event(conn, kind="bot_authored", payload={
"id": "bot_b",
"name": "Iris",
"persona": "wry transplant from the Boston office",
"voice_samples": ["Oh, please.", "Don't make me say it twice."],
"traits": ["sardonic", "loyal"],
"backstory": "Met Aria at a conference two years back.",
"initial_relationship_to_you": "stranger; curious",
"kickoff_prose": "",
})
append_event(conn, kind="you_authored", payload={
"name": "Sam",
"pronouns": "they/them",
"persona": "tired analyst",
})
append_event(conn, kind="chat_created", payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1 evening",
"weather": "clear",
})
append_event(conn, kind="container_created", payload={
"chat_id": "chat_bot_a",
"name": "office bullpen",
"type": "workplace",
"properties": {"public": False, "moving": False, "audible_range": "room"},
})
# Edges: host -> you, guest -> you, host -> guest, guest -> host.
append_event(conn, kind="edge_update", payload={
"source_id": "bot_a",
"target_id": "you",
"affinity_delta": 12,
"trust_delta": 5,
"knowledge_facts": ["they work on the same floor"],
})
append_event(conn, kind="edge_update", payload={
"source_id": "bot_a",
"target_id": "bot_b",
"affinity_delta": 20,
"trust_delta": 15,
"knowledge_facts": ["studied physics together"],
})
append_event(conn, kind="edge_update", payload={
"source_id": "bot_b",
"target_id": "you",
"affinity_delta": 4,
"trust_delta": 0,
"knowledge_facts": ["Aria's coworker"],
})
append_event(conn, kind="edge_update", payload={
"source_id": "bot_b",
"target_id": "bot_a",
"affinity_delta": 18,
"trust_delta": 12,
"knowledge_facts": ["former roommate"],
})
# Activity for all three entities — note distinct verbs so we can
# check whose activity got dropped under tight budget.
append_event(conn, kind="activity_change", payload={
"entity_id": "you",
"container_id": 1,
"posture": "sitting at your desk",
"action": {"verb": "finishing emails"},
"attention": "the screen",
"holding": ["coffee mug"],
})
append_event(conn, kind="activity_change", payload={
"entity_id": "bot_a",
"container_id": 1,
"posture": "sitting at her desk",
"action": {"verb": "pretending to work"},
"attention": "you, in glances",
})
append_event(conn, kind="activity_change", payload={
"entity_id": "bot_b",
"container_id": 1,
"posture": "leaning against the doorframe",
"action": {"verb": "smirking-distinctively"},
"attention": "Aria",
})
append_event(conn, kind="scene_opened", payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a", "bot_b"],
})
append_event(conn, kind="group_node_initialized", payload={
"chat_id": "chat_bot_a",
"members": ["you", "bot_a", "bot_b"],
"summary": "Three coworkers catching up after hours UNIQUE-GROUP-SUMMARY.",
"dynamic": "warm-but-prickly UNIQUE-GROUP-DYNAMIC",
})
project(conn)
def test_assemble_with_no_guest_matches_phase1(tmp_path):
"""Regression: 2-entity scenario without guest_id behaves exactly as Phase 1."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_basic(conn)
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=[],
)
body = msgs[0].content
# Phase 1 must blocks present.
assert "Aria" in body
assert "PERSONA" in body
assert "Sam" in body
assert "ACTIVITIES" in body
assert "62/100" in body # speaker → addressee edge intact
# No guest content leaks in.
assert "Group dynamic" not in body
assert "Iris" not in body
def test_assemble_with_guest_includes_group_node_summary(tmp_path):
"""When guest is present (auto-detected via chat.guest_bot_id) and a
group_node row exists, its summary + dynamic are rendered."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_with_guest(conn)
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=[],
)
body = msgs[0].content
assert "Group dynamic" in body
assert "UNIQUE-GROUP-SUMMARY" in body
assert "UNIQUE-GROUP-DYNAMIC" in body
# Guest activity also present (SHOULD-tier, fits at default budget).
assert "smirking-distinctively" in body
# Speaker's other edges include the host -> guest direction.
assert "Iris" in body
def test_assemble_when_speaker_is_guest_orients_edges_correctly(tmp_path):
"""When the guest is the speaker, identity is the guest, the
addressee edge is guest you, and other edges include guest host."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_with_guest(conn)
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_b", # guest as speaker
recent_dialogue=[],
retrieved_memory_summaries=[],
)
body = msgs[0].content
# Speaker identity is the guest's persona.
assert "You are Iris." in body
assert "wry transplant from the Boston office" in body
# Edge to addressee is guest → you (Sam) with the seeded values
# (default 50 + 4 affinity = 54).
assert "YOUR EDGE TO Sam" in body
assert "54/100" in body
# Other edges include guest → host (Aria) with seeded value
# (default 50 + 18 = 68).
assert "OTHER EDGES" in body
assert "Aria" in body
assert "68/100" in body
def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path):
"""Under tight budget MUST blocks survive but SHOULD-tier guest
activity is dropped first."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_with_guest(conn)
# Short dialogue so MUST core (speaker identity + edge + last 4
# turns + closing) sits comfortably under the hard budget while
# SHOULD-tier additions (guest activity, group node, other edges)
# would push over.
dialogue = [
{"speaker": "you", "text": "line-16 hi there"},
{"speaker": "bot_a", "text": "line-17 hey"},
{"speaker": "you", "text": "line-18 quiet night"},
{"speaker": "bot_a", "text": "line-19 indeed"},
]
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=dialogue,
retrieved_memory_summaries=[],
# MUST core ~310 tokens; SHOULD additions (guest activity +
# group node + other edges) push it well over 380. budget_hard
# is set just above MUST core so SHOULD-tier blocks must be
# trimmed away.
budget_soft=250,
budget_hard=340,
)
body = msgs[0].content
# MUST: speaker identity, edge to addressee, last 4 dialogue turns.
assert "Aria" in body
assert "YOUR EDGE TO Sam" in body
for i in range(16, 20):
assert f"line-{i:02d}" in body
# Guest activity (SHOULD-tier) must be dropped under tight budget.
assert "smirking-distinctively" not in body
# Token budget honoured.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
assert len(enc.encode(body)) <= 340
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the relationship-seed service (T38).
Per Requirements §5.2, when two bots first co-appear in a chat, the user
is prompted with "Have they met before? If yes, write a short prose
seed." The prose is parsed via classifier into structured directed-edge
content for the ``botA -> botB`` and ``botB -> botA`` edges.
These tests cover:
* The happy path: a canned classifier response parses cleanly into a
populated :class:`RelationshipSeed` with both directions filled.
* Empty prose short-circuits before any classifier call (mock has no
canned responses; an accidental call would raise ``IndexError``).
* Whitespace-only prose has the same short-circuit behavior.
"""
from __future__ import annotations
import json
import pytest
from chat.llm.mock import MockLLMClient
from chat.services.relationship_seed import (
RelationshipSeed,
seed_inter_bot_edges,
)
@pytest.mark.asyncio
async def test_seed_parses_canned_prose():
canned = json.dumps(
{
"a_to_b_summary": "old college friend who now distrusts him slightly",
"a_to_b_knowledge_facts": [
"studied physics together",
"lost touch after a falling out",
],
"a_to_b_affinity_delta": 2,
"a_to_b_trust_delta": -1,
"b_to_a_summary": "former roommate; warm memories, mild resentment",
"b_to_a_knowledge_facts": ["lived together junior year"],
"b_to_a_affinity_delta": 3,
"b_to_a_trust_delta": 0,
}
)
mock = MockLLMClient(canned=[canned])
result = await seed_inter_bot_edges(
mock,
classifier_model="x",
bot_a_id="bot_a",
bot_a_name="Alice",
bot_b_id="bot_b",
bot_b_name="Bob",
relationship_prose=(
"Alice and Bob met in college. They studied physics together and "
"lived as roommates junior year, but drifted apart after a fight."
),
)
assert isinstance(result, RelationshipSeed)
assert (
result.a_to_b_summary
== "old college friend who now distrusts him slightly"
)
assert result.a_to_b_knowledge_facts == [
"studied physics together",
"lost touch after a falling out",
]
assert result.a_to_b_affinity_delta == 2
assert result.a_to_b_trust_delta == -1
assert (
result.b_to_a_summary
== "former roommate; warm memories, mild resentment"
)
assert result.b_to_a_knowledge_facts == ["lived together junior year"]
assert result.b_to_a_affinity_delta == 3
assert result.b_to_a_trust_delta == 0
@pytest.mark.asyncio
async def test_seed_empty_prose_returns_empty():
"""Empty prose short-circuits — classifier must not be called."""
mock = MockLLMClient(canned=[])
result = await seed_inter_bot_edges(
mock,
classifier_model="x",
bot_a_id="bot_a",
bot_a_name="Alice",
bot_b_id="bot_b",
bot_b_name="Bob",
relationship_prose="",
)
assert result == RelationshipSeed()
@pytest.mark.asyncio
async def test_seed_whitespace_only_prose_returns_empty():
"""Whitespace-only prose is treated the same as empty."""
mock = MockLLMClient(canned=[])
result = await seed_inter_bot_edges(
mock,
classifier_model="x",
bot_a_id="bot_a",
bot_a_name="Alice",
bot_b_id="bot_b",
bot_b_name="Bob",
relationship_prose=" \n ",
)
assert result == RelationshipSeed()
+484
View File
@@ -202,3 +202,487 @@ def test_get_chat_renders_existing_turns(client, tmp_path):
body = response.text body = response.text
assert "hello" in body assert "hello" in body
assert "Hi there." in body assert "Hi there." in body
# ---------------------------------------------------------------------------
# Phase 2 (T44) — multi-entity turn flow.
#
# These tests cover the post_turn flow when a guest is present: addressee
# detection, multi-pair state-update + multi-witness memory writes, and
# the optional interjection follow-on. Each test installs its own
# MockLLMClient with a canned-response queue tailored to the call shape
# of that scenario; the queue is documented at the top of each test so
# the orchestration is auditable.
# ---------------------------------------------------------------------------
def _bot_payload(bot_id: str, name: str, persona: str = "") -> dict:
return {
"id": bot_id,
"name": name,
"persona": persona or f"persona for {name}",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "...",
}
def _seed_chat_with_guest(db_path: Path) -> None:
"""Author host BotA + guest BotB, create a chat with both wired in,
and seed an open scene plus minimal activity rows so the prompt
assembler sees a third party. Edges are seeded for all six directed
pairs at the schema-default 50/50 baseline so multi-pair state
updates land cleanly."""
with open_db(db_path) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": ""},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
# Container + open scene so scene_close detection has something
# to act on in the per-POV summary test.
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a", "bot_b"],
},
)
# Seed all six directed edges so state-update writes land on
# initialized rows. Knowledge fact on bot_a -> you exercises
# the existing-fact preservation path.
for src, tgt, facts in [
("bot_a", "you", ["coworker"]),
("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_bot_a",
"knowledge_facts": facts,
},
)
for entity_id, verb in [
("you", "talking"),
("bot_a", "listening"),
("bot_b", "listening"),
]:
append_event(
conn,
kind="activity_change",
payload={
"entity_id": entity_id,
"posture": "sitting",
"action": {
"verb": verb,
"interruptible": True,
"required_attention": "low",
"expected_duration": "ongoing",
},
"attention": "",
"holding": [],
"status": {},
},
)
project(conn)
def _override_llm(canned: list[str]) -> MockLLMClient:
"""Wire a fresh ``MockLLMClient`` and return it so tests can introspect
the residual canned queue after the request."""
from chat.web.kickoff import get_llm_client
mock = MockLLMClient(canned=list(canned))
app.dependency_overrides[get_llm_client] = lambda: mock
return mock
def _zero_state() -> str:
return json.dumps(
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
)
@pytest.fixture
def app_state_setup(tmp_path, monkeypatch):
"""Same env wiring as the existing ``client`` fixture but without a
pre-installed MockLLMClient the multi-entity tests pin their own
canned queues per scenario.
"""
cfg = tmp_path / "config.toml"
cfg.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
db = tmp_path / "test.db"
monkeypatch.setenv("CHAT_DB_PATH", str(db))
with TestClient(app) as c:
app.state.background_worker.enabled = False
yield c
app.dependency_overrides.clear()
def test_single_bot_turn_no_guest_regression(app_state_setup, tmp_path):
"""No-guest regression: the canned-response queue remains parse +
narrative + 2 state-updates. Interjection is path-bypassed because
the chat has no guest, so ``detect_interjection`` is NOT invoked.
Ends with one user_turn, one assistant_turn, two edge_updates, and a
single ``memory_written``.
"""
_seed(tmp_path / "test.db")
canned_parse = json.dumps(
{"segments": [{"kind": "dialogue", "text": "hello"}]}
)
mock = _override_llm(
[canned_parse, "Hi there.", _zero_state(), _zero_state()]
)
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns", data={"prose": "hello"}
)
assert response.status_code == 204
finally:
app.dependency_overrides.clear()
# No guest -> no interjection classifier call -> queue fully drained.
assert mock._canned == []
with open_db(tmp_path / "test.db") as conn:
cur = conn.execute(
"SELECT kind FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn', 'edge_update', "
" 'memory_written') ORDER BY id"
)
kinds = [r[0] for r in cur.fetchall()]
user_turns = [k for k in kinds if k == "user_turn"]
assistant_turns = [k for k in kinds if k == "assistant_turn"]
edge_updates_after_seed = [k for k in kinds if k == "edge_update"]
memory_writes = [k for k in kinds if k == "memory_written"]
assert len(user_turns) == 1
assert len(assistant_turns) == 1
# Seed adds exactly one edge_update (bot_a -> you); the post-turn
# pass adds two more for a total of three.
assert len(edge_updates_after_seed) == 3
assert len(memory_writes) == 1
def test_multi_bot_turn_no_interjection(app_state_setup, tmp_path):
"""Chat has a guest; ``detect_interjection`` returns False. Verify:
1 user_turn + 1 assistant_turn + 6 *post-turn* edge_updates + 2
memory_written events. Single turn_html broadcast.
Canned queue (8 calls):
1. parse_turn
2. narrative stream (primary, addressee = host because the prose
doesn't name the guest)
3-8. 6 state-update calls (one per directed pair across {you,
bot_a, bot_b})
9. detect_interjection -> should_interject=False
10. detect_scene_close -> should_close=False
"""
_seed_chat_with_guest(tmp_path / "test.db")
canned_parse = json.dumps(
{"segments": [{"kind": "dialogue", "text": "hello room"}]}
)
canned = [
canned_parse,
"Greetings.",
_zero_state(), _zero_state(), _zero_state(),
_zero_state(), _zero_state(), _zero_state(),
json.dumps({"should_interject": False, "reason": "calm"}),
json.dumps({"should_close": False, "reason": "no signal"}),
]
mock = _override_llm(canned)
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns", data={"prose": "hello room"}
)
assert response.status_code == 204
finally:
app.dependency_overrides.clear()
# All 10 canned slots should have been consumed.
assert mock._canned == []
with open_db(tmp_path / "test.db") as conn:
# Count post-turn edge_updates (i.e. those after the latest
# assistant_turn id).
max_at = conn.execute(
"SELECT MAX(id) FROM event_log WHERE kind = 'assistant_turn'"
).fetchone()[0]
cur = conn.execute(
"SELECT COUNT(*) FROM event_log "
"WHERE kind = 'edge_update' AND id > ?",
(max_at,),
)
post_turn_edge_updates = cur.fetchone()[0]
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'user_turn'"
)
user_turn_count = cur.fetchone()[0]
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'assistant_turn'"
)
assistant_turn_count = cur.fetchone()[0]
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written'"
)
memory_count = cur.fetchone()[0]
assert user_turn_count == 1
assert assistant_turn_count == 1
assert post_turn_edge_updates == 6
assert memory_count == 2
def test_multi_bot_turn_with_interjection(app_state_setup, tmp_path):
"""Chat has a guest; ``detect_interjection`` returns True. Verify:
1 user_turn + 2 assistant_turns + (6 + 6) post-turn edge_updates +
4 memory_written events.
Canned queue (16 calls):
1. parse_turn
2. narrative stream (primary)
3-8. 6 state-update calls (post-primary)
9. detect_interjection -> should_interject=True
10. narrative stream (interjection)
11-16. 6 state-update calls (post-interjection)
17. detect_scene_close -> should_close=False
"""
_seed_chat_with_guest(tmp_path / "test.db")
canned_parse = json.dumps(
{"segments": [{"kind": "dialogue", "text": "tell me"}]}
)
canned = [
canned_parse,
"Primary beat.",
_zero_state(), _zero_state(), _zero_state(),
_zero_state(), _zero_state(), _zero_state(),
json.dumps({"should_interject": True, "reason": "jealous"}),
"Interjection beat!",
_zero_state(), _zero_state(), _zero_state(),
_zero_state(), _zero_state(), _zero_state(),
json.dumps({"should_close": False, "reason": "no signal"}),
]
mock = _override_llm(canned)
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns", data={"prose": "tell me"}
)
assert response.status_code == 204
finally:
app.dependency_overrides.clear()
assert mock._canned == []
with open_db(tmp_path / "test.db") as conn:
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'assistant_turn'"
)
assistant_count = cur.fetchone()[0]
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written'"
)
memory_count = cur.fetchone()[0]
# All edge_updates after the FIRST assistant_turn are post-turn.
first_at = conn.execute(
"SELECT MIN(id) FROM event_log WHERE kind = 'assistant_turn'"
).fetchone()[0]
post_turn_edges = conn.execute(
"SELECT COUNT(*) FROM event_log "
"WHERE kind = 'edge_update' AND id > ?",
(first_at,),
).fetchone()[0]
# Both assistant_turn payloads should reference the same user_turn
# and the second one tags ``interjection_of`` the first speaker.
rows = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE kind = 'assistant_turn' ORDER BY id"
).fetchall()
first_payload = json.loads(rows[0][0])
second_payload = json.loads(rows[1][0])
assert assistant_count == 2
assert memory_count == 4
assert post_turn_edges == 12
assert first_payload["text"] == "Primary beat."
assert second_payload["text"] == "Interjection beat!"
# The silent witness is the bot that wasn't the primary addressee.
assert second_payload["interjection_of"] == first_payload["speaker_id"]
assert second_payload["speaker_id"] != first_payload["speaker_id"]
assert first_payload["user_turn_id"] == second_payload["user_turn_id"]
def test_multi_bot_turn_scene_close_writes_per_pov_summaries(
app_state_setup, tmp_path
):
"""Chat has a guest, prose hard-signals a scene close, classifier
confirms. Verify a ``scene_closed`` event lands and per-POV summary
rewrites fire for both bots (memory.pov_summary changes for each).
Interjection short-circuits at False so the queue stays compact.
Canned queue (12 calls):
1. parse_turn
2. narrative stream (primary)
3-8. 6 state-update calls
9. detect_interjection -> False (no follow-on stream)
10. detect_scene_close -> True
11. apply_scene_close_summary host POV
12. apply_scene_close_summary guest POV
"""
_seed_chat_with_guest(tmp_path / "test.db")
canned_parse = json.dumps(
{
"segments": [
{"kind": "narration", "text": "we are done here, fade out"}
]
}
)
pov_payload = json.dumps(
{
"summary": "BotA noticed the day winding down.",
"knowledge_facts": [],
"relationship_summary": "warmer",
}
)
pov_payload_guest = json.dumps(
{
"summary": "BotB watched the scene close.",
"knowledge_facts": [],
"relationship_summary": "warmer",
}
)
canned = [
canned_parse,
"Goodnight.",
_zero_state(), _zero_state(), _zero_state(),
_zero_state(), _zero_state(), _zero_state(),
json.dumps({"should_interject": False, "reason": "calm"}),
json.dumps({"should_close": True, "reason": "fade out signaled"}),
pov_payload,
pov_payload_guest,
]
mock = _override_llm(canned)
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns", data={"prose": "we are done here, fade out"}
)
assert response.status_code == 204
finally:
app.dependency_overrides.clear()
assert mock._canned == []
with open_db(tmp_path / "test.db") as conn:
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'scene_closed'"
)
scene_close_count = cur.fetchone()[0]
# One memory_pov_summary manual_edit per witness.
cur = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
)
manual_edits = [json.loads(r[0]) for r in cur.fetchall()]
pov_edits = [
e for e in manual_edits
if e.get("target_kind") == "memory_pov_summary"
]
# After the rewrite, bot_a's scene-1 memory carries the host POV
# and bot_b's scene-1 memory carries the guest POV.
host_pov = conn.execute(
"SELECT pov_summary FROM memories WHERE owner_id = ? AND scene_id = 1",
("bot_a",),
).fetchone()
guest_pov = conn.execute(
"SELECT pov_summary FROM memories WHERE owner_id = ? AND scene_id = 1",
("bot_b",),
).fetchone()
assert scene_close_count == 1
# Two memory rewrites — one per witness.
assert len(pov_edits) == 2
assert host_pov is not None and "BotA noticed" in host_pov[0]
assert guest_pov is not None and "BotB watched" in guest_pov[0]
def test_addressee_detection_routes_to_named_bot(app_state_setup, tmp_path):
"""Prose that names the guest by name routes the primary turn to the
guest. Interjection (when fired) makes the host the silent witness
and the second assistant_turn carries the host as speaker.
Canned queue: same shape as the with-interjection test (16 calls)
plus the trailing scene_close decision.
"""
_seed_chat_with_guest(tmp_path / "test.db")
canned_parse = json.dumps(
{"segments": [{"kind": "dialogue", "text": "BotB, what do you think?"}]}
)
canned = [
canned_parse,
"BotB pondering.",
_zero_state(), _zero_state(), _zero_state(),
_zero_state(), _zero_state(), _zero_state(),
json.dumps({"should_interject": True, "reason": "host wants in"}),
"BotA chiming in.",
_zero_state(), _zero_state(), _zero_state(),
_zero_state(), _zero_state(), _zero_state(),
json.dumps({"should_close": False, "reason": "no signal"}),
]
mock = _override_llm(canned)
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns",
data={"prose": "BotB, what do you think?"},
)
assert response.status_code == 204
finally:
app.dependency_overrides.clear()
assert mock._canned == []
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE kind = 'assistant_turn' ORDER BY id"
).fetchall()
primary_payload = json.loads(rows[0][0])
interjection_payload = json.loads(rows[1][0])
# Primary speaker is the guest because the prose names BotB and not
# BotA (case-insensitive whole-word match).
assert primary_payload["speaker_id"] == "bot_b"
# Interjection follow-on goes to the silent witness — the host.
assert interjection_payload["speaker_id"] == "bot_a"
assert interjection_payload["interjection_of"] == "bot_b"
+2 -2
View File
@@ -324,11 +324,11 @@ def test_get_scene_returns_none_for_missing(tmp_path):
assert active_scene(conn, "chat_missing") is None assert active_scene(conn, "chat_missing") is None
def test_schema_version_after_migration_is_7(tmp_path): def test_schema_version_after_migration_is_8(tmp_path):
db = tmp_path / "t.db" db = tmp_path / "t.db"
apply_migrations(db) apply_migrations(db)
with open_db(db) as conn: with open_db(db) as conn:
row = conn.execute( row = conn.execute(
"SELECT value FROM meta WHERE key = 'schema_version'" "SELECT value FROM meta WHERE key = 'schema_version'"
).fetchone() ).fetchone()
assert int(row[0]) == 7 assert int(row[0]) == 8