6 Commits

Author SHA1 Message Date
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
6 changed files with 648 additions and 1 deletions
+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"]
+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"]