diff --git a/chat/services/interjection.py b/chat/services/interjection.py new file mode 100644 index 0000000..84fac62 --- /dev/null +++ b/chat/services/interjection.py @@ -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"] diff --git a/tests/test_interjection.py b/tests/test_interjection.py new file mode 100644 index 0000000..8200d11 --- /dev/null +++ b/tests/test_interjection.py @@ -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"