Skip to content

Commit 9da71ef

Browse files
authored
Port to Quart (#503)
* Quart draft * Fix ask and test * Quart deploying now * Use semantic * Get tests working * Revert simple * Typing fixes * dont use pipe
1 parent df48d8c commit 9da71ef

21 files changed

+216
-191
lines changed

.vscode/launch.json

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,23 @@
55
"version": "0.2.0",
66
"configurations": [
77
{
8-
"name": "Python: Flask",
8+
"name": "Python: Quart",
99
"type": "python",
1010
"request": "launch",
11-
"module": "flask",
11+
"module": "quart",
1212
"cwd": "${workspaceFolder}/app/backend",
1313
"env": {
14-
"FLASK_APP": "app.py",
15-
"FLASK_ENV": "development",
16-
"FLASK_DEBUG": "0"
14+
"QUART_APP": "main:app",
15+
"QUART_ENV": "development",
16+
"QUART_DEBUG": "0"
1717
},
1818
"args": [
1919
"run",
20-
"--no-debugger",
2120
"--no-reload",
22-
"-p 5000"
21+
"-p 50505"
2322
],
2423
"console": "integratedTerminal",
25-
"justMyCode": true,
24+
"justMyCode": false,
2625
"envFile": "${input:dotEnvFilePath}",
2726
},
2827
{
@@ -57,4 +56,4 @@
5756
"command": "azure-dev.commands.getDotEnvFilePath"
5857
}
5958
]
60-
}
59+
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,5 @@ Here are the most common failure scenarios and solutions:
157157

158158
1. You see `CERTIFICATE_VERIFY_FAILED` when the `prepdocs.py` script runs. That's typically due to incorrect SSL certificates setup on your machine. Try the suggestions in this [StackOverflow answer](https://stackoverflow.com/questions/35569042/ssl-certificate-verify-failed-with-python3/43855394#43855394).
159159

160-
1. After running `azd up` and visiting the website, you see a '404 Not Found' in the browser. Wait 10 minutes and try again, as it might be still starting up. Then try running `azd deploy` and wait again. If you still encounter errors with the deployed app, consult these [tips for debugging Flask app deployments](http://blog.pamelafox.org/2023/06/tips-for-debugging-flask-deployments-to.html)
160+
1. After running `azd up` and visiting the website, you see a '404 Not Found' in the browser. Wait 10 minutes and try again, as it might be still starting up. Then try running `azd deploy` and wait again. If you still encounter errors with the deployed app, consult these [tips for debugging App Service app deployments](http://blog.pamelafox.org/2023/06/tips-for-debugging-flask-deployments-to.html)
161161
and file an issue if the error logs don't help you resolve the issue.

app/backend/app.py

Lines changed: 36 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
import time
66

77
import openai
8-
from azure.identity import DefaultAzureCredential
9-
from azure.search.documents import SearchClient
10-
from azure.storage.blob import BlobServiceClient
11-
from flask import (
8+
from azure.identity.aio import DefaultAzureCredential
9+
from azure.search.documents.aio import SearchClient
10+
from azure.storage.blob.aio import BlobServiceClient
11+
from quart import (
1212
Blueprint,
13-
Flask,
13+
Quart,
1414
abort,
1515
current_app,
1616
jsonify,
@@ -49,75 +49,76 @@
4949
bp = Blueprint("routes", __name__, static_folder='static')
5050

5151
@bp.route("/")
52-
def index():
53-
return bp.send_static_file("index.html")
52+
async def index():
53+
return await bp.send_static_file("index.html")
5454

5555
@bp.route("/favicon.ico")
56-
def favicon():
57-
return bp.send_static_file("favicon.ico")
56+
async def favicon():
57+
return await bp.send_static_file("favicon.ico")
5858

5959
@bp.route("/assets/<path:path>")
60-
def assets(path):
61-
return send_from_directory("static/assets", path)
60+
async def assets(path):
61+
return await send_from_directory("static/assets", path)
6262

6363
# Serve content files from blob storage from within the app to keep the example self-contained.
6464
# *** NOTE *** this assumes that the content files are public, or at least that all users of the app
6565
# can access all the files. This is also slow and memory hungry.
6666
@bp.route("/content/<path>")
67-
def content_file(path):
67+
async def content_file(path):
6868
blob_container = current_app.config[CONFIG_BLOB_CLIENT].get_container_client(AZURE_STORAGE_CONTAINER)
69-
blob = blob_container.get_blob_client(path).download_blob()
69+
blob = await blob_container.get_blob_client(path).download_blob()
7070
if not blob.properties or not blob.properties.has_key("content_settings"):
7171
abort(404)
7272
mime_type = blob.properties["content_settings"]["content_type"]
7373
if mime_type == "application/octet-stream":
7474
mime_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
7575
blob_file = io.BytesIO()
76-
blob.readinto(blob_file)
76+
await blob.readinto(blob_file)
7777
blob_file.seek(0)
78-
return send_file(blob_file, mimetype=mime_type, as_attachment=False, download_name=path)
78+
return await send_file(blob_file, mimetype=mime_type, as_attachment=False, attachment_filename=path)
7979

8080
@bp.route("/ask", methods=["POST"])
81-
def ask():
81+
async def ask():
8282
if not request.is_json:
8383
return jsonify({"error": "request must be json"}), 415
84-
approach = request.json["approach"]
84+
request_json = await request.get_json()
85+
approach = request_json["approach"]
8586
try:
8687
impl = current_app.config[CONFIG_ASK_APPROACHES].get(approach)
8788
if not impl:
8889
return jsonify({"error": "unknown approach"}), 400
89-
r = impl.run(request.json["question"], request.json.get("overrides") or {})
90+
r = await impl.run(request_json["question"], request_json.get("overrides") or {})
9091
return jsonify(r)
9192
except Exception as e:
9293
logging.exception("Exception in /ask")
9394
return jsonify({"error": str(e)}), 500
9495

9596
@bp.route("/chat", methods=["POST"])
96-
def chat():
97+
async def chat():
9798
if not request.is_json:
9899
return jsonify({"error": "request must be json"}), 415
99-
approach = request.json["approach"]
100+
request_json = await request.get_json()
101+
approach = request_json["approach"]
100102
try:
101103
impl = current_app.config[CONFIG_CHAT_APPROACHES].get(approach)
102104
if not impl:
103105
return jsonify({"error": "unknown approach"}), 400
104-
r = impl.run(request.json["history"], request.json.get("overrides") or {})
106+
r = await impl.run(request_json["history"], request_json.get("overrides") or {})
105107
return jsonify(r)
106108
except Exception as e:
107109
logging.exception("Exception in /chat")
108110
return jsonify({"error": str(e)}), 500
109111

110112
@bp.before_request
111-
def ensure_openai_token():
113+
async def ensure_openai_token():
112114
openai_token = current_app.config[CONFIG_OPENAI_TOKEN]
113115
if openai_token.expires_on < time.time() + 60:
114-
openai_token = current_app.config[CONFIG_CREDENTIAL].get_token("https://cognitiveservices.azure.com/.default")
116+
openai_token = await current_app.config[CONFIG_CREDENTIAL].get_token("https://cognitiveservices.azure.com/.default")
115117
current_app.config[CONFIG_OPENAI_TOKEN] = openai_token
116118
openai.api_key = openai_token.token
117119

118-
119-
def create_app():
120-
app = Flask(__name__)
120+
@bp.before_app_serving
121+
async def setup_clients():
121122

122123
# Use the current user identity to authenticate with Azure OpenAI, Cognitive Search and Blob Storage (no secrets needed,
123124
# just use 'az login' locally, and managed identity when deployed on Azure). If you need to use keys, use separate AzureKeyCredential instances with the
@@ -135,24 +136,21 @@ def create_app():
135136
credential=azure_credential)
136137

137138
# Used by the OpenAI SDK
138-
openai.api_type = "azure"
139139
openai.api_base = f"https://{AZURE_OPENAI_SERVICE}.openai.azure.com"
140140
openai.api_version = "2023-05-15"
141-
142-
# Comment these two lines out if using keys, set your API key in the OPENAI_API_KEY environment variable instead
143141
openai.api_type = "azure_ad"
144-
openai_token = azure_credential.get_token(
142+
openai_token = await azure_credential.get_token(
145143
"https://cognitiveservices.azure.com/.default"
146144
)
147145
openai.api_key = openai_token.token
148146

149147
# Store on app.config for later use inside requests
150-
app.config[CONFIG_OPENAI_TOKEN] = openai_token
151-
app.config[CONFIG_CREDENTIAL] = azure_credential
152-
app.config[CONFIG_BLOB_CLIENT] = blob_client
148+
current_app.config[CONFIG_OPENAI_TOKEN] = openai_token
149+
current_app.config[CONFIG_CREDENTIAL] = azure_credential
150+
current_app.config[CONFIG_BLOB_CLIENT] = blob_client
153151
# Various approaches to integrate GPT and external knowledge, most applications will use a single one of these patterns
154152
# or some derivative, here we include several for exploration purposes
155-
app.config[CONFIG_ASK_APPROACHES] = {
153+
current_app.config[CONFIG_ASK_APPROACHES] = {
156154
"rtr": RetrieveThenReadApproach(
157155
search_client,
158156
AZURE_OPENAI_CHATGPT_DEPLOYMENT,
@@ -175,7 +173,7 @@ def create_app():
175173
KB_FIELDS_CONTENT
176174
)
177175
}
178-
app.config[CONFIG_CHAT_APPROACHES] = {
176+
current_app.config[CONFIG_CHAT_APPROACHES] = {
179177
"rrr": ChatReadRetrieveReadApproach(
180178
search_client,
181179
AZURE_OPENAI_CHATGPT_DEPLOYMENT,
@@ -186,10 +184,8 @@ def create_app():
186184
)
187185
}
188186

189-
app.register_blueprint(bp)
190187

188+
def create_app():
189+
app = Quart(__name__)
190+
app.register_blueprint(bp)
191191
return app
192-
193-
if __name__ == "__main__":
194-
app = create_app()
195-
app.run()

app/backend/approaches/__init__.py

Whitespace-only changes.

app/backend/approaches/approach.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
from abc import ABC, abstractmethod
12
from typing import Any
23

34

4-
class Approach:
5-
def run(self, q: str, overrides: dict[str, Any]) -> Any:
6-
raise NotImplementedError
5+
class ChatApproach(ABC):
6+
@abstractmethod
7+
async def run(self, history: list[dict], overrides: dict[str, Any]) -> Any:
8+
...
9+
10+
11+
class AskApproach(ABC):
12+
@abstractmethod
13+
async def run(self, q: str, overrides: dict[str, Any]) -> Any:
14+
...

app/backend/approaches/chatreadretrieveread.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
from typing import Any, Sequence
1+
from typing import Any
22

33
import openai
4-
from azure.search.documents import SearchClient
4+
from azure.search.documents.aio import SearchClient
55
from azure.search.documents.models import QueryType
66

7-
from approaches.approach import Approach
7+
from approaches.approach import ChatApproach
88
from core.messagebuilder import MessageBuilder
99
from core.modelhelper import get_token_limit
1010
from text import nonewlines
1111

1212

13-
class ChatReadRetrieveReadApproach(Approach):
13+
class ChatReadRetrieveReadApproach(ChatApproach):
1414
# Chat roles
1515
SYSTEM = "system"
1616
USER = "user"
@@ -57,7 +57,7 @@ def __init__(self, search_client: SearchClient, chatgpt_deployment: str, chatgpt
5757
self.content_field = content_field
5858
self.chatgpt_token_limit = get_token_limit(chatgpt_model)
5959

60-
def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> Any:
60+
async def run(self, history: list[dict[str, str]], overrides: dict[str, Any]) -> Any:
6161
has_text = overrides.get("retrieval_mode") in ["text", "hybrid", None]
6262
has_vector = overrides.get("retrieval_mode") in ["vectors", "hybrid", None]
6363
use_semantic_captions = True if overrides.get("semantic_captions") and has_text else False
@@ -77,7 +77,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A
7777
self.chatgpt_token_limit - len(user_q)
7878
)
7979

80-
chat_completion = openai.ChatCompletion.create(
80+
chat_completion = await openai.ChatCompletion.acreate(
8181
deployment_id=self.chatgpt_deployment,
8282
model=self.chatgpt_model,
8383
messages=messages,
@@ -93,7 +93,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A
9393

9494
# If retrieval mode includes vectors, compute an embedding for the query
9595
if has_vector:
96-
query_vector = openai.Embedding.create(engine=self.embedding_deployment, input=query_text)["data"][0]["embedding"]
96+
query_vector = (await openai.Embedding.acreate(engine=self.embedding_deployment, input=query_text))["data"][0]["embedding"]
9797
else:
9898
query_vector = None
9999

@@ -103,7 +103,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A
103103

104104
# Use semantic L2 reranker if requested and if retrieval mode is text or hybrid (vectors + text)
105105
if overrides.get("semantic_ranker") and has_text:
106-
r = self.search_client.search(query_text,
106+
r = await self.search_client.search(query_text,
107107
filter=filter,
108108
query_type=QueryType.SEMANTIC,
109109
query_language="en-us",
@@ -115,16 +115,16 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A
115115
top_k=50 if query_vector else None,
116116
vector_fields="embedding" if query_vector else None)
117117
else:
118-
r = self.search_client.search(query_text,
118+
r = await self.search_client.search(query_text,
119119
filter=filter,
120120
top=top,
121121
vector=query_vector,
122122
top_k=50 if query_vector else None,
123123
vector_fields="embedding" if query_vector else None)
124124
if use_semantic_captions:
125-
results = [doc[self.sourcepage_field] + ": " + nonewlines(" . ".join([c.text for c in doc['@search.captions']])) for doc in r]
125+
results = [doc[self.sourcepage_field] + ": " + nonewlines(" . ".join([c.text for c in doc['@search.captions']])) async for doc in r]
126126
else:
127-
results = [doc[self.sourcepage_field] + ": " + nonewlines(doc[self.content_field]) for doc in r]
127+
results = [doc[self.sourcepage_field] + ": " + nonewlines(doc[self.content_field]) async for doc in r]
128128
content = "\n".join(results)
129129

130130
follow_up_questions_prompt = self.follow_up_questions_prompt_content if overrides.get("suggest_followup_questions") else ""
@@ -147,7 +147,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A
147147
history[-1]["user"],
148148
max_tokens=self.chatgpt_token_limit)
149149

150-
chat_completion = openai.ChatCompletion.create(
150+
chat_completion = await openai.ChatCompletion.acreate(
151151
deployment_id=self.chatgpt_deployment,
152152
model=self.chatgpt_model,
153153
messages=messages,
@@ -161,7 +161,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A
161161

162162
return {"data_points": results, "answer": chat_content, "thoughts": f"Searched for:<br>{query_text}<br><br>Conversations:<br>" + msg_to_display.replace('\n', '<br>')}
163163

164-
def get_messages_from_history(self, system_prompt: str, model_id: str, history: Sequence[dict[str, str]], user_conv: str, few_shots = [], max_tokens: int = 4096) -> []:
164+
def get_messages_from_history(self, system_prompt: str, model_id: str, history: list[dict[str, str]], user_conv: str, few_shots = [], max_tokens: int = 4096) -> list:
165165
message_builder = MessageBuilder(system_prompt, model_id)
166166

167167
# Add examples to show the chat what responses we want. It will try to mimic any responses and make sure they match the rules laid out in the system message.
@@ -174,9 +174,10 @@ def get_messages_from_history(self, system_prompt: str, model_id: str, history:
174174
message_builder.append_message(self.USER, user_content, index=append_index)
175175

176176
for h in reversed(history[:-1]):
177-
if h.get("bot"):
178-
message_builder.append_message(self.ASSISTANT, h.get('bot'), index=append_index)
179-
message_builder.append_message(self.USER, h.get('user'), index=append_index)
177+
if bot_msg := h.get("bot"):
178+
message_builder.append_message(self.ASSISTANT, bot_msg, index=append_index)
179+
if user_msg := h.get("user"):
180+
message_builder.append_message(self.USER, user_msg, index=append_index)
180181
if message_builder.token_length > max_tokens:
181182
break
182183

0 commit comments

Comments
 (0)