Skip to content

Commit df48d8c

Browse files
authored
Use an app factory pattern to enable app testing (#495)
* Run ruff, add to precommit * Blueprint * Change to blueprint, app factory * Fixing the imports * Build frontend * Update startup * Update startup * Gunicorn command update * Revert yaml change
1 parent 07a97f3 commit df48d8c

File tree

15 files changed

+267
-89
lines changed

15 files changed

+267
-89
lines changed

.github/workflows/python-test.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,23 @@ jobs:
1414
fail-fast: false
1515
matrix:
1616
os: ["ubuntu-20.04"]
17-
python_version: ["3.8", "3.9", "3.10", "3.11"]
17+
python_version: ["3.9", "3.10", "3.11"]
1818
steps:
1919
- uses: actions/checkout@v3
2020
- name: Setup python
2121
uses: actions/setup-python@v2
2222
with:
2323
python-version: ${{ matrix.python_version }}
2424
architecture: x64
25+
- name: Setup node
26+
uses: actions/setup-node@v2
27+
with:
28+
node-version: 18
29+
- name: Build frontend
30+
run: |
31+
cd ./app/frontend
32+
npm install
33+
npm run build
2534
- name: Install dependencies
2635
run: |
2736
python -m pip install --upgrade pip

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The repo includes sample data so it's ready to try end to end. In this sample ap
2929
#### To Run Locally
3030

3131
* [Azure Developer CLI](https://aka.ms/azure-dev/install)
32-
* [Python 3.8+](https://www.python.org/downloads/)
32+
* [Python 3.9+](https://www.python.org/downloads/)
3333
* **Important**: Python and the pip package manager must be in the path in Windows for the setup scripts to work.
3434
* **Important**: Ensure you can run `python --version` from console. On Ubuntu, you might need to run `sudo apt install python-is-python3` to link `python` to `python3`.
3535
* [Node.js 14+](https://nodejs.org/en/download/)

app/backend/app.py

Lines changed: 138 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,85 +5,67 @@
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 (
12+
Blueprint,
13+
Flask,
14+
abort,
15+
current_app,
16+
jsonify,
17+
request,
18+
send_file,
19+
send_from_directory,
20+
)
21+
822
from approaches.chatreadretrieveread import ChatReadRetrieveReadApproach
923
from approaches.readdecomposeask import ReadDecomposeAsk
1024
from approaches.readretrieveread import ReadRetrieveReadApproach
1125
from approaches.retrievethenread import RetrieveThenReadApproach
12-
from azure.identity import DefaultAzureCredential
13-
from azure.search.documents import SearchClient
14-
from azure.storage.blob import BlobServiceClient
15-
from flask import Flask, abort, jsonify, request, send_file
1626

1727
# Replace these with your own values, either in environment variables or directly here
18-
AZURE_STORAGE_ACCOUNT = os.environ.get("AZURE_STORAGE_ACCOUNT") or "mystorageaccount"
19-
AZURE_STORAGE_CONTAINER = os.environ.get("AZURE_STORAGE_CONTAINER") or "content"
20-
AZURE_SEARCH_SERVICE = os.environ.get("AZURE_SEARCH_SERVICE") or "gptkb"
21-
AZURE_SEARCH_INDEX = os.environ.get("AZURE_SEARCH_INDEX") or "gptkbindex"
22-
AZURE_OPENAI_SERVICE = os.environ.get("AZURE_OPENAI_SERVICE") or "myopenai"
23-
AZURE_OPENAI_GPT_DEPLOYMENT = os.environ.get("AZURE_OPENAI_GPT_DEPLOYMENT") or "davinci"
24-
AZURE_OPENAI_CHATGPT_DEPLOYMENT = os.environ.get("AZURE_OPENAI_CHATGPT_DEPLOYMENT") or "chat"
25-
AZURE_OPENAI_CHATGPT_MODEL = os.environ.get("AZURE_OPENAI_CHATGPT_MODEL") or "gpt-35-turbo"
26-
AZURE_OPENAI_EMB_DEPLOYMENT = os.environ.get("AZURE_OPENAI_EMB_DEPLOYMENT") or "embedding"
27-
28-
KB_FIELDS_CONTENT = os.environ.get("KB_FIELDS_CONTENT") or "content"
29-
KB_FIELDS_CATEGORY = os.environ.get("KB_FIELDS_CATEGORY") or "category"
30-
KB_FIELDS_SOURCEPAGE = os.environ.get("KB_FIELDS_SOURCEPAGE") or "sourcepage"
31-
32-
# Use the current user identity to authenticate with Azure OpenAI, Cognitive Search and Blob Storage (no secrets needed,
33-
# just use 'az login' locally, and managed identity when deployed on Azure). If you need to use keys, use separate AzureKeyCredential instances with the
34-
# keys for each service
35-
# If you encounter a blocking error during a DefaultAzureCredntial resolution, you can exclude the problematic credential by using a parameter (ex. exclude_shared_token_cache_credential=True)
36-
azure_credential = DefaultAzureCredential(exclude_shared_token_cache_credential = True)
37-
38-
# Used by the OpenAI SDK
39-
openai.api_type = "azure"
40-
openai.api_base = f"https://{AZURE_OPENAI_SERVICE}.openai.azure.com"
41-
openai.api_version = "2023-05-15"
42-
43-
# Comment these two lines out if using keys, set your API key in the OPENAI_API_KEY environment variable instead
44-
openai.api_type = "azure_ad"
45-
openai_token = azure_credential.get_token("https://cognitiveservices.azure.com/.default")
46-
openai.api_key = openai_token.token
47-
48-
# Set up clients for Cognitive Search and Storage
49-
search_client = SearchClient(
50-
endpoint=f"https://{AZURE_SEARCH_SERVICE}.search.windows.net",
51-
index_name=AZURE_SEARCH_INDEX,
52-
credential=azure_credential)
53-
blob_client = BlobServiceClient(
54-
account_url=f"https://{AZURE_STORAGE_ACCOUNT}.blob.core.windows.net",
55-
credential=azure_credential)
56-
blob_container = blob_client.get_container_client(AZURE_STORAGE_CONTAINER)
57-
58-
# Various approaches to integrate GPT and external knowledge, most applications will use a single one of these patterns
59-
# or some derivative, here we include several for exploration purposes
60-
ask_approaches = {
61-
"rtr": RetrieveThenReadApproach(search_client, AZURE_OPENAI_CHATGPT_DEPLOYMENT, AZURE_OPENAI_CHATGPT_MODEL, AZURE_OPENAI_EMB_DEPLOYMENT, KB_FIELDS_SOURCEPAGE, KB_FIELDS_CONTENT),
62-
"rrr": ReadRetrieveReadApproach(search_client, AZURE_OPENAI_GPT_DEPLOYMENT, AZURE_OPENAI_EMB_DEPLOYMENT, KB_FIELDS_SOURCEPAGE, KB_FIELDS_CONTENT),
63-
"rda": ReadDecomposeAsk(search_client, AZURE_OPENAI_GPT_DEPLOYMENT, AZURE_OPENAI_EMB_DEPLOYMENT, KB_FIELDS_SOURCEPAGE, KB_FIELDS_CONTENT)
64-
}
65-
66-
chat_approaches = {
67-
"rrr": ChatReadRetrieveReadApproach(search_client,
68-
AZURE_OPENAI_CHATGPT_DEPLOYMENT,
69-
AZURE_OPENAI_CHATGPT_MODEL,
70-
AZURE_OPENAI_EMB_DEPLOYMENT,
71-
KB_FIELDS_SOURCEPAGE,
72-
KB_FIELDS_CONTENT)
73-
}
74-
75-
app = Flask(__name__)
76-
77-
@app.route("/", defaults={"path": "index.html"})
78-
@app.route("/<path:path>")
79-
def static_file(path):
80-
return app.send_static_file(path)
28+
AZURE_STORAGE_ACCOUNT = os.getenv("AZURE_STORAGE_ACCOUNT", "mystorageaccount")
29+
AZURE_STORAGE_CONTAINER = os.getenv("AZURE_STORAGE_CONTAINER", "content")
30+
AZURE_SEARCH_SERVICE = os.getenv("AZURE_SEARCH_SERVICE", "gptkb")
31+
AZURE_SEARCH_INDEX = os.getenv("AZURE_SEARCH_INDEX", "gptkbindex")
32+
AZURE_OPENAI_SERVICE = os.getenv("AZURE_OPENAI_SERVICE", "myopenai")
33+
AZURE_OPENAI_GPT_DEPLOYMENT = os.getenv("AZURE_OPENAI_GPT_DEPLOYMENT", "davinci")
34+
AZURE_OPENAI_CHATGPT_DEPLOYMENT = os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT", "chat")
35+
AZURE_OPENAI_CHATGPT_MODEL = os.getenv("AZURE_OPENAI_CHATGPT_MODEL", "gpt-35-turbo")
36+
AZURE_OPENAI_EMB_DEPLOYMENT = os.getenv("AZURE_OPENAI_EMB_DEPLOYMENT", "embedding")
37+
38+
KB_FIELDS_CONTENT = os.getenv("KB_FIELDS_CONTENT", "content")
39+
KB_FIELDS_CATEGORY = os.getenv("KB_FIELDS_CATEGORY", "category")
40+
KB_FIELDS_SOURCEPAGE = os.getenv("KB_FIELDS_SOURCEPAGE", "sourcepage")
41+
42+
CONFIG_OPENAI_TOKEN = "openai_token"
43+
CONFIG_CREDENTIAL = "azure_credential"
44+
CONFIG_ASK_APPROACHES = "ask_approaches"
45+
CONFIG_CHAT_APPROACHES = "chat_approaches"
46+
CONFIG_BLOB_CLIENT = "blob_client"
47+
48+
49+
bp = Blueprint("routes", __name__, static_folder='static')
50+
51+
@bp.route("/")
52+
def index():
53+
return bp.send_static_file("index.html")
54+
55+
@bp.route("/favicon.ico")
56+
def favicon():
57+
return bp.send_static_file("favicon.ico")
58+
59+
@bp.route("/assets/<path:path>")
60+
def assets(path):
61+
return send_from_directory("static/assets", path)
8162

8263
# Serve content files from blob storage from within the app to keep the example self-contained.
8364
# *** NOTE *** this assumes that the content files are public, or at least that all users of the app
8465
# can access all the files. This is also slow and memory hungry.
85-
@app.route("/content/<path>")
66+
@bp.route("/content/<path>")
8667
def content_file(path):
68+
blob_container = current_app.config[CONFIG_BLOB_CLIENT].get_container_client(AZURE_STORAGE_CONTAINER)
8769
blob = blob_container.get_blob_client(path).download_blob()
8870
if not blob.properties or not blob.properties.has_key("content_settings"):
8971
abort(404)
@@ -95,13 +77,13 @@ def content_file(path):
9577
blob_file.seek(0)
9678
return send_file(blob_file, mimetype=mime_type, as_attachment=False, download_name=path)
9779

98-
@app.route("/ask", methods=["POST"])
80+
@bp.route("/ask", methods=["POST"])
9981
def ask():
100-
if not request.json:
101-
return jsonify({"error": "request must be json"}), 400
82+
if not request.is_json:
83+
return jsonify({"error": "request must be json"}), 415
10284
approach = request.json["approach"]
10385
try:
104-
impl = ask_approaches.get(approach)
86+
impl = current_app.config[CONFIG_ASK_APPROACHES].get(approach)
10587
if not impl:
10688
return jsonify({"error": "unknown approach"}), 400
10789
r = impl.run(request.json["question"], request.json.get("overrides") or {})
@@ -110,13 +92,13 @@ def ask():
11092
logging.exception("Exception in /ask")
11193
return jsonify({"error": str(e)}), 500
11294

113-
@app.route("/chat", methods=["POST"])
95+
@bp.route("/chat", methods=["POST"])
11496
def chat():
115-
if not request.json:
116-
return jsonify({"error": "request must be json"}), 400
97+
if not request.is_json:
98+
return jsonify({"error": "request must be json"}), 415
11799
approach = request.json["approach"]
118100
try:
119-
impl = chat_approaches.get(approach)
101+
impl = current_app.config[CONFIG_CHAT_APPROACHES].get(approach)
120102
if not impl:
121103
return jsonify({"error": "unknown approach"}), 400
122104
r = impl.run(request.json["history"], request.json.get("overrides") or {})
@@ -125,12 +107,89 @@ def chat():
125107
logging.exception("Exception in /chat")
126108
return jsonify({"error": str(e)}), 500
127109

128-
@app.before_request
110+
@bp.before_request
129111
def ensure_openai_token():
130-
global openai_token
112+
openai_token = current_app.config[CONFIG_OPENAI_TOKEN]
131113
if openai_token.expires_on < time.time() + 60:
132-
openai_token = azure_credential.get_token("https://cognitiveservices.azure.com/.default")
114+
openai_token = current_app.config[CONFIG_CREDENTIAL].get_token("https://cognitiveservices.azure.com/.default")
115+
current_app.config[CONFIG_OPENAI_TOKEN] = openai_token
133116
openai.api_key = openai_token.token
134117

118+
119+
def create_app():
120+
app = Flask(__name__)
121+
122+
# Use the current user identity to authenticate with Azure OpenAI, Cognitive Search and Blob Storage (no secrets needed,
123+
# just use 'az login' locally, and managed identity when deployed on Azure). If you need to use keys, use separate AzureKeyCredential instances with the
124+
# keys for each service
125+
# If you encounter a blocking error during a DefaultAzureCredntial resolution, you can exclude the problematic credential by using a parameter (ex. exclude_shared_token_cache_credential=True)
126+
azure_credential = DefaultAzureCredential(exclude_shared_token_cache_credential = True)
127+
128+
# Set up clients for Cognitive Search and Storage
129+
search_client = SearchClient(
130+
endpoint=f"https://{AZURE_SEARCH_SERVICE}.search.windows.net",
131+
index_name=AZURE_SEARCH_INDEX,
132+
credential=azure_credential)
133+
blob_client = BlobServiceClient(
134+
account_url=f"https://{AZURE_STORAGE_ACCOUNT}.blob.core.windows.net",
135+
credential=azure_credential)
136+
137+
# Used by the OpenAI SDK
138+
openai.api_type = "azure"
139+
openai.api_base = f"https://{AZURE_OPENAI_SERVICE}.openai.azure.com"
140+
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
143+
openai.api_type = "azure_ad"
144+
openai_token = azure_credential.get_token(
145+
"https://cognitiveservices.azure.com/.default"
146+
)
147+
openai.api_key = openai_token.token
148+
149+
# 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
153+
# Various approaches to integrate GPT and external knowledge, most applications will use a single one of these patterns
154+
# or some derivative, here we include several for exploration purposes
155+
app.config[CONFIG_ASK_APPROACHES] = {
156+
"rtr": RetrieveThenReadApproach(
157+
search_client,
158+
AZURE_OPENAI_CHATGPT_DEPLOYMENT,
159+
AZURE_OPENAI_CHATGPT_MODEL,
160+
AZURE_OPENAI_EMB_DEPLOYMENT,
161+
KB_FIELDS_SOURCEPAGE,
162+
KB_FIELDS_CONTENT
163+
),
164+
"rrr": ReadRetrieveReadApproach(
165+
search_client,
166+
AZURE_OPENAI_GPT_DEPLOYMENT,
167+
AZURE_OPENAI_EMB_DEPLOYMENT,
168+
KB_FIELDS_SOURCEPAGE,
169+
KB_FIELDS_CONTENT
170+
),
171+
"rda": ReadDecomposeAsk(search_client,
172+
AZURE_OPENAI_GPT_DEPLOYMENT,
173+
AZURE_OPENAI_EMB_DEPLOYMENT,
174+
KB_FIELDS_SOURCEPAGE,
175+
KB_FIELDS_CONTENT
176+
)
177+
}
178+
app.config[CONFIG_CHAT_APPROACHES] = {
179+
"rrr": ChatReadRetrieveReadApproach(
180+
search_client,
181+
AZURE_OPENAI_CHATGPT_DEPLOYMENT,
182+
AZURE_OPENAI_CHATGPT_MODEL,
183+
AZURE_OPENAI_EMB_DEPLOYMENT,
184+
KB_FIELDS_SOURCEPAGE,
185+
KB_FIELDS_CONTENT,
186+
)
187+
}
188+
189+
app.register_blueprint(bp)
190+
191+
return app
192+
135193
if __name__ == "__main__":
194+
app = create_app()
136195
app.run()

app/backend/approaches/chatreadretrieveread.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from typing import Any, Sequence
22

33
import openai
4-
from approaches.approach import Approach
54
from azure.search.documents import SearchClient
65
from azure.search.documents.models import QueryType
6+
7+
from approaches.approach import Approach
78
from core.messagebuilder import MessageBuilder
89
from core.modelhelper import get_token_limit
910
from text import nonewlines

app/backend/approaches/readdecomposeask.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
from typing import Any, List, Optional
33

44
import openai
5-
from approaches.approach import Approach
65
from azure.search.documents import SearchClient
76
from azure.search.documents.models import QueryType
87
from langchain.agents import AgentExecutor, Tool
98
from langchain.agents.react.base import ReActDocstoreAgent
109
from langchain.callbacks.manager import CallbackManager
1110
from langchain.llms.openai import AzureOpenAI
1211
from langchain.prompts import BasePromptTemplate, PromptTemplate
12+
13+
from approaches.approach import Approach
1314
from langchainadapters import HtmlCallbackHandler
1415
from text import nonewlines
1516

app/backend/approaches/readretrieveread.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from typing import Any
22

33
import openai
4-
from approaches.approach import Approach
54
from azure.search.documents import SearchClient
65
from azure.search.documents.models import QueryType
76
from langchain.agents import AgentExecutor, Tool, ZeroShotAgent
87
from langchain.callbacks.manager import CallbackManager, Callbacks
98
from langchain.chains import LLMChain
109
from langchain.llms.openai import AzureOpenAI
10+
11+
from approaches.approach import Approach
1112
from langchainadapters import HtmlCallbackHandler
1213
from lookuptool import CsvLookupTool
1314
from text import nonewlines

app/backend/approaches/retrievethenread.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from typing import Any
22

33
import openai
4-
from approaches.approach import Approach
54
from azure.search.documents import SearchClient
65
from azure.search.documents.models import QueryType
6+
7+
from approaches.approach import Approach
78
from core.messagebuilder import MessageBuilder
89
from text import nonewlines
910

app/backend/gunicorn.conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
workers = (num_cpus * 2) + 1
1010
threads = 1 if num_cpus == 1 else 2
1111
timeout = 600
12+
worker_class = "gthread"

app/backend/start_appservice.sh

Lines changed: 0 additions & 1 deletion
This file was deleted.

infra/main.bicep

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ module backend 'core/host/appservice.bicep' = {
111111
appServicePlanId: appServicePlan.outputs.id
112112
runtimeName: 'python'
113113
runtimeVersion: '3.10'
114-
appCommandLine: 'start_appservice.sh'
114+
appCommandLine: 'python3 -m gunicorn "app:create_app()"'
115115
scmDoBuildDuringDeployment: true
116116
managedIdentity: true
117117
appSettings: {

0 commit comments

Comments
 (0)