livekit--agents
185 行
7.9 KiB
Python
185 行
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from ... import llm, stt, tts, vad
|
|
from ...llm.chat_context import Instructions
|
|
from ...llm.tool_context import ToolError, ToolFlag, function_tool
|
|
from ...types import NOT_GIVEN, NotGivenOr
|
|
from ...utils import is_given
|
|
from ...voice.agent import AgentTask
|
|
from ...voice.events import RunContext
|
|
|
|
if TYPE_CHECKING:
|
|
from ...voice.audio_recognition import TurnDetectionMode
|
|
|
|
PHONE_REGEX = r"^\+?[1-9]\d{6,14}$"
|
|
|
|
_BASE_INSTRUCTIONS = """
|
|
You are only a single step in a broader system, responsible solely for capturing a phone number.
|
|
{modality_specific}
|
|
Call `update_phone_number` at the first opportunity whenever you form a new hypothesis about the phone number. (before asking any questions or providing any answers.)
|
|
Don't invent phone numbers, stick strictly to what the user said.
|
|
{confirmation_instructions}
|
|
If the number is unclear or invalid, or it takes too much back-and-forth, prompt for it in parts: first the area code, then the remaining digits.
|
|
Never repeat the phone number back to the user as a single block of digits. Read it back in groups.
|
|
Ignore unrelated input and avoid going off-topic. Do not generate markdown, greetings, or unnecessary commentary.
|
|
Avoid verbosity by not sharing example phone numbers or formats unless prompted to do so. Do not deviate from the goal of collecting the user's phone number.
|
|
Always explicitly invoke a tool when applicable. Do not simulate tool usage, no real action is taken unless the tool is explicitly called.\
|
|
{extra_instructions}
|
|
"""
|
|
|
|
_AUDIO_SPECIFIC = """
|
|
Handle input as noisy voice transcription. Expect that users will say phone numbers aloud with formats like:
|
|
- '555 123 4567'
|
|
- 'five five five, one two three, four five six seven'
|
|
- '+1 555 123 4567'
|
|
- 'area code 555, 123 4567'
|
|
- '555-123-4567'
|
|
Normalize common spoken patterns silently:
|
|
- Convert spoken digits to their numeric form: 'five' → 5, 'zero' → 0, 'oh' → 0.
|
|
- Remove filler words, pauses, and hesitations.
|
|
- Strip dashes, spaces, parentheses, and dots from the number.
|
|
- Recognize 'plus' at the start as the international prefix `+`.
|
|
- Recognize 'area code' as a prefix for the area code digits.
|
|
Don't mention corrections. Treat inputs as possibly imperfect but fix them silently.
|
|
"""
|
|
|
|
_TEXT_SPECIFIC = """
|
|
Handle input as typed text. Expect users to type their phone number directly.
|
|
Strip dashes, spaces, parentheses, and dots from the number.
|
|
If the number looks almost correct but has minor formatting issues, clean it up silently.
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class GetPhoneNumberResult:
|
|
phone_number: str
|
|
|
|
|
|
class GetPhoneNumberTask(AgentTask[GetPhoneNumberResult]):
|
|
def __init__(
|
|
self,
|
|
extra_instructions: str = "",
|
|
chat_ctx: NotGivenOr[llm.ChatContext] = NOT_GIVEN,
|
|
turn_detection: NotGivenOr[TurnDetectionMode | None] = NOT_GIVEN,
|
|
tools: NotGivenOr[list[llm.Tool | llm.Toolset]] = NOT_GIVEN,
|
|
stt: NotGivenOr[stt.STT | None] = NOT_GIVEN,
|
|
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
|
|
llm: NotGivenOr[llm.LLM | llm.RealtimeModel | None] = NOT_GIVEN,
|
|
tts: NotGivenOr[tts.TTS | None] = NOT_GIVEN,
|
|
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
|
|
require_confirmation: NotGivenOr[bool] = NOT_GIVEN,
|
|
require_explicit_ask: bool = False,
|
|
) -> None:
|
|
confirmation_instructions = (
|
|
"Call `confirm_phone_number` after the user confirmed the phone number is correct."
|
|
)
|
|
extra = extra_instructions if extra_instructions else ""
|
|
|
|
self._current_phone_number = ""
|
|
self._require_confirmation = require_confirmation
|
|
self._require_explicit_ask = require_explicit_ask
|
|
|
|
super().__init__(
|
|
instructions=Instructions(
|
|
audio=_BASE_INSTRUCTIONS.format(
|
|
modality_specific=_AUDIO_SPECIFIC,
|
|
confirmation_instructions=(
|
|
confirmation_instructions if require_confirmation is not False else ""
|
|
),
|
|
extra_instructions=extra,
|
|
),
|
|
text=_BASE_INSTRUCTIONS.format(
|
|
modality_specific=_TEXT_SPECIFIC,
|
|
confirmation_instructions=(
|
|
confirmation_instructions if require_confirmation is True else ""
|
|
),
|
|
extra_instructions=extra,
|
|
),
|
|
),
|
|
chat_ctx=chat_ctx,
|
|
turn_detection=turn_detection,
|
|
tools=[*(tools or []), self._build_update_phone_number_tool()],
|
|
stt=stt,
|
|
vad=vad,
|
|
llm=llm,
|
|
tts=tts,
|
|
allow_interruptions=allow_interruptions,
|
|
)
|
|
|
|
async def on_enter(self) -> None:
|
|
self.session.generate_reply(instructions="Ask the user to provide their phone number.")
|
|
|
|
def _build_update_phone_number_tool(self) -> llm.FunctionTool:
|
|
# Built dynamically so we can apply IGNORE_ON_ENTER per-instance
|
|
# based on require_explicit_ask.
|
|
flags = ToolFlag.IGNORE_ON_ENTER if self._require_explicit_ask else ToolFlag.NONE
|
|
|
|
@function_tool(flags=flags)
|
|
async def update_phone_number(phone_number: str, ctx: RunContext) -> str | None:
|
|
"""Update the phone number provided by the user.
|
|
|
|
Args:
|
|
phone_number: The phone number provided by the user, digits only with optional leading +
|
|
"""
|
|
return await self._update_phone_number_impl(phone_number, ctx)
|
|
|
|
return update_phone_number
|
|
|
|
async def _update_phone_number_impl(self, phone_number: str, ctx: RunContext) -> str | None:
|
|
cleaned = re.sub(r"[\s\-().]+", "", phone_number.strip())
|
|
|
|
if not re.match(PHONE_REGEX, cleaned):
|
|
raise ToolError(f"Invalid phone number provided: {phone_number}")
|
|
|
|
self._current_phone_number = cleaned
|
|
|
|
if not self._confirmation_required(ctx):
|
|
if not self.done():
|
|
self.complete(GetPhoneNumberResult(phone_number=self._current_phone_number))
|
|
return None # no need to continue the conversation
|
|
|
|
confirm_tool = self._build_confirm_tool(phone_number=cleaned)
|
|
current_tools = [t for t in self.tools if t.id != "confirm_phone_number"]
|
|
current_tools.append(confirm_tool)
|
|
await self.update_tools(current_tools)
|
|
|
|
return (
|
|
f"The phone number has been updated to {cleaned}\n"
|
|
f"Read the number back to the user in groups.\n"
|
|
f"Prompt the user for confirmation, do not call `confirm_phone_number` directly"
|
|
)
|
|
|
|
def _build_confirm_tool(self, *, phone_number: str) -> llm.FunctionTool:
|
|
@function_tool()
|
|
async def confirm_phone_number() -> None:
|
|
"""Call after the user confirms the phone number is correct."""
|
|
if phone_number != self._current_phone_number:
|
|
self.session.generate_reply(
|
|
instructions="The phone number has changed since confirmation was requested, ask the user to confirm the updated number."
|
|
)
|
|
return
|
|
|
|
if not self.done():
|
|
self.complete(GetPhoneNumberResult(phone_number=phone_number))
|
|
|
|
return confirm_phone_number
|
|
|
|
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
|
|
async def decline_phone_number_capture(self, reason: str) -> None:
|
|
"""Handles the case when the user explicitly declines to provide a phone number.
|
|
|
|
Args:
|
|
reason: A short explanation of why the user declined to provide the phone number
|
|
"""
|
|
if not self.done():
|
|
self.complete(ToolError(f"couldn't get the phone number: {reason}"))
|
|
|
|
def _confirmation_required(self, ctx: RunContext) -> bool:
|
|
if is_given(self._require_confirmation):
|
|
return self._require_confirmation
|
|
return ctx.speech_handle.input_details.modality == "audio"
|