merge: T39 interjection classifier service
This commit is contained in:
@@ -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"]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user