From 65c1086596f29c183310a6341483ae299b7cb878 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 11 Nov 2024 09:11:05 -0800 Subject: [PATCH] global handling of unhandled exceptions in app, graceful handling of service failures getting speech token (#236) * also resolves some "problems" surfaced in VS Code for document agent --- .../assistant/agents/document_agent.py | 56 +++++++++---------- workbench-app/pnpm-lock.yaml | 14 ++--- workbench-app/src/Root.tsx | 37 +++++++++++- .../components/Conversations/SpeechButton.tsx | 18 +++--- workbench-app/src/libs/useNotify.tsx | 6 +- workbench-app/src/main.tsx | 6 +- .../azure_speech.py | 10 +++- 7 files changed, 95 insertions(+), 52 deletions(-) diff --git a/assistants/prospector-assistant/assistant/agents/document_agent.py b/assistants/prospector-assistant/assistant/agents/document_agent.py index 9ed800ca..c2db86fc 100644 --- a/assistants/prospector-assistant/assistant/agents/document_agent.py +++ b/assistants/prospector-assistant/assistant/agents/document_agent.py @@ -761,7 +761,7 @@ async def _gc_attachment_check( ) -> tuple[Status, StepName | None]: method_metadata_key = "document_agent_gc_response" - gc_convo_config: GuidedConversationAgentConfigModel = GCAttachmentCheckConfigModel() + gc_conversation_config: GuidedConversationAgentConfigModel = GCAttachmentCheckConfigModel() # get attachment filenames for context filenames = await self._attachments_extension.get_attachment_filenames( context, config=config.agents_config.attachment_agent @@ -769,13 +769,13 @@ async def _gc_attachment_check( filenames_str = ", ".join(filenames) filenames_str = "Filenames already attached: " + filenames_str - gc_convo_config.context = gc_convo_config.context + "\n\n" + filenames_str + gc_conversation_config.context = gc_conversation_config.context + "\n\n" + filenames_str try: response_message, conversation_status, next_step_name = await GuidedConversationAgent.step_conversation( config=config, openai_client=openai_client.create_client(config.service_config), - agent_config=gc_convo_config, + agent_config=gc_conversation_config, conversation_context=context, last_user_message=message.content, ) @@ -990,15 +990,6 @@ async def _draft_content( context, config=config.agents_config.attachment_agent ) - # get outline related info - outline: str | None = None - content: str | None = None - # path = _get_document_agent_conversation_storage_path(context) - if path.exists(storage_directory_for_context(context) / "document_agent/outline.txt"): - outline = (storage_directory_for_context(context) / "document_agent/outline.txt").read_text() - if path.exists(storage_directory_for_context(context) / "document_agent/content.txt"): - content = (storage_directory_for_context(context) / "document_agent/content.txt").read_text() - # create chat completion messages chat_completion_messages: list[ChatCompletionMessageParam] = [] chat_completion_messages.append(_draft_content_main_system_message()) @@ -1006,12 +997,20 @@ async def _draft_content( _chat_history_system_message(conversation.messages, participants_list.participants) ) chat_completion_messages.extend(attachment_messages) - if outline is not None: - chat_completion_messages.append(_outline_system_message(outline)) - if content is not None: # only grabs previously written content, not all yet. - chat_completion_messages.append(_content_system_message(content)) + + # get outline related info + if path.exists(storage_directory_for_context(context) / "document_agent/outline.txt"): + document_outline = (storage_directory_for_context(context) / "document_agent/outline.txt").read_text() + if document_outline is not None: + chat_completion_messages.append(_outline_system_message(document_outline)) + + if path.exists(storage_directory_for_context(context) / "document_agent/content.txt"): + document_content = (storage_directory_for_context(context) / "document_agent/content.txt").read_text() + if document_content is not None: # only grabs previously written content, not all yet. + chat_completion_messages.append(_content_system_message(document_content)) # make completion call to openai + content: str | None = None async with openai_client.create_client(config.service_config) as client: try: completion_args = { @@ -1031,21 +1030,22 @@ async def _draft_content( ) _on_error_metadata_update(metadata, method_metadata_key, config, chat_completion_messages, e) - # store only latest version for now (will keep all versions later as need arises) - (storage_directory_for_context(context) / "document_agent/content.txt").write_text(content) + if content is not None: + # store only latest version for now (will keep all versions later as need arises) + (storage_directory_for_context(context) / "document_agent/content.txt").write_text(content) - # send the response to the conversation only if from a command. Otherwise return info to caller. - message_type = MessageType.chat - if message.message_type == MessageType.command: - message_type = MessageType.command + # send the response to the conversation only if from a command. Otherwise return info to caller. + message_type = MessageType.chat + if message.message_type == MessageType.command: + message_type = MessageType.command - await context.send_messages( - NewConversationMessage( - content=content, - message_type=message_type, - metadata=metadata, + await context.send_messages( + NewConversationMessage( + content=content, + message_type=message_type, + metadata=metadata, + ) ) - ) return Status.USER_COMPLETED, None diff --git a/workbench-app/pnpm-lock.yaml b/workbench-app/pnpm-lock.yaml index 8dedd2cd..ec4dc329 100644 --- a/workbench-app/pnpm-lock.yaml +++ b/workbench-app/pnpm-lock.yaml @@ -9205,7 +9205,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 cosmiconfig: 7.1.0 resolve: 1.22.8 @@ -9757,7 +9757,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 csstype: 3.1.3 dompurify@3.1.6: {} @@ -11609,7 +11609,7 @@ snapshots: react-error-boundary@3.1.4(react@18.3.1): dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 react: 18.3.1 react-is@16.13.1: {} @@ -11682,7 +11682,7 @@ snapshots: react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -11736,7 +11736,7 @@ snapshots: redux@4.2.1: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 reflect.getprototypeof@1.0.6: dependencies: @@ -11764,7 +11764,7 @@ snapshots: regenerator-transform@0.15.2: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 regexp.prototype.flags@1.5.2: dependencies: @@ -11871,7 +11871,7 @@ snapshots: rtl-css-js@1.16.1: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 run-parallel@1.2.0: dependencies: diff --git a/workbench-app/src/Root.tsx b/workbench-app/src/Root.tsx index 6e328a4c..9d0b7a77 100644 --- a/workbench-app/src/Root.tsx +++ b/workbench-app/src/Root.tsx @@ -1,10 +1,11 @@ -import { Toaster } from '@fluentui/react-components'; +import { Link, Popover, PopoverSurface, PopoverTrigger, Toaster } from '@fluentui/react-components'; import debug from 'debug'; import React from 'react'; import { Outlet } from 'react-router-dom'; import { Constants } from './Constants'; import useDragAndDrop from './libs/useDragAndDrop'; import { useKeySequence } from './libs/useKeySequence'; +import { useNotify } from './libs/useNotify'; import { useAppDispatch, useAppSelector } from './redux/app/hooks'; import { setIsDraggingOverBody, toggleDevMode } from './redux/features/app/appSlice'; @@ -29,6 +30,38 @@ export const Root: React.FC = () => { ], () => dispatch(toggleDevMode()), ); + const { notifyError } = useNotify(); + + const globalErrorHandler = React.useCallback( + (event: PromiseRejectionEvent) => { + log('Unhandled promise rejection', event.reason); + notifyError({ + id: ['unhandledrejection', event.reason.message, event.reason.stack].join(':'), + title: 'Unhandled error', + message: event.reason.message, + additionalActions: [ + + + More info + + +
{event.reason.stack}
+
+
, + ], + }); + }, + [notifyError], + ); + + React.useEffect(() => { + // add a global error handler to catch unhandled promise rejections + window.addEventListener('unhandledrejection', globalErrorHandler); + + return () => { + window.removeEventListener('unhandledrejection', globalErrorHandler); + }; + }, [globalErrorHandler]); // ignore file drop events at the document level as this prevents the browser from // opening the file in the window if the drop event is not handled or the user misses @@ -44,7 +77,7 @@ export const Root: React.FC = () => { return ( <> - + ); }; diff --git a/workbench-app/src/components/Conversations/SpeechButton.tsx b/workbench-app/src/components/Conversations/SpeechButton.tsx index b8f17571..c5113852 100644 --- a/workbench-app/src/components/Conversations/SpeechButton.tsx +++ b/workbench-app/src/components/Conversations/SpeechButton.tsx @@ -20,7 +20,7 @@ interface SpeechButtonProps { export const SpeechButton: React.FC = (props) => { const { disabled, onListeningChange, onSpeechRecognizing, onSpeechRecognized } = props; const [recognizer, setRecognizer] = React.useState(); - const [isFetching, setIsFetching] = React.useState(false); + const [isInitialized, setIsInitialized] = React.useState(false); const [isListening, setIsListening] = React.useState(false); const [lastSpeechResultTimestamp, setLastSpeechResultTimestamp] = React.useState(0); @@ -115,15 +115,17 @@ export const SpeechButton: React.FC = (props) => { }, [getAzureSpeechTokenAsync, onSpeechRecognized, onSpeechRecognizing]); React.useEffect(() => { - // If the recognizer is already available or we are fetching it, do nothing - if (recognizer || isFetching) return; + // If the recognizer is already initialized, return + if (isInitialized) return; - // Indicate that we are fetching the recognizer to prevent multiple fetches - setIsFetching(true); + // Set the recognizer as initialized + setIsInitialized(true); - // Fetch the recognizer, then indicate that we are no longer fetching even if the fetch fails - getRecognizer().finally(() => setIsFetching(false)); - }, [getRecognizer, isFetching, recognizer]); + (async () => { + // Fetch the recognizer + await getRecognizer(); + })(); + }, [getRecognizer, isInitialized, recognizer]); React.useEffect(() => { onListeningChange(isListening); diff --git a/workbench-app/src/libs/useNotify.tsx b/workbench-app/src/libs/useNotify.tsx index be94e99f..b0acdcb3 100644 --- a/workbench-app/src/libs/useNotify.tsx +++ b/workbench-app/src/libs/useNotify.tsx @@ -15,7 +15,7 @@ interface NotifyOptions { id: string; title?: string; message: string; - details?: string; + subtitle?: string; action?: Slot<'div'> | string; additionalActions?: React.ReactElement[]; timeout?: number; @@ -27,7 +27,7 @@ export const useNotify = (toasterId: string = Constants.app.globalToasterId) => const notify = React.useCallback( (options: NotifyOptions) => { - const { id, title, message, details, action, additionalActions, timeout, intent } = options; + const { id, title, message, subtitle, action, additionalActions, timeout, intent } = options; const getAction = () => { if (typeof action === 'string') { @@ -43,7 +43,7 @@ export const useNotify = (toasterId: string = Constants.app.globalToasterId) => dispatchToast( {title} - {message} + {message} {additionalActions && {additionalActions}} , { diff --git a/workbench-app/src/main.tsx b/workbench-app/src/main.tsx index a997a763..5e83c404 100644 --- a/workbench-app/src/main.tsx +++ b/workbench-app/src/main.tsx @@ -6,7 +6,7 @@ import { initializeFileTypeIcons } from '@fluentui/react-file-type-icons'; import debug from 'debug'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import { Provider } from 'react-redux'; +import { Provider as ReduxProvider } from 'react-redux'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { Constants } from './Constants'; import { Root } from './Root'; @@ -154,7 +154,7 @@ document.addEventListener('DOMContentLoaded', () => { const root = ReactDOM.createRoot(container); const app = ( - + @@ -167,7 +167,7 @@ document.addEventListener('DOMContentLoaded', () => { - + ); // NOTE: React.StrictMode is used to help catch common issues in the app but will also double-render diff --git a/workbench-service/semantic_workbench_service/azure_speech.py b/workbench-service/semantic_workbench_service/azure_speech.py index 0843fb0f..b7ffde47 100644 --- a/workbench-service/semantic_workbench_service/azure_speech.py +++ b/workbench-service/semantic_workbench_service/azure_speech.py @@ -1,14 +1,22 @@ +import logging + from azure.identity import DefaultAzureCredential from . import settings +logger = logging.getLogger(__name__) + def get_token() -> dict[str, str]: if settings.azure_speech.resource_id == "" or settings.azure_speech.region == "": return {} credential = DefaultAzureCredential() - token = credential.get_token("https://cognitiveservices.azure.com/.default").token + try: + token = credential.get_token("https://cognitiveservices.azure.com/.default").token + except Exception as e: + logger.error(f"Failed to get token: {e}") + return {} return { "token": f"aad#{settings.azure_speech.resource_id}#{token}",