Skip to content

Commit c78ffed

Browse files
committed
Port to prompty
1 parent 4063767 commit c78ffed

19 files changed

+254
-179
lines changed

app/backend/approaches/approach.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import json
12
import os
3+
import pathlib
24
from abc import ABC
35
from dataclasses import dataclass
46
from typing import (
@@ -14,6 +16,7 @@
1416
from urllib.parse import urljoin
1517

1618
import aiohttp
19+
import prompty
1720
from azure.search.documents.aio import SearchClient
1821
from azure.search.documents.models import (
1922
QueryCaptionResult,
@@ -96,6 +99,8 @@ class Approach(ABC):
9699
# Useful for using local small language models, for example
97100
ALLOW_NON_GPT_MODELS = True
98101

102+
PROMPTS_DIRECTORY = pathlib.Path(__file__).parent / "prompts"
103+
99104
def __init__(
100105
self,
101106
search_client: SearchClient,
@@ -122,6 +127,12 @@ def __init__(
122127
self.vision_endpoint = vision_endpoint
123128
self.vision_token_provider = vision_token_provider
124129

130+
def load_prompty(self, path: str):
131+
return prompty.load(self.PROMPTS_DIRECTORY / path)
132+
133+
def load_tools(self, path: str):
134+
return json.loads(open(self.PROMPTS_DIRECTORY / path).read())
135+
125136
def build_filter(self, overrides: dict[str, Any], auth_claims: dict[str, Any]) -> Optional[str]:
126137
include_category = overrides.get("include_category")
127138
exclude_category = overrides.get("exclude_category")

app/backend/approaches/chatapproach.py

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,40 @@
22
import re
33
from abc import ABC, abstractmethod
44
from typing import Any, AsyncGenerator, Optional
5-
from jinja2 import Environment, FileSystemLoader
65

6+
import prompty
77
from openai.types.chat import ChatCompletion, ChatCompletionMessageParam
88

99
from approaches.approach import Approach
1010

11-
class ChatApproach(Approach, ABC):
12-
13-
NO_RESPONSE = "0"
1411

15-
def __init__(self):
16-
self._initialize_templates()
17-
18-
def _initialize_templates(self):
19-
self.env = Environment(loader=FileSystemLoader('approaches/prompts/chat'))
20-
json_content = self.env.loader.get_source(self.env, 'query_few_shots.json')[0]
21-
self.query_prompt_few_shots: list[ChatCompletionMessageParam] = json.loads(json_content)
22-
self.query_prompt_template = self.env.get_template('query_template.jinja').render()
23-
self.follow_up_questions_prompt = self.env.get_template('follow_up_questions.jinja').render()
24-
self.system_message_chat_conversation_template = self.env.get_template('system_message.jinja')
25-
self.system_message_chat_conversation_vision_template = self.env.get_template('system_message_vision.jinja')
12+
class ChatApproach(Approach, ABC):
2613

27-
@property
28-
@abstractmethod
29-
def system_message_chat_conversation(self) -> str:
30-
pass
14+
NO_RESPONSE = "0"
3115

3216
@abstractmethod
3317
async def run_until_final_call(self, messages, overrides, auth_claims, should_stream) -> tuple:
3418
pass
3519

36-
def get_system_prompt(self, override_prompt: Optional[str], follow_up_questions_prompt: str) -> str:
37-
if override_prompt is None:
38-
return self.system_message_chat_conversation_template.render(
39-
follow_up_questions_prompt=follow_up_questions_prompt,
40-
injected_prompt=""
41-
)
42-
elif override_prompt.startswith(">>>"):
43-
return self.system_message_chat_conversation_template.render(
44-
follow_up_questions_prompt=follow_up_questions_prompt,
45-
injected_prompt=override_prompt[3:] + "\n"
20+
def get_messages(
21+
self, override_prompt: Optional[str], include_follow_up_questions: bool, user_query: str, content: str
22+
) -> list[ChatCompletionMessageParam]:
23+
if override_prompt is None or override_prompt.startswith(">>>"):
24+
injected_prompt = "" if override_prompt is None else override_prompt[3:]
25+
return prompty.prepare(
26+
self.answer_prompt,
27+
{
28+
"include_follow_up_questions": include_follow_up_questions,
29+
"injected_prompt": injected_prompt,
30+
"user_query": user_query,
31+
"content": content,
32+
},
4633
)
4734
else:
48-
return override_prompt.format(follow_up_questions_prompt=follow_up_questions_prompt)
35+
# TODO: Warn if follow-up is specified, follow-up won't be injected
36+
return prompty.prepare(
37+
self.answer_prompt, {"override_prompt": override_prompt, "user_query": user_query, "content": content}
38+
)
4939

5040
def get_search_query(self, chat_completion: ChatCompletion, user_query: str):
5141
response_message = chat_completion.choices[0].message
@@ -153,4 +143,4 @@ async def run_stream(
153143
) -> AsyncGenerator[dict[str, Any], None]:
154144
overrides = context.get("overrides", {})
155145
auth_claims = context.get("auth_claims", {})
156-
return self.run_with_streaming(messages, overrides, auth_claims, session_state)
146+
return self.run_with_streaming(messages, overrides, auth_claims, session_state) #

app/backend/approaches/chatreadretrieveread.py

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any, Coroutine, List, Literal, Optional, Union, overload
22

3+
import prompty
34
from azure.search.documents.aio import SearchClient
45
from azure.search.documents.models import VectorQuery
56
from openai import AsyncOpenAI, AsyncStream
@@ -39,7 +40,6 @@ def __init__(
3940
query_language: str,
4041
query_speller: str,
4142
):
42-
super().__init__()
4343
self.search_client = search_client
4444
self.openai_client = openai_client
4545
self.auth_helper = auth_helper
@@ -53,13 +53,9 @@ def __init__(
5353
self.query_language = query_language
5454
self.query_speller = query_speller
5555
self.chatgpt_token_limit = get_token_limit(chatgpt_model, default_to_minimum=self.ALLOW_NON_GPT_MODELS)
56-
57-
@property
58-
def system_message_chat_conversation(self):
59-
return self.system_message_chat_conversation_template.render(
60-
follow_up_questions_prompt="",
61-
injected_prompt=""
62-
)
56+
self.query_rewrite_prompt = self.load_prompty("chat/query_rewrite.prompty")
57+
self.query_rewrite_tools = self.load_tools("chat/query_rewrite_tools.json")
58+
self.answer_prompt = self.load_prompty("chat/answer_question.prompty")
6359

6460
@overload
6561
async def run_until_final_call(
@@ -99,37 +95,20 @@ async def run_until_final_call(
9995
original_user_query = messages[-1]["content"]
10096
if not isinstance(original_user_query, str):
10197
raise ValueError("The most recent message content must be a string.")
102-
user_query_request = "Generate search query for: " + original_user_query
103-
104-
tools: List[ChatCompletionToolParam] = [
105-
{
106-
"type": "function",
107-
"function": {
108-
"name": "search_sources",
109-
"description": "Retrieve sources from the Azure AI Search index",
110-
"parameters": {
111-
"type": "object",
112-
"properties": {
113-
"search_query": {
114-
"type": "string",
115-
"description": "Query string to retrieve documents from azure search eg: 'Health care plan'",
116-
}
117-
},
118-
"required": ["search_query"],
119-
},
120-
},
121-
}
122-
]
98+
99+
# Use prompty to prepare the query prompt
100+
query_messages = prompty.prepare(self.query_rewrite_prompt, inputs={"user_query": original_user_query})
101+
tools: List[ChatCompletionToolParam] = self.query_rewrite_tools
123102

124103
# STEP 1: Generate an optimized keyword search query based on the chat history and the last question
125104
query_response_token_limit = 100
126105
query_messages = build_messages(
127106
model=self.chatgpt_model,
128-
system_prompt=self.query_prompt_template,
129-
tools=tools,
130-
few_shots=self.query_prompt_few_shots,
107+
system_prompt=query_messages[0]["content"],
108+
few_shots=query_messages[1:-1],
131109
past_messages=messages[:-1],
132-
new_user_content=user_query_request,
110+
new_user_content=query_messages[-1]["content"],
111+
tools=tools,
133112
max_tokens=self.chatgpt_token_limit - query_response_token_limit,
134113
fallback_to_default=self.ALLOW_NON_GPT_MODELS,
135114
)
@@ -172,19 +151,20 @@ async def run_until_final_call(
172151

173152
# STEP 3: Generate a contextual and content specific answer using the search results and chat history
174153

175-
# Allow client to replace the entire prompt, or to inject into the exiting prompt using >>>
176-
system_message = self.get_system_prompt(
154+
# Allow client to replace the entire prompt, or to inject into the existing prompt using >>>
155+
formatted_messages = self.get_messages(
177156
overrides.get("prompt_template"),
178-
self.follow_up_questions_prompt_content if overrides.get("suggest_followup_questions") else ""
157+
include_follow_up_questions=bool(overrides.get("suggest_followup_questions")),
158+
user_query=original_user_query,
159+
content=content,
179160
)
180161

181162
response_token_limit = 1024
182163
messages = build_messages(
183164
model=self.chatgpt_model,
184-
system_prompt=system_message,
165+
system_prompt=formatted_messages[0]["content"],
185166
past_messages=messages[:-1],
186-
# Model does not handle lengthy system messages well. Moving sources to latest user conversation to solve follow up questions prompt.
187-
new_user_content=original_user_query + "\n\nSources:\n" + content,
167+
new_user_content=formatted_messages[-1]["content"],
188168
max_tokens=self.chatgpt_token_limit - response_token_limit,
189169
fallback_to_default=self.ALLOW_NON_GPT_MODELS,
190170
)

app/backend/approaches/chatreadretrievereadvision.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from typing import Any, Awaitable, Callable, Coroutine, Optional, Union
1+
from typing import Any, Awaitable, Callable, Coroutine, List, Optional, Union
22

3+
import prompty
34
from azure.search.documents.aio import SearchClient
45
from azure.storage.blob.aio import ContainerClient
56
from openai import AsyncOpenAI, AsyncStream
@@ -9,6 +10,7 @@
910
ChatCompletionContentPartImageParam,
1011
ChatCompletionContentPartParam,
1112
ChatCompletionMessageParam,
13+
ChatCompletionToolParam,
1214
)
1315
from openai_messages_token_helper import build_messages, get_token_limit
1416

@@ -46,7 +48,6 @@ def __init__(
4648
vision_endpoint: str,
4749
vision_token_provider: Callable[[], Awaitable[str]]
4850
):
49-
super().__init__()
5051
self.search_client = search_client
5152
self.blob_container_client = blob_container_client
5253
self.openai_client = openai_client
@@ -65,13 +66,9 @@ def __init__(
6566
self.vision_endpoint = vision_endpoint
6667
self.vision_token_provider = vision_token_provider
6768
self.chatgpt_token_limit = get_token_limit(gpt4v_model, default_to_minimum=self.ALLOW_NON_GPT_MODELS)
68-
69-
@property
70-
def system_message_chat_conversation(self):
71-
return self.system_message_chat_conversation_vision_template.render(
72-
follow_up_questions_prompt="",
73-
injected_prompt=""
74-
)
69+
self.query_rewrite_prompt = self.load_prompty("chat/query_rewrite.prompty")
70+
self.query_rewrite_tools = self.load_tools("chat/query_rewrite_tools.json")
71+
self.answer_prompt = self.load_prompty("chat/answer_question_vision.prompty")
7572

7673
async def run_until_final_call(
7774
self,
@@ -97,29 +94,32 @@ async def run_until_final_call(
9794
original_user_query = messages[-1]["content"]
9895
if not isinstance(original_user_query, str):
9996
raise ValueError("The most recent message content must be a string.")
100-
past_messages: list[ChatCompletionMessageParam] = messages[:-1]
10197

102-
# STEP 1: Generate an optimized keyword search query based on the chat history and the last question
103-
user_query_request = "Generate search query for: " + original_user_query
98+
# Use prompty to prepare the query prompt
99+
query_messages = prompty.prepare(self.query_rewrite_prompt, inputs={"user_query": original_user_query})
100+
tools: List[ChatCompletionToolParam] = self.query_rewrite_tools
104101

102+
# STEP 1: Generate an optimized keyword search query based on the chat history and the last question
105103
query_response_token_limit = 100
106104
query_model = self.chatgpt_model
107105
query_deployment = self.chatgpt_deployment
108106
query_messages = build_messages(
109107
model=query_model,
110-
system_prompt=self.query_prompt_template,
111-
few_shots=self.query_prompt_few_shots,
112-
past_messages=past_messages,
113-
new_user_content=user_query_request,
108+
system_prompt=query_messages[0]["content"],
109+
few_shots=query_messages[1:-1],
110+
past_messages=messages[:-1],
111+
new_user_content=query_messages[-1]["content"],
114112
max_tokens=self.chatgpt_token_limit - query_response_token_limit,
115113
)
116114

117115
chat_completion: ChatCompletion = await self.openai_client.chat.completions.create(
118-
model=query_deployment if query_deployment else query_model,
119116
messages=query_messages,
117+
# Azure OpenAI takes the deployment name as the model name
118+
model=query_deployment if query_deployment else query_model,
120119
temperature=0.0, # Minimize creativity for search query generation
121120
max_tokens=query_response_token_limit,
122121
n=1,
122+
tools=tools,
123123
seed=seed,
124124
)
125125

@@ -156,12 +156,16 @@ async def run_until_final_call(
156156
# STEP 3: Generate a contextual and content specific answer using the search results and chat history
157157

158158
# Allow client to replace the entire prompt, or to inject into the existing prompt using >>>
159-
system_message = self.get_system_prompt(
159+
formatted_messages = self.get_messages(
160160
overrides.get("prompt_template"),
161-
self.follow_up_questions_prompt_content if overrides.get("suggest_followup_questions") else "",
161+
include_follow_up_questions=bool(overrides.get("suggest_followup_questions")),
162+
user_query=original_user_query,
163+
content=content,
162164
)
163165

164-
user_content: list[ChatCompletionContentPartParam] = [{"text": original_user_query, "type": "text"}]
166+
user_content: list[ChatCompletionContentPartParam] = [
167+
{"text": formatted_messages[-1]["content"], "type": "text"}
168+
]
165169
image_list: list[ChatCompletionContentPartImageParam] = []
166170

167171
if send_text_to_gptvision:
@@ -176,7 +180,7 @@ async def run_until_final_call(
176180
response_token_limit = 1024
177181
messages = build_messages(
178182
model=self.gpt4v_model,
179-
system_prompt=system_message,
183+
system_prompt=formatted_messages[0]["content"],
180184
past_messages=messages[:-1],
181185
new_user_content=user_content,
182186
max_tokens=self.chatgpt_token_limit - response_token_limit,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
model:
3+
api: chat
4+
---
5+
You are an intelligent assistant helping Contoso Inc employees with their healthcare plan questions and employee handbook questions.
6+
Use 'you' to refer to the individual asking the questions even if they ask with 'I'.
7+
Answer the following question using only the data provided in the sources below.
8+
Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response.
9+
If you cannot answer using the sources below, say you don't know. Use below example to answer.
10+
11+
example:
12+
13+
user:
14+
What is the deductible for the employee plan for a visit to Overlake in Bellevue
15+
16+
Sources:
17+
info1.txt: deductibles depend on whether you are in-network or out-of-network. In-network deductibles are $500 for employee and $1000 for family. Out-of-network deductibles are $1000 for employee and $2000 for family.",
18+
info2.pdf: Overlake is in-network for the employee plan.",
19+
info3.pdf: Overlake is the name of the area that includes a park and ride near Bellevue.",
20+
info4.pdf: In-network institutions include Overlake, Swedish and others in the region."
21+
22+
assistant:
23+
In-network deductibles are $500 for employee and $1000 for family [info1.txt] and Overlake is in-network for the employee plan [info2.pdf][info4.pdf].
24+
25+
user:
26+
{{ user_query }}
27+
28+
Sources:
29+
{{ content }}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
model:
3+
api: chat
4+
---
5+
You are an intelligent assistant helping analyze the Annual Financial Report of Contoso Ltd., The documents contain text, graphs, tables and images.
6+
Each image source has the file name in the top left corner of the image with coordinates (10,10) pixels and is in the format SourceFileName:<file_name>.
7+
Each text source starts in a new line and has the file name followed by colon and the actual information.
8+
Always include the source name from the image or text for each fact you use in the response in the format: [filename].
9+
Answer the following question using only the data provided in the sources below.
10+
The text and image source can be the same file name, don't use the image title when citing the image source, only use the file name as mentioned.
11+
If you cannot answer using the sources below, say you don't know. Return just the answer without any input texts.
12+
13+
example:
14+
15+
user:
16+
What is the deductible for the employee plan for a visit to Overlake in Bellevue
17+
18+
Sources:
19+
info1.txt: deductibles depend on whether you are in-network or out-of-network. In-network deductibles are $500 for employee and $1000 for family. Out-of-network deductibles are $1000 for employee and $2000 for family.",
20+
info2.pdf: Overlake is in-network for the employee plan.",
21+
info3.pdf: Overlake is the name of the area that includes a park and ride near Bellevue.",
22+
info4.pdf: In-network institutions include Overlake, Swedish and others in the region."
23+
24+
assistant:
25+
In-network deductibles are $500 for employee and $1000 for family [info1.txt] and Overlake is in-network for the employee plan [info2.pdf][info4.pdf].
26+
27+
user:
28+
{{ user_query }}
29+
30+
Sources:
31+
{{ content }}

0 commit comments

Comments
 (0)