diff --git a/app/backend/approaches/approach.py b/app/backend/approaches/approach.py index a315feec4c..4cf3bfc20c 100644 --- a/app/backend/approaches/approach.py +++ b/app/backend/approaches/approach.py @@ -387,6 +387,8 @@ def create_chat_completion( params["stream_options"] = {"include_usage": True} params["tools"] = tools + if params["tools"] is not None: + params["parallel_tool_calls"] = False # Azure OpenAI takes the deployment name as the model name return self.openai_client.chat.completions.create( diff --git a/app/backend/approaches/chatreadretrieveread.py b/app/backend/approaches/chatreadretrieveread.py index 4299fbfff5..c609a0b18c 100644 --- a/app/backend/approaches/chatreadretrieveread.py +++ b/app/backend/approaches/chatreadretrieveread.py @@ -1,3 +1,4 @@ +import json from typing import Any, Awaitable, List, Optional, Union, cast from azure.search.documents.aio import SearchClient @@ -88,7 +89,12 @@ async def run_until_final_call( ) query_messages = self.prompt_manager.render_prompt( - self.query_rewrite_prompt, {"user_query": original_user_query, "past_messages": messages[:-1]} + self.query_rewrite_prompt, + { + "user_query": original_user_query, + "past_messages": messages[:-1], + "user_email": auth_claims.get("email", ""), + }, ) tools: List[ChatCompletionToolParam] = self.query_rewrite_tools @@ -110,7 +116,97 @@ async def run_until_final_call( ), ) - query_text = self.get_search_query(chat_completion, original_user_query) + tool_type = self.get_tool_type(chat_completion) + + # If the model chose to send an email, handle that separately + if tool_type == "send_email": + email_data = self.get_email_data(chat_completion) + # Format the chat history as HTML for the email + chat_history_html = self.format_chat_history_as_html(messages[:-1]) + # Add the original query at the end + chat_history_html += f"
User: {original_user_query}
" + + # Send the email via Graph API + if "oid" in auth_claims: + await self.send_chat_history_email( + auth_claims, + email_data["to_email"], + email_data["subject"], + email_data["introduction"], + chat_history_html, + ) + + # Set up a response indicating email was sent + extra_info = ExtraInfo( + DataPoints(text=""), + thoughts=[ThoughtStep("Email sent", "Email with chat history sent to user", {})], + ) + + # Create a response that indicates the email was sent + response_message = f"I've sent an email with our conversation history to your registered email address with the subject: '{email_data['subject']}'." + + # Create a chat completion object manually as we're not going through normal flow + chat_coroutine = self.create_chat_completion( + self.chatgpt_deployment, + self.chatgpt_model, + [ + { + "role": "system", + "content": "You are a helpful assistant, let the user know that you've completed the requested action.", + }, + {"role": "user", "content": "Send email with chat history"}, + {"role": "assistant", "content": response_message}, + ], + overrides, + self.get_response_token_limit(self.chatgpt_model, 300), + should_stream, + ) + + return (extra_info, chat_coroutine) + + # If the model chose to add a note, handle that separately + if tool_type == "add_note": + note_data = self.get_note_data(chat_completion) + # Format the chat history as HTML for the OneNote page + chat_history_html = self.format_chat_history_as_html(messages[:-1]) + # Compose the full OneNote page content + full_content = f"{note_data['intro_content']}
" + chat_history_html + # Create the OneNote page via Graph API + if "oid" in auth_claims: + note_response = await self.auth_helper.create_onenote_page( + graph_resource_access_token=auth_claims.get("graph_resource_access_token"), + title=note_data["title"], + content_html=full_content, + ) + extra_info = ExtraInfo( + DataPoints(text=""), + thoughts=[ThoughtStep("OneNote page created", "OneNote page with chat history created", {})], + ) + messages = [ + { + "role": "system", + "content": "You are a helpful assistant, let the user know that you've completed the requested action.", + }, + {"role": "user", "content": original_user_query}, + {"role": "assistant", "tool_calls": chat_completion.choices[0].message.tool_calls}, + { + "role": "tool", + "tool_call_id": chat_completion.choices[0].message.tool_calls[0].id, + "content": json.dumps(note_response), + }, + ] + chat_coroutine = self.create_chat_completion( + self.chatgpt_deployment, + self.chatgpt_model, + messages, + overrides, + self.get_response_token_limit(self.chatgpt_model, 300), + should_stream, + ) + return (extra_info, chat_coroutine) + + # Extract search query if it's a search request + query_text = self.get_search_query(chat_completion, original_user_query, tool_type) # STEP 2: Retrieve relevant documents from the search index with the GPT optimized query @@ -198,3 +294,121 @@ async def run_until_final_call( ), ) return (extra_info, chat_coroutine) + + def get_search_query(self, chat_completion: ChatCompletion, original_user_query: str, tool_type: str) -> str: + """Extract the search query from the chat completion""" + if tool_type != "search_sources": + return original_user_query + + if not chat_completion.choices or not chat_completion.choices[0].message: + return original_user_query + + message = chat_completion.choices[0].message + + if not message.tool_calls: + # If no tool calls but content exists, try to extract query from content + if message.content and message.content.strip() != "0": + return message.content + return original_user_query + + # For each tool call, check if it's a search_sources call and extract the query + for tool_call in message.tool_calls: + if tool_call.function.name == "search_sources": + try: + arguments = json.loads(tool_call.function.arguments) + if "search_query" in arguments: + return arguments["search_query"] + except (json.JSONDecodeError, KeyError): + pass + + return original_user_query + + def get_email_data(self, chat_completion: ChatCompletion) -> dict: + """Extract email data from a send_email tool call""" + message = chat_completion.choices[0].message + + for tool_call in message.tool_calls: + if tool_call.function.name == "send_email": + try: + arguments = json.loads(tool_call.function.arguments) + return { + "subject": arguments.get("subject", "Chat History"), + "to_email": arguments.get("to_email", ""), + "introduction": arguments.get("introduction", "Here is your requested chat history:"), + } + except (json.JSONDecodeError, KeyError): + # Return defaults if there's an error parsing the arguments + return { + "subject": "Chat History", + "to_email": "", + "introduction": "Here is your requested chat history:", + } + + # Fallback defaults + return {"subject": "Chat History", "to_email": "", "introduction": "Here is your requested chat history:"} + + def get_note_data(self, chat_completion: ChatCompletion) -> dict: + """Extract note data from an add_note tool call""" + message = chat_completion.choices[0].message + title = None + intro_content = None + for tool_call in message.tool_calls: + if tool_call.function.name == "add_note": + try: + arguments = json.loads(tool_call.function.arguments) + title = arguments.get("title") + intro_content = arguments.get("intro_content") + except (json.JSONDecodeError, KeyError): + pass + return { + "title": title if title else "Chat History", + "intro_content": intro_content if intro_content else "Here is the chat history:", + } + + def format_chat_history_as_html(self, messages: list[ChatCompletionMessageParam]) -> str: + """Format the chat history as HTML for email""" + html = "" + for message in messages: + role = message.get("role", "") + content = message.get("content", "") + if not content or not isinstance(content, str): + continue + + if role == "user": + html += f"User: {content}
" + elif role == "assistant": + html += f"Assistant: {content}
" + elif role == "system": + # Usually we don't include system messages in the chat history for users + pass + + return html + + async def send_chat_history_email( + self, auth_claims: dict, to_email: str, subject: str, introduction: str, chat_history_html: str + ) -> dict: + """Send the chat history as an email to the user""" + # Create the full email content with the introduction and chat history + full_content = f"{introduction}\n\n{chat_history_html}" + # Call send_mail with all required parameters + return await self.auth_helper.send_mail( + graph_resource_access_token=auth_claims.get("graph_resource_access_token"), + to_recipients=[to_email], + subject=subject, + content=full_content, + content_type="HTML", + ) + + def get_tool_type(self, chat_completion: ChatCompletion) -> str: + """Determine the type of tool call in the chat completion""" + if not chat_completion.choices or not chat_completion.choices[0].message: + return "" + + message = chat_completion.choices[0].message + if not message.tool_calls: + return "" + + for tool_call in message.tool_calls: + return tool_call.function.name + + return "" diff --git a/app/backend/approaches/prompts/chat_query_rewrite.prompty b/app/backend/approaches/prompts/chat_query_rewrite.prompty index 545b3f5b8c..f1651866b0 100644 --- a/app/backend/approaches/prompts/chat_query_rewrite.prompty +++ b/app/backend/approaches/prompts/chat_query_rewrite.prompty @@ -16,29 +16,21 @@ sample: system: Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching in a knowledge base. You have access to Azure AI Search index with 100's of documents. -Generate a search query based on the conversation and the new question. +You can either: +1. Send an email of the current chat history if requested by the user. The current user's email address is {{ user_email }}. +2. Create a new note in OneNote with a title (summarizing chat history) and any extra content requested by user. +2. Generate a search query based on the conversation and the new question. +In that case, follow these instructions: Do not include cited source filenames and document names e.g. info.txt or doc.pdf in the search query terms. Do not include any text inside [] or <<>> in the search query terms. Do not include any special characters like '+'. If the question is not in English, translate the question to English before generating the search query. If you cannot generate a search query, return just the number 0. -user: -How did crypto do last year? - -assistant: -Summarize Cryptocurrency Market Dynamics from last year - -user: -What are my health plans? - -assistant: -Show available health plans - {% for message in past_messages %} {{ message["role"] }}: {{ message["content"] }} {% endfor %} user: -Generate search query for: {{ user_query }} +{{ user_query }} diff --git a/app/backend/approaches/prompts/chat_query_rewrite_tools.json b/app/backend/approaches/prompts/chat_query_rewrite_tools.json index cf1743483c..24d262e005 100644 --- a/app/backend/approaches/prompts/chat_query_rewrite_tools.json +++ b/app/backend/approaches/prompts/chat_query_rewrite_tools.json @@ -14,4 +14,50 @@ "required": ["search_query"] } } +}, +{ + "type": "function", + "function": { + "name": "send_email", + "description": "Send the current chat history as an email to the user", + "parameters": { + "type": "object", + "properties": { + "to_email": { + "type": "string", + "description": "The email address to send the chat history to" + }, + "subject": { + "type": "string", + "description": "The subject line of the email" + }, + "introduction": { + "type": "string", + "description": "A brief introduction to the chat history that will appear at the top of the email" + } + }, + "required": ["subject", "to_email", "introduction"] + } + } +}, +{ + "type": "function", + "function": { + "name": "add_note", + "description": "Save a new note to the user's OneNote default notebook using Microsoft Graph.", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The title of the OneNote page, which should be reflective of the recent conversation history." + }, + "intro_content": { + "type": "string", + "description": "Any additional content to be added to the OneNote page. The system will always add the chat history to the page after this content." + } + }, + "required": ["title", "intro_content"] + } + } }] diff --git a/app/backend/core/authentication.py b/app/backend/core/authentication.py index 2c9aaf87d4..99b9c3e2cb 100644 --- a/app/backend/core/authentication.py +++ b/app/backend/core/authentication.py @@ -106,7 +106,7 @@ def get_auth_setup_for_client(self) -> dict[str, Any]: "scopes": [".default"], # Uncomment the following line to cause a consent dialog to appear on every login # For more information, please visit https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code - # "prompt": "consent" + "prompt": "consent", }, "tokenRequest": { "scopes": [f"api://{self.server_app_id}/access_as_user"], @@ -182,6 +182,7 @@ def build_security_filters(self, overrides: dict[str, Any], auth_claims: dict[st async def list_groups(graph_resource_access_token: dict) -> list[str]: headers = {"Authorization": "Bearer " + graph_resource_access_token["access_token"]} groups = [] + async with aiohttp.ClientSession(headers=headers) as session: resp_json = None resp_status = None @@ -207,6 +208,136 @@ async def list_groups(graph_resource_access_token: dict) -> list[str]: return groups + @staticmethod + async def send_mail( + graph_resource_access_token: dict, to_recipients: list, subject: str, content: str, content_type: str = "Text" + ) -> dict: + """ + Send an email using the Microsoft Graph API on behalf of the authenticated user. + + Args: + graph_resource_access_token: The access token for the Microsoft Graph API + to_recipients: List of email addresses to send to + subject: Email subject + content: Email body content + content_type: Content type ("Text" or "HTML") + + Returns: + Response from the Graph API + """ + + headers = { + "Authorization": f"Bearer {graph_resource_access_token['access_token']}", + "Content-Type": "application/json", + } + + # Create the email payload + email_payload = { + "message": { + "subject": subject, + "body": {"contentType": content_type, "content": content}, + "toRecipients": [ + {"emailAddress": {"address": "pamelafox-test@caglobaldemos2507.onmicrosoft.com"}} + for email in to_recipients + ], + }, + "saveToSentItems": True, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + url="https://graph.microsoft.com/v1.0/me/sendMail", headers=headers, json=email_payload + ) as resp: + # For successful requests, MS Graph returns 202 Accepted + if resp.status in [200, 202, 204]: + return {"status": "success", "message": "Email sent successfully"} + else: + error_content = await resp.text() + logging.error(f"Error sending email: {resp.status} - {error_content}") + raise AuthError(error=error_content, status_code=resp.status) + + @staticmethod + async def get_user_info(graph_resource_access_token: dict) -> dict: + """ + Get detailed information about the authenticated user using the Microsoft Graph API. + + Args: + graph_resource_access_token: The access token for the Microsoft Graph API + + Returns: + Dictionary containing user information + """ + headers = { + "Authorization": f"Bearer {graph_resource_access_token['access_token']}", + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + async with session.get(url="https://graph.microsoft.com/v1.0/me", headers=headers) as resp: + if resp.status == 200: + user_data = await resp.json() + logging.info(f"User information: {json.dumps(user_data, indent=2)}") + return user_data + else: + error_content = await resp.text() + logging.error(f"Error getting user information: {resp.status} - {error_content}") + try: + error_json = json.loads(error_content) + error_message = error_json.get("error", {}).get("message", error_content) + logging.error(f"Error details: {error_message}") + except Exception: + pass + raise AuthError(error=error_content, status_code=resp.status) + + @staticmethod + async def create_onenote_page(graph_resource_access_token: dict, title: str, content_html: str) -> dict: + """ + Create a new page in the user's default OneNote notebook using the Microsoft Graph API. + + Args: + graph_resource_access_token: The access token for the Microsoft Graph API + title: The title of the OneNote page + content_html: The HTML content of the OneNote page + + Returns: + Response from the Graph API + """ + headers = { + "Authorization": f"Bearer {graph_resource_access_token['access_token']}", + "Content-Type": "application/xhtml+xml", + } + # OneNote page content must be valid XHTML with a