diff --git a/src/dailyai/pipeline/frames.py b/src/dailyai/pipeline/frames.py index dd348478d..5acd09672 100644 --- a/src/dailyai/pipeline/frames.py +++ b/src/dailyai/pipeline/frames.py @@ -74,6 +74,9 @@ class UserStoppedSpeakingFrame(Frame): pass @dataclass() +class LLMFunctionStartFrame(Frame): + function_name: str +@dataclass() class LLMFunctionCallFrame(Frame): function_name: str arguments: str \ No newline at end of file diff --git a/src/dailyai/services/ai_services.py b/src/dailyai/services/ai_services.py index e738b6cdb..db7fd950b 100644 --- a/src/dailyai/services/ai_services.py +++ b/src/dailyai/services/ai_services.py @@ -12,11 +12,11 @@ from dailyai.pipeline.frames import ( LLMMessagesQueueFrame, LLMResponseEndFrame, LLMResponseStartFrame, + LLMFunctionStartFrame, LLMFunctionCallFrame, Frame, TextFrame, - TranscriptionQueueFrame, - UserStoppedSpeakingFrame + TranscriptionQueueFrame ) from abc import abstractmethod @@ -87,17 +87,24 @@ class LLMService(AIService): if isinstance(frame, LLMMessagesQueueFrame): yield LLMResponseStartFrame() async for text_chunk in self.run_llm_async(frame.messages, tool_choice): + # We're streaming the LLM response and returning individual TextFrames for each chunk because + # we want to enable quick TTS. But if the LLM response is a function call, we don't need to yield + # each chunk because the function call is only useful as a single frame. Instead, we'll emit a + # LLMFunctionStartFrame to let downstream services know a function call is coming, then we'll + # collect the function arguments and return the entire call in a single LLMFunctionCallFrame. if isinstance(text_chunk, str): yield TextFrame(text_chunk) elif text_chunk.function: if text_chunk.function.name: - # function_name += text_chunk.function.name - yield LLMFunctionCallFrame(function_name=text_chunk.function.name, arguments=None) + function_name += text_chunk.function.name + yield LLMFunctionStartFrame(function_name=text_chunk.function.name) if text_chunk.function.arguments: - # arguments += text_chunk.function.arguments - yield LLMFunctionCallFrame(function_name=None, arguments=text_chunk.function.arguments) + # Keep iterating through the response to collect all the argument fragments and + # yield a complete LLMFunctionCallFrame after run_llm_async completes + arguments += text_chunk.function.arguments if (function_name and arguments): + yield LLMFunctionCallFrame(function_name=function_name, arguments=arguments) function_name = "" arguments = "" yield LLMResponseEndFrame() diff --git a/src/dailyai/services/base_transport_service.py b/src/dailyai/services/base_transport_service.py index 4ca554645..2fea5d7d3 100644 --- a/src/dailyai/services/base_transport_service.py +++ b/src/dailyai/services/base_transport_service.py @@ -328,7 +328,7 @@ class BaseTransportService(): break def interrupt(self): - self._logger.debug("!!! Interrupting") + self._logger.debug("### Interrupting") self._is_interrupted.set() async def get_receive_frames(self) -> AsyncGenerator[Frame, None]: diff --git a/src/examples/foundational/14-patient-intake.py b/src/examples/foundational/14-patient-intake.py index b1fe6fa8a..ec2c70f74 100644 --- a/src/examples/foundational/14-patient-intake.py +++ b/src/examples/foundational/14-patient-intake.py @@ -16,11 +16,11 @@ from dailyai.services.deepgram_ai_services import DeepgramTTSService from dailyai.services.elevenlabs_ai_service import ElevenLabsTTSService from dailyai.pipeline.aggregators import LLMAssistantContextAggregator, LLMContextAggregator, LLMUserContextAggregator, UserResponseAggregator, LLMResponseAggregator from support.runner import configure -from dailyai.pipeline.frames import LLMMessagesQueueFrame, TranscriptionQueueFrame, Frame, TextFrame, LLMFunctionCallFrame, LLMResponseEndFrame, StartFrame, AudioFrame, SpriteFrame, ImageFrame +from dailyai.pipeline.frames import LLMMessagesQueueFrame, TranscriptionQueueFrame, Frame, TextFrame, LLMFunctionCallFrame, LLMFunctionStartFrame, LLMResponseEndFrame, StartFrame, AudioFrame, SpriteFrame, ImageFrame from dailyai.services.ai_services import FrameLogger, AIService import logging -logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) sounds = {} sound_files = [ @@ -207,8 +207,6 @@ class ChecklistProcessor(AIService): self._messages = messages self._llm = llm self._tools = tools - self._function_name = "" - self._arguments = "" self._id = "You are Jessica, an agent for a company called Tri-County Health Services. Your job is to collect important information from the user before their doctor visit. You're talking to Chad Bailey. You should address the user by their first name and be polite and professional. You're not a medical professional, so you shouldn't provide any advice. Keep your responses short. Your job is to collect information to give to a doctor. Don't make assumptions about what values to plug into functions. Ask for clarification if a user response is ambiguous." self._acks = ["One sec.", "Let me confirm that.", "Thanks.", "OK."] @@ -250,7 +248,7 @@ class ChecklistProcessor(AIService): # TODO-CB: forcing a global here :/ self._tools.clear() self._tools.extend(this_step['tools']) - if isinstance(frame, LLMFunctionCallFrame) and frame.function_name: + if isinstance(frame, LLMFunctionStartFrame): print(f"... Preparing function call: {frame.function_name}") self._function_name = frame.function_name if this_step['run_async']: @@ -266,22 +264,19 @@ class ChecklistProcessor(AIService): # Insert a quick response while we run the function # yield AudioFrame(sounds["clack-short-quiet.wav"]) pass - elif isinstance(frame, LLMFunctionCallFrame) and frame.arguments: - self._arguments += frame.arguments - elif isinstance(frame, LLMResponseEndFrame): + elif isinstance(frame, LLMFunctionCallFrame): - if self._function_name and self._arguments: + if frame.function_name and frame.arguments: print( - f"--> Calling function: {self._function_name} with arguments:") + f"--> Calling function: {frame.function_name} with arguments:") pretty_json = re.sub("\n", "\n ", json.dumps( - json.loads(self._arguments), indent=2)) + json.loads(frame.arguments), indent=2)) print(f"--> {pretty_json}\n") - if not self._function_name in self._functions: - raise Exception(f"The LLM tried to call a function named {self._function_name}, which isn't in the list of known functions. Please check your prompt and/or self._functions.") - fn = getattr(self, self._function_name) - result = fn(json.loads(self._arguments)) - self._function_name = "" - self._arguments = "" + if not frame.function_name in self._functions: + raise Exception(f"The LLM tried to call a function named {frame.function_name}, which isn't in the list of known functions. Please check your prompt and/or self._functions.") + fn = getattr(self, frame.function_name) + result = fn(json.loads(frame.arguments)) + if not this_step['run_async']: if result: current_step += 1