Files
livekit--agents/tests/test_aligned_transcript_fallback.py

105 lines
4.2 KiB
Python

"""The agent transcript must survive a TTS that advertises alignment it never sends (#6493).
``capabilities.aligned_transcript`` says word timings were *requested*, not that the
model/language pair returns any, and the timed channel exists as soon as the node
streams — before a single timed word has arrived. Committing the turn's transcript to
that channel therefore left the turn with no transcript at all whenever the provider
sent none, which is what surfaced as "no agent transcript was returned from tts".
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterable
import pytest
from livekit.agents.types import TimedString
from livekit.agents.voice.agent_activity import _aligned_transcript_or_text
from livekit.agents.voice.events import ConversationItemAddedEvent
from .fake_session import FakeActions, create_session, run_session
from .fake_tts import FakeTTS
from .test_agent_session import MyAgent
pytestmark = pytest.mark.unit
async def _stream(*items: str) -> AsyncIterable[str]:
for item in items:
yield item
async def _collect(source: AsyncIterable[str]) -> list[str]:
return [chunk async for chunk in source]
async def test_aligned_transcript_is_forwarded_when_it_arrives() -> None:
timed = _stream(
TimedString("hello ", start_time=0.0, end_time=0.5),
TimedString("there", start_time=0.5, end_time=1.0),
)
text = _stream("hello there")
forwarded = await _collect(_aligned_transcript_or_text(timed, text))
assert forwarded == ["hello ", "there"]
# the timings are what the aligned path exists for, so they must survive
assert all(isinstance(chunk, TimedString) for chunk in forwarded)
# the spoken text was left untouched
assert await _collect(text) == ["hello there"]
async def test_falls_back_to_the_spoken_text_when_no_timings_arrive(
caplog: pytest.LogCaptureFixture,
) -> None:
timed = _stream() # the provider advertised alignment and sent none
text = _stream("hello ", "there")
with caplog.at_level(logging.WARNING, logger="livekit.agents"):
forwarded = await _collect(_aligned_transcript_or_text(timed, text))
assert forwarded == ["hello ", "there"], "the turn would otherwise have no transcript"
assert any("no aligned transcript" in record.message for record in caplog.records)
async def test_partial_alignment_is_not_duplicated(caplog: pytest.LogCaptureFixture) -> None:
# one timed word is proof the provider aligns; the rest is its business, and
# replaying the spoken text on top would repeat what was already forwarded
timed = _stream(TimedString("hello", start_time=0.0, end_time=0.5))
text = _stream("hello there")
with caplog.at_level(logging.WARNING, logger="livekit.agents"):
forwarded = await _collect(_aligned_transcript_or_text(timed, text))
assert forwarded == ["hello"]
assert not [r for r in caplog.records if "no aligned transcript" in r.message]
async def test_turn_keeps_its_transcript_when_the_tts_sends_no_timings() -> None:
# end to end: the session's TTS claims alignment (as cartesia/elevenlabs do by
# default) while emitting audio only, which is the shape reported in #6493 for a
# model/language pair that cannot align
actions = FakeActions()
actions.add_user_speech(0.5, 2.0, "Hello, how are you?", stt_delay=0.1)
actions.add_llm("I'm doing well, thank you!", ttft=0.1, duration=0.2)
actions.add_tts(2.0, ttfb=0.2, duration=0.2)
session = create_session(actions, extra_kwargs={"use_tts_aligned_transcript": True})
assert isinstance(session.tts, FakeTTS)
session.tts._capabilities.aligned_transcript = True
conversation: list[ConversationItemAddedEvent] = []
session.on("conversation_item_added", conversation.append)
await asyncio.wait_for(run_session(session, MyAgent()), timeout=30)
agent_messages = [
ev.item for ev in conversation if ev.item.type == "message" and ev.item.role == "assistant"
]
assert agent_messages, "the agent turn was never recorded"
assert agent_messages[0].text_content, (
"the agent turn has no transcript: the aligned channel was empty and nothing fell back"
)