Skip to content

[Do not merge] Example using the Graph API to send email and save notes #2515

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/backend/approaches/approach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
218 changes: 216 additions & 2 deletions app/backend/approaches/chatreadretrieveread.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from typing import Any, Awaitable, List, Optional, Union, cast

from azure.search.documents.aio import SearchClient
Expand Down Expand Up @@ -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

Expand All @@ -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"<p><strong>User:</strong> {original_user_query}</p>"

# 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"<p>{note_data['intro_content']}</p>" + 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

Expand Down Expand Up @@ -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"<p><strong>User:</strong> {content}</p>"
elif role == "assistant":
html += f"<p><strong>Assistant:</strong> {content}</p>"
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 ""
20 changes: 6 additions & 14 deletions app/backend/approaches/prompts/chat_query_rewrite.prompty
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
46 changes: 46 additions & 0 deletions app/backend/approaches/prompts/chat_query_rewrite_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}]
Loading