Files
2026-07-13 13:39:38 +08:00

562 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import json
from typing import Any
import pytest
from livekit import rtc
from livekit.agents.llm import LLM, ChatContext, ChatMessage, CollectedResponse, FunctionToolCall
from livekit.agents.stt import STT, RecognizeStream, SpeechEvent, STTCapabilities
from livekit.agents.types import NOT_GIVEN, APIConnectOptions, NotGivenOr
from livekit.agents.utils import AudioBuffer
from livekit.agents.voice.events import ConversationItemAddedEvent
from livekit.agents.voice.keyterm_detection import (
_PENDING_TTL,
KeytermDetector,
_detect_keyterms,
_format_input,
_parse_tool_call,
_resolve_detection,
)
pytestmark = pytest.mark.unit
def _detector(*, static_keyterms: list[str] | None = None, **options: Any) -> KeytermDetector:
return KeytermDetector(static_keyterms=static_keyterms, options=options)
def _entries(d: KeytermDetector) -> list[tuple[str, bool]]:
"""Detected terms with their confirmed flag (confirmed first, then pending)."""
return [(t, True) for t in d._detected_terms] + [(t, False) for t in d._pending_terms]
def _ctx(text: str = "hello") -> ChatContext:
ctx = ChatContext.empty()
ctx.add_message(role="user", content=text)
return ctx
class _RecordingSTT(STT):
"""STT that records every _update_session_keyterms() / _push_conversation_item() call."""
def __init__(
self, *, supports_keyterms: bool = True, supports_chat_context: bool = False
) -> None:
super().__init__(
capabilities=STTCapabilities(
streaming=True,
interim_results=False,
keyterms=supports_keyterms,
chat_context=supports_chat_context,
)
)
self.pushed: list[list[str]] = []
self.chat_items: list[ChatMessage] = []
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> SpeechEvent:
raise NotImplementedError
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = ..., # type: ignore[assignment]
) -> RecognizeStream:
raise NotImplementedError
def _update_session_keyterms(self, keyterms: list[str]) -> None:
self.pushed.append(list(keyterms))
def _push_conversation_item(self, ev: ConversationItemAddedEvent) -> None:
if isinstance(ev.item, ChatMessage):
self.chat_items.append(ev.item)
class _FakeStream:
def __init__(self, pending: list[str], confirm: list[str], remove: list[str]) -> None:
self._args = json.dumps({"pending": pending, "confirm": confirm, "remove": remove})
async def collect(self) -> CollectedResponse:
call = FunctionToolCall(call_id="1", name="record_keyterms", arguments=self._args)
return CollectedResponse(text="", tool_calls=[call], usage=None, extra={})
class _RecordingLLM(LLM):
"""Fake LLM: returns a `record_keyterms` call per `chat()`, one result tuple per call.
Subclasses LLM so the detector's ``isinstance(..., LLM)`` gate passes; the last result
repeats once the sequence is exhausted.
"""
def __init__(self, *results: tuple[list[str], list[str], list[str]]) -> None:
super().__init__()
self._results = list(results) or [([], [], [])]
self.calls = 0
self.last_chat_ctx: ChatContext | None = None
def chat(self, *, chat_ctx: ChatContext, **kwargs: Any) -> _FakeStream: # type: ignore[override]
result = self._results[min(self.calls, len(self._results) - 1)]
self.calls += 1
self.last_chat_ctx = chat_ctx
return _FakeStream(*result)
class _BlockingStream(_FakeStream):
def __init__(self, gate: asyncio.Event) -> None:
super().__init__([], [], [])
self._gate = gate
async def collect(self) -> CollectedResponse:
await self._gate.wait()
return await super().collect()
class _BlockingLLM(LLM):
"""Fake LLM whose response blocks until ``gate`` is set (for single-flight tests)."""
def __init__(self) -> None:
super().__init__()
self.gate = asyncio.Event()
self.calls = 0
def chat(self, *, chat_ctx: ChatContext, **kwargs: Any) -> _BlockingStream: # type: ignore[override]
self.calls += 1
return _BlockingStream(self.gate)
class _FakeSession(rtc.EventEmitter[str]):
def __init__(self) -> None:
super().__init__()
self.history = ChatContext.empty()
def add_user(self, text: str) -> None:
msg = self.history.add_message(role="user", content=text)
self.emit("conversation_item_added", ConversationItemAddedEvent(item=msg))
def add_assistant(self, text: str) -> None:
msg = self.history.add_message(role="assistant", content=text)
self.emit("conversation_item_added", ConversationItemAddedEvent(item=msg))
async def _drain(detector: KeytermDetector) -> None:
await asyncio.sleep(0)
if detector._detect_task is not None:
with contextlib.suppress(Exception): # a failed pass is logged + re-raised on the task
await detector._detect_task
# -- keyterm state machine (driven through one detection pass each) --
async def test_only_confirmed_terms_are_applied() -> None:
d = _detector(static_keyterms=["Acme"], llm=_RecordingLLM((["Niamh"], ["Foo"], [])))
await d._run_once(_ctx())
# pending terms are tracked but not applied (entries: confirmed then pending)
assert _entries(d) == [("Foo", True), ("Niamh", False)]
assert d.keyterms == ["Acme", "Foo"]
async def test_pending_then_confirmed() -> None:
d = _detector(llm=_RecordingLLM((["Kubernetes"], [], []), ([], ["Kubernetes"], [])))
await d._run_once(_ctx())
assert d.keyterms == []
await d._run_once(_ctx())
assert _entries(d) == [("Kubernetes", True)]
assert d.keyterms == ["Kubernetes"]
async def test_static_terms_shown_to_llm_as_applied() -> None:
fake = _RecordingLLM()
d = _detector(static_keyterms=["Acme Corp"], llm=fake)
await d._run_once(_ctx())
# user terms must appear in the applied list, or the LLM keeps re-proposing them
assert fake.last_chat_ctx is not None
user_msg = fake.last_chat_ctx.items[-1].text_content or ""
applied_section = user_msg.split("## Applied keyterms")[1].splitlines()[1]
assert "Acme Corp" in applied_section
async def test_user_precedence_and_dedup() -> None:
d = _detector(
static_keyterms=["Acme", "Acme", "LiveKit"],
llm=_RecordingLLM(([], ["LiveKit", "Foo"], [])),
)
assert d.static_keyterms == ["Acme", "LiveKit"]
await d._run_once(_ctx()) # an auto term equal to a user term is dropped
assert [t for t, _ in _entries(d)] == ["Foo"]
assert d.keyterms == ["Acme", "LiveKit", "Foo"]
async def test_confirmed_cannot_revert_to_pending() -> None:
d = _detector(llm=_RecordingLLM(([], ["Niamh"], []), (["Niamh"], [], [])))
await d._run_once(_ctx())
assert d.keyterms == ["Niamh"]
await d._run_once(_ctx()) # a stray `pending` must not reset a confirmed term
assert _entries(d) == [("Niamh", True)]
async def test_correction_removes_and_replaces() -> None:
d = _detector(llm=_RecordingLLM((["Jon"], [], []), (["John"], [], ["Jon"]), ([], ["John"], [])))
await d._run_once(_ctx())
assert _entries(d) == [("Jon", False)]
await d._run_once(_ctx()) # misheard spelling removed, corrected one added as pending
assert _entries(d) == [("John", False)]
await d._run_once(_ctx())
assert d.keyterms == ["John"]
async def test_remove_applies_to_confirmed_terms() -> None:
d = _detector(llm=_RecordingLLM(([], ["Jon"], []), ([], ["John"], ["Jon"])))
await d._run_once(_ctx())
assert d.keyterms == ["Jon"]
await d._run_once(_ctx()) # a user correction can remove an already-applied term
assert d.keyterms == ["John"]
async def test_remove_unknown_is_noop() -> None:
d = _detector(llm=_RecordingLLM(([], ["Foo"], []), ([], [], ["does-not-exist"])))
await d._run_once(_ctx())
await d._run_once(_ctx())
assert d.keyterms == ["Foo"]
async def test_cap_evicts_oldest_confirmed() -> None:
d = _detector(max_keyterms=3, llm=_RecordingLLM(([], ["a", "b", "c", "d", "e"], [])))
await d._run_once(_ctx())
assert [t for t, _ in _entries(d)] == ["c", "d", "e"]
async def test_pending_evicted_when_not_confirmed() -> None:
# pass 1 adds "Tmp" pending; later passes never confirm it, so it ages out
d = _detector(llm=_RecordingLLM((["Tmp"], [], []), ([], ["Other"], [])))
await d._run_once(_ctx())
for _ in range(_PENDING_TTL - 1):
await d._run_once(_ctx())
assert "Tmp" in dict(_entries(d))
await d._run_once(_ctx()) # TTL exceeded
assert "Tmp" not in dict(_entries(d))
async def test_confirmed_not_evicted_by_staleness() -> None:
d = _detector(llm=_RecordingLLM(([], ["Keep"], []), (["x"], [], [])))
await d._run_once(_ctx())
for _ in range(_PENDING_TTL + 2):
await d._run_once(_ctx()) # pending churn ages out, but the confirmed term stays
assert d.keyterms == ["Keep"]
async def test_failed_pass_keeps_state() -> None:
class _BoomLLM(LLM):
def chat(self, *, chat_ctx: ChatContext, **kwargs: Any) -> Any: # type: ignore[override]
raise RuntimeError("boom")
d = _detector(llm=_BoomLLM())
# a failed pass is logged and re-raised on the (fire-and-forget) task; state is untouched
with contextlib.suppress(RuntimeError):
await d._run_once(_ctx())
assert d.keyterms == []
# -- STT binding --
async def test_push_only_on_applied_change() -> None:
stt = _RecordingSTT()
session = _FakeSession()
d = _detector(
static_keyterms=["Acme"],
enabled=True,
llm=_RecordingLLM((["Foo"], [], []), ([], ["Foo"], [])),
)
d.start(session, stt=stt)
assert stt.pushed == [["Acme"]] # start pushes the current set
session.add_user("u1")
await _drain(d) # pending Foo: tracked, no applied change -> no push
assert stt.pushed == [["Acme"]]
session.add_user("u2")
await _drain(d) # confirm Foo: push
assert stt.pushed[-1] == ["Acme", "Foo"]
await d.aclose()
async def test_detection_llm_metrics_forwarded() -> None:
# the detector re-emits its detection LLM's usage, so it reaches the session metrics pipeline
from livekit.agents.metrics import LLMMetrics
received: list[LLMMetrics] = []
fake = _RecordingLLM()
d = _detector(enabled=True, llm=fake)
d.on("metrics_collected", received.append)
d.start(_FakeSession(), stt=_RecordingSTT())
metrics = LLMMetrics(
label=fake.label,
request_id="r1",
timestamp=0.0,
duration=0.0,
ttft=0.0,
cancelled=False,
completion_tokens=1,
prompt_tokens=2,
prompt_cached_tokens=0,
total_tokens=3,
tokens_per_second=0.0,
)
fake.emit("metrics_collected", metrics)
assert received == [metrics]
await d.aclose() # detaches from the detection LLM so a later emit is dropped
fake.emit("metrics_collected", metrics)
assert received == [metrics]
async def test_start_same_stt_does_not_repush() -> None:
stt = _RecordingSTT()
session = _FakeSession()
d = _detector(static_keyterms=["Acme"], enabled=True, llm=_RecordingLLM())
d.start(session, stt=stt)
assert stt.pushed == [["Acme"]]
await d.aclose()
# re-binding the same instance on the next activity must not re-push (some STTs reconnect)
d.start(session, stt=stt)
assert stt.pushed == [["Acme"]]
await d.aclose()
async def test_static_terms_pushed_without_detection() -> None:
stt = _RecordingSTT()
session = _FakeSession()
d = _detector(static_keyterms=["Acme"], enabled=False)
d.start(session, stt=stt) # detection off must still bind the STT and push
assert stt.pushed == [["Acme"]]
d.set_static_keyterms(["New"])
assert stt.pushed[-1] == ["New"]
await d.aclose()
async def test_start_without_terms_does_not_push() -> None:
stt = _RecordingSTT()
session = _FakeSession()
d = _detector(enabled=False)
d.start(session, stt=stt) # nothing to apply -> no push, no capability warning
assert stt.pushed == []
await d.aclose()
async def test_set_static_keyterms_pushes() -> None:
stt = _RecordingSTT()
session = _FakeSession()
d = _detector(enabled=True, llm=_RecordingLLM())
d.start(session, stt=stt)
d.set_static_keyterms(["New"])
assert stt.pushed[-1] == ["New"]
await d.aclose()
def test_unsupported_stt_warn_and_skip() -> None:
stt = _RecordingSTT(supports_keyterms=False)
# exercise the base method (warn-and-skip), not the recorder override
STT._update_session_keyterms(stt, ["a", "b"])
assert stt.pushed == []
# -- chat context sink (native carryover) --
# forwarding (subscribe + push every turn) lives in AgentActivity; here we only cover the
# STT sink contract: a supporting STT receives the pushed turns, an unsupported one warns.
def test_push_conversation_item_forwards_to_supporting_stt() -> None:
stt = _RecordingSTT(supports_chat_context=True)
user = ConversationItemAddedEvent(item=ChatMessage(role="user", content=["hi"]))
agent = ConversationItemAddedEvent(item=ChatMessage(role="assistant", content=["Welcome"]))
stt._push_conversation_item(user) # both user and agent turns are forwarded
stt._push_conversation_item(agent)
assert [m.text_content for m in stt.chat_items] == ["hi", "Welcome"]
def test_unsupported_stt_chat_ctx_warn_and_skip() -> None:
stt = _RecordingSTT(supports_chat_context=False)
ev = ConversationItemAddedEvent(item=ChatMessage(role="assistant", content=["hi"]))
# exercise the base method (warn-and-skip), not the recorder override
STT._push_conversation_item(stt, ev)
assert stt.chat_items == []
# -- triggering --
async def test_triggers_every_n_user_turns() -> None:
session = _FakeSession()
fake = _RecordingLLM(([], ["Acme"], []))
d = _detector(enabled=True, turn_interval=2, llm=fake)
d.start(session, stt=_RecordingSTT())
session.add_user("first") # below interval
await _drain(d)
assert fake.calls == 0
session.add_assistant("ack") # assistant turns don't advance the counter
await _drain(d)
assert fake.calls == 0
session.add_user("second") # triggers
await _drain(d)
assert fake.calls == 1
await d.aclose()
async def test_ignores_assistant_messages_for_counting() -> None:
session = _FakeSession()
fake = _RecordingLLM()
d = _detector(enabled=True, llm=fake)
d.start(session, stt=_RecordingSTT())
session.add_assistant("hello")
await _drain(d)
assert fake.calls == 0
session.add_user("hi")
await _drain(d)
assert fake.calls == 1
await d.aclose()
async def test_empty_user_turn_does_not_trigger() -> None:
session = _FakeSession()
fake = _RecordingLLM()
d = _detector(enabled=True, llm=fake)
d.start(session, stt=_RecordingSTT())
session.add_user("")
await _drain(d)
assert fake.calls == 0
await d.aclose()
async def test_single_flight_skips_overlapping_pass() -> None:
session = _FakeSession()
fake = _BlockingLLM()
d = _detector(enabled=True, llm=fake)
d.start(session, stt=_RecordingSTT())
session.add_user("first")
await asyncio.sleep(0)
assert fake.calls == 1
session.add_user("second") # a pass is still in flight -> skipped, not queued
await asyncio.sleep(0)
assert fake.calls == 1
fake.gate.set()
await _drain(d)
assert fake.calls == 1
await d.aclose()
async def test_aclose_unsubscribes() -> None:
session = _FakeSession()
fake = _RecordingLLM()
d = _detector(enabled=True, llm=fake)
d.start(session, stt=_RecordingSTT())
await d.aclose()
session.add_user("hi")
await asyncio.sleep(0)
assert fake.calls == 0
async def test_disabled_detection_does_not_trigger() -> None:
session = _FakeSession()
fake = _RecordingLLM(([], ["Acme"], []))
d = _detector(enabled=False, llm=fake)
d.start(session, stt=_RecordingSTT())
session.add_user("the Acme Grand")
await _drain(d)
assert fake.calls == 0
assert d.keyterms == []
async def test_unsupported_stt_skips_detection() -> None:
# no point running LLM detection passes when the STT can't consume the keyterms
session = _FakeSession()
fake = _RecordingLLM(([], ["Acme"], []))
d = _detector(enabled=True, llm=fake)
d.start(session, stt=_RecordingSTT(supports_keyterms=False))
session.add_user("the Acme Grand")
await _drain(d)
assert fake.calls == 0
# -- module helpers --
async def test_detect_keyterms_parses_result() -> None:
llm = _RecordingLLM(([], ["Niamh"], ["Jon"]))
pending, confirm, remove = await _detect_keyterms(llm, _ctx("It's Niamh"), current_keyterms=[])
assert pending == []
assert confirm == ["Niamh"]
assert remove == ["Jon"]
# no transcript -> no LLM call, empty result
assert await _detect_keyterms(llm, ChatContext.empty()) == ([], [], [])
def test_parse_tool_call() -> None:
call = FunctionToolCall(
call_id="1",
name="record_keyterms",
arguments=json.dumps(
{
"pending": ["John", " ", 5], # blanks and non-strings are dropped
"confirm": ["Foo"],
"remove": ["Jon"],
}
),
)
pending, confirm, remove = _parse_tool_call([call])
assert pending == ["John"]
assert confirm == ["Foo"]
assert remove == ["Jon"]
def test_parse_tool_call_missing() -> None:
assert _parse_tool_call([]) == ([], [], [])
bad = FunctionToolCall(call_id="1", name="record_keyterms", arguments="not json")
assert _parse_tool_call([bad]) == ([], [], [])
def test_format_input_splits_applied_and_candidate() -> None:
text = _format_input(_ctx("hi"), [("Term1", True), ("Term2", False)])
assert text is not None
assert "Applied keyterms" in text and "Term1" in text
assert "Candidate keyterms" in text and "Term2" in text
assert "record_keyterms" in text # trailing instruction
# no transcript yet -> nothing to send
assert _format_input(ChatContext.empty(), []) is None
def test_resolve_detection() -> None:
assert _resolve_detection(None)["enabled"] is False
assert _resolve_detection({"enabled": False})["enabled"] is False
resolved = _resolve_detection({"enabled": True})
assert resolved["enabled"] is True
assert resolved["turn_interval"] == 1
assert resolved["max_keyterms"] is None