Skip to content

Commit c3810e8

Browse files
fujita-hpamelafox
andauthored
Feature: Store chat history in Cosmos DB (#2063)
* Add a Cosmos DB resource for chat history * Added Cosmos DB chat history feature to the backend * Added Cosmos DB chat history feature to the frontend * fix typo * Reconfigure the additional API endpoints in a separate blueprint * Refactor CosmosDB integration for chat history feature Use the timestamp property instead of _ts * Update docs * Change cast to type annotation, add initial test * Use authenticated decorators properly * Add more unit tests for CosmosDB * Remove unreachable code path, change 200 to 204 for delete, more tests * Address mypy issues * Update auth test with a session ID * Delete volatile property from snapshot --------- Co-authored-by: Pamela Fox <[email protected]>
1 parent 009d5e1 commit c3810e8

File tree

30 files changed

+984
-35
lines changed

30 files changed

+984
-35
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ However, you can try the [Azure pricing calculator](https://azure.com/e/a87a169b
9292
- Azure AI Document Intelligence: SO (Standard) tier using pre-built layout. Pricing per document page, sample documents have 261 pages total. [Pricing](https://azure.microsoft.com/pricing/details/form-recognizer/)
9393
- Azure AI Search: Basic tier, 1 replica, free level of semantic search. Pricing per hour. [Pricing](https://azure.microsoft.com/pricing/details/search/)
9494
- Azure Blob Storage: Standard tier with ZRS (Zone-redundant storage). Pricing per storage and read operations. [Pricing](https://azure.microsoft.com/pricing/details/storage/blobs/)
95+
- Azure Cosmos DB: Serverless tier. Pricing per request unit and storage. [Pricing](https://azure.microsoft.com/pricing/details/cosmos-db/)
9596
- Azure Monitor: Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/)
9697

9798
To reduce costs, you can switch to free SKUs for various services, but those SKUs have limitations.

app/backend/app.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,15 @@
5353
from approaches.chatreadretrievereadvision import ChatReadRetrieveReadVisionApproach
5454
from approaches.retrievethenread import RetrieveThenReadApproach
5555
from approaches.retrievethenreadvision import RetrieveThenReadVisionApproach
56+
from chat_history.cosmosdb import chat_history_cosmosdb_bp
5657
from config import (
5758
CONFIG_ASK_APPROACH,
5859
CONFIG_ASK_VISION_APPROACH,
5960
CONFIG_AUTH_CLIENT,
6061
CONFIG_BLOB_CONTAINER_CLIENT,
6162
CONFIG_CHAT_APPROACH,
6263
CONFIG_CHAT_HISTORY_BROWSER_ENABLED,
64+
CONFIG_CHAT_HISTORY_COSMOS_ENABLED,
6365
CONFIG_CHAT_VISION_APPROACH,
6466
CONFIG_CREDENTIAL,
6567
CONFIG_GPT4V_DEPLOYED,
@@ -224,7 +226,10 @@ async def chat(auth_claims: Dict[str, Any]):
224226
# else creates a new session_id depending on the chat history options enabled.
225227
session_state = request_json.get("session_state")
226228
if session_state is None:
227-
session_state = create_session_id(current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED])
229+
session_state = create_session_id(
230+
current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED],
231+
current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED],
232+
)
228233
result = await approach.run(
229234
request_json["messages"],
230235
context=context,
@@ -255,7 +260,10 @@ async def chat_stream(auth_claims: Dict[str, Any]):
255260
# else creates a new session_id depending on the chat history options enabled.
256261
session_state = request_json.get("session_state")
257262
if session_state is None:
258-
session_state = create_session_id(current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED])
263+
session_state = create_session_id(
264+
current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED],
265+
current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED],
266+
)
259267
result = await approach.run_stream(
260268
request_json["messages"],
261269
context=context,
@@ -289,6 +297,7 @@ def config():
289297
"showSpeechOutputBrowser": current_app.config[CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED],
290298
"showSpeechOutputAzure": current_app.config[CONFIG_SPEECH_OUTPUT_AZURE_ENABLED],
291299
"showChatHistoryBrowser": current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED],
300+
"showChatHistoryCosmos": current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED],
292301
}
293302
)
294303

@@ -455,6 +464,7 @@ async def setup_clients():
455464
USE_SPEECH_OUTPUT_BROWSER = os.getenv("USE_SPEECH_OUTPUT_BROWSER", "").lower() == "true"
456465
USE_SPEECH_OUTPUT_AZURE = os.getenv("USE_SPEECH_OUTPUT_AZURE", "").lower() == "true"
457466
USE_CHAT_HISTORY_BROWSER = os.getenv("USE_CHAT_HISTORY_BROWSER", "").lower() == "true"
467+
USE_CHAT_HISTORY_COSMOS = os.getenv("USE_CHAT_HISTORY_COSMOS", "").lower() == "true"
458468

459469
# WEBSITE_HOSTNAME is always set by App Service, RUNNING_IN_PRODUCTION is set in main.bicep
460470
RUNNING_ON_AZURE = os.getenv("WEBSITE_HOSTNAME") is not None or os.getenv("RUNNING_IN_PRODUCTION") is not None
@@ -484,6 +494,9 @@ async def setup_clients():
484494
current_app.logger.info("Setting up Azure credential using AzureDeveloperCliCredential for home tenant")
485495
azure_credential = AzureDeveloperCliCredential(process_timeout=60)
486496

497+
# Set the Azure credential in the app config for use in other parts of the app
498+
current_app.config[CONFIG_CREDENTIAL] = azure_credential
499+
487500
# Set up clients for AI Search and Storage
488501
search_client = SearchClient(
489502
endpoint=f"https://{AZURE_SEARCH_SERVICE}.search.windows.net",
@@ -573,7 +586,6 @@ async def setup_clients():
573586
current_app.config[CONFIG_SPEECH_SERVICE_VOICE] = AZURE_SPEECH_SERVICE_VOICE
574587
# Wait until token is needed to fetch for the first time
575588
current_app.config[CONFIG_SPEECH_SERVICE_TOKEN] = None
576-
current_app.config[CONFIG_CREDENTIAL] = azure_credential
577589

578590
if OPENAI_HOST.startswith("azure"):
579591
if OPENAI_HOST == "azure_custom":
@@ -628,6 +640,7 @@ async def setup_clients():
628640
current_app.config[CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED] = USE_SPEECH_OUTPUT_BROWSER
629641
current_app.config[CONFIG_SPEECH_OUTPUT_AZURE_ENABLED] = USE_SPEECH_OUTPUT_AZURE
630642
current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED] = USE_CHAT_HISTORY_BROWSER
643+
current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED] = USE_CHAT_HISTORY_COSMOS
631644

632645
# Various approaches to integrate GPT and external knowledge, most applications will use a single one of these patterns
633646
# or some derivative, here we include several for exploration purposes
@@ -717,6 +730,7 @@ async def close_clients():
717730
def create_app():
718731
app = Quart(__name__)
719732
app.register_blueprint(bp)
733+
app.register_blueprint(chat_history_cosmosdb_bp)
720734

721735
if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"):
722736
app.logger.info("APPLICATIONINSIGHTS_CONNECTION_STRING is set, enabling Azure Monitor")

app/backend/chat_history/__init__.py

Whitespace-only changes.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import os
2+
import time
3+
from typing import Any, Dict, Union
4+
5+
from azure.cosmos.aio import ContainerProxy, CosmosClient
6+
from azure.identity.aio import AzureDeveloperCliCredential, ManagedIdentityCredential
7+
from quart import Blueprint, current_app, jsonify, request
8+
9+
from config import (
10+
CONFIG_CHAT_HISTORY_COSMOS_ENABLED,
11+
CONFIG_COSMOS_HISTORY_CLIENT,
12+
CONFIG_COSMOS_HISTORY_CONTAINER,
13+
CONFIG_CREDENTIAL,
14+
)
15+
from decorators import authenticated
16+
from error import error_response
17+
18+
chat_history_cosmosdb_bp = Blueprint("chat_history_cosmos", __name__, static_folder="static")
19+
20+
21+
@chat_history_cosmosdb_bp.post("/chat_history")
22+
@authenticated
23+
async def post_chat_history(auth_claims: Dict[str, Any]):
24+
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
25+
return jsonify({"error": "Chat history not enabled"}), 400
26+
27+
container: ContainerProxy = current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER]
28+
if not container:
29+
return jsonify({"error": "Chat history not enabled"}), 400
30+
31+
entra_oid = auth_claims.get("oid")
32+
if not entra_oid:
33+
return jsonify({"error": "User OID not found"}), 401
34+
35+
try:
36+
request_json = await request.get_json()
37+
id = request_json.get("id")
38+
answers = request_json.get("answers")
39+
title = answers[0][0][:50] + "..." if len(answers[0][0]) > 50 else answers[0][0]
40+
timestamp = int(time.time() * 1000)
41+
42+
await container.upsert_item(
43+
{"id": id, "entra_oid": entra_oid, "title": title, "answers": answers, "timestamp": timestamp}
44+
)
45+
46+
return jsonify({}), 201
47+
except Exception as error:
48+
return error_response(error, "/chat_history")
49+
50+
51+
@chat_history_cosmosdb_bp.post("/chat_history/items")
52+
@authenticated
53+
async def get_chat_history(auth_claims: Dict[str, Any]):
54+
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
55+
return jsonify({"error": "Chat history not enabled"}), 400
56+
57+
container: ContainerProxy = current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER]
58+
if not container:
59+
return jsonify({"error": "Chat history not enabled"}), 400
60+
61+
entra_oid = auth_claims.get("oid")
62+
if not entra_oid:
63+
return jsonify({"error": "User OID not found"}), 401
64+
65+
try:
66+
request_json = await request.get_json()
67+
count = request_json.get("count", 20)
68+
continuation_token = request_json.get("continuation_token")
69+
70+
res = container.query_items(
71+
query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.entra_oid = @entra_oid ORDER BY c.timestamp DESC",
72+
parameters=[dict(name="@entra_oid", value=entra_oid)],
73+
max_item_count=count,
74+
)
75+
76+
# set the continuation token for the next page
77+
pager = res.by_page(continuation_token)
78+
79+
# Get the first page, and the continuation token
80+
try:
81+
page = await pager.__anext__()
82+
continuation_token = pager.continuation_token # type: ignore
83+
84+
items = []
85+
async for item in page:
86+
items.append(
87+
{
88+
"id": item.get("id"),
89+
"entra_oid": item.get("entra_oid"),
90+
"title": item.get("title", "untitled"),
91+
"timestamp": item.get("timestamp"),
92+
}
93+
)
94+
95+
# If there are no more pages, StopAsyncIteration is raised
96+
except StopAsyncIteration:
97+
items = []
98+
continuation_token = None
99+
100+
return jsonify({"items": items, "continuation_token": continuation_token}), 200
101+
102+
except Exception as error:
103+
return error_response(error, "/chat_history/items")
104+
105+
106+
@chat_history_cosmosdb_bp.get("/chat_history/items/<item_id>")
107+
@authenticated
108+
async def get_chat_history_session(auth_claims: Dict[str, Any], item_id: str):
109+
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
110+
return jsonify({"error": "Chat history not enabled"}), 400
111+
112+
container: ContainerProxy = current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER]
113+
if not container:
114+
return jsonify({"error": "Chat history not enabled"}), 400
115+
116+
entra_oid = auth_claims.get("oid")
117+
if not entra_oid:
118+
return jsonify({"error": "User OID not found"}), 401
119+
120+
try:
121+
res = await container.read_item(item=item_id, partition_key=entra_oid)
122+
return (
123+
jsonify(
124+
{
125+
"id": res.get("id"),
126+
"entra_oid": res.get("entra_oid"),
127+
"title": res.get("title", "untitled"),
128+
"timestamp": res.get("timestamp"),
129+
"answers": res.get("answers", []),
130+
}
131+
),
132+
200,
133+
)
134+
except Exception as error:
135+
return error_response(error, f"/chat_history/items/{item_id}")
136+
137+
138+
@chat_history_cosmosdb_bp.delete("/chat_history/items/<item_id>")
139+
@authenticated
140+
async def delete_chat_history_session(auth_claims: Dict[str, Any], item_id: str):
141+
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
142+
return jsonify({"error": "Chat history not enabled"}), 400
143+
144+
container: ContainerProxy = current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER]
145+
if not container:
146+
return jsonify({"error": "Chat history not enabled"}), 400
147+
148+
entra_oid = auth_claims.get("oid")
149+
if not entra_oid:
150+
return jsonify({"error": "User OID not found"}), 401
151+
152+
try:
153+
await container.delete_item(item=item_id, partition_key=entra_oid)
154+
return jsonify({}), 204
155+
except Exception as error:
156+
return error_response(error, f"/chat_history/items/{item_id}")
157+
158+
159+
@chat_history_cosmosdb_bp.before_app_serving
160+
async def setup_clients():
161+
USE_CHAT_HISTORY_COSMOS = os.getenv("USE_CHAT_HISTORY_COSMOS", "").lower() == "true"
162+
AZURE_COSMOSDB_ACCOUNT = os.getenv("AZURE_COSMOSDB_ACCOUNT")
163+
AZURE_CHAT_HISTORY_DATABASE = os.getenv("AZURE_CHAT_HISTORY_DATABASE")
164+
AZURE_CHAT_HISTORY_CONTAINER = os.getenv("AZURE_CHAT_HISTORY_CONTAINER")
165+
166+
azure_credential: Union[AzureDeveloperCliCredential, ManagedIdentityCredential] = current_app.config[
167+
CONFIG_CREDENTIAL
168+
]
169+
170+
if USE_CHAT_HISTORY_COSMOS:
171+
current_app.logger.info("USE_CHAT_HISTORY_COSMOS is true, setting up CosmosDB client")
172+
if not AZURE_COSMOSDB_ACCOUNT:
173+
raise ValueError("AZURE_COSMOSDB_ACCOUNT must be set when USE_CHAT_HISTORY_COSMOS is true")
174+
if not AZURE_CHAT_HISTORY_DATABASE:
175+
raise ValueError("AZURE_CHAT_HISTORY_DATABASE must be set when USE_CHAT_HISTORY_COSMOS is true")
176+
if not AZURE_CHAT_HISTORY_CONTAINER:
177+
raise ValueError("AZURE_CHAT_HISTORY_CONTAINER must be set when USE_CHAT_HISTORY_COSMOS is true")
178+
cosmos_client = CosmosClient(
179+
url=f"https://{AZURE_COSMOSDB_ACCOUNT}.documents.azure.com:443/", credential=azure_credential
180+
)
181+
cosmos_db = cosmos_client.get_database_client(AZURE_CHAT_HISTORY_DATABASE)
182+
cosmos_container = cosmos_db.get_container_client(AZURE_CHAT_HISTORY_CONTAINER)
183+
184+
current_app.config[CONFIG_COSMOS_HISTORY_CLIENT] = cosmos_client
185+
current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER] = cosmos_container
186+
187+
188+
@chat_history_cosmosdb_bp.after_app_serving
189+
async def close_clients():
190+
if current_app.config.get(CONFIG_COSMOS_HISTORY_CLIENT):
191+
cosmos_client: CosmosClient = current_app.config[CONFIG_COSMOS_HISTORY_CLIENT]
192+
await cosmos_client.close()

app/backend/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@
2323
CONFIG_SPEECH_SERVICE_TOKEN = "speech_service_token"
2424
CONFIG_SPEECH_SERVICE_VOICE = "speech_service_voice"
2525
CONFIG_CHAT_HISTORY_BROWSER_ENABLED = "chat_history_browser_enabled"
26+
CONFIG_CHAT_HISTORY_COSMOS_ENABLED = "chat_history_cosmos_enabled"
27+
CONFIG_COSMOS_HISTORY_CLIENT = "cosmos_history_client"
28+
CONFIG_COSMOS_HISTORY_CONTAINER = "cosmos_history_container"

app/backend/core/sessionhelper.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
from typing import Union
33

44

5-
def create_session_id(config_chat_history_browser_enabled: bool) -> Union[str, None]:
5+
def create_session_id(
6+
config_chat_history_cosmos_enabled: bool, config_chat_history_browser_enabled: bool
7+
) -> Union[str, None]:
8+
if config_chat_history_cosmos_enabled:
9+
return str(uuid.uuid4())
610
if config_chat_history_browser_enabled:
711
return str(uuid.uuid4())
812
return None

app/backend/decorators.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from functools import wraps
3-
from typing import Any, Callable, Dict
3+
from typing import Any, Callable, Dict, TypeVar, cast
44

55
from quart import abort, current_app, request
66

@@ -37,19 +37,22 @@ async def auth_handler(path=""):
3737
return auth_handler
3838

3939

40-
def authenticated(route_fn: Callable[[Dict[str, Any]], Any]):
40+
_C = TypeVar("_C", bound=Callable[..., Any])
41+
42+
43+
def authenticated(route_fn: _C) -> _C:
4144
"""
4245
Decorator for routes that might require access control. Unpacks Authorization header information into an auth_claims dictionary
4346
"""
4447

4548
@wraps(route_fn)
46-
async def auth_handler():
49+
async def auth_handler(*args, **kwargs):
4750
auth_helper = current_app.config[CONFIG_AUTH_CLIENT]
4851
try:
4952
auth_claims = await auth_helper.get_auth_claims_if_enabled(request.headers)
5053
except AuthError:
5154
abort(403)
5255

53-
return await route_fn(auth_claims)
56+
return await route_fn(auth_claims, *args, **kwargs)
5457

55-
return auth_handler
58+
return cast(_C, auth_handler)

app/backend/requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ tiktoken
77
tenacity
88
azure-ai-documentintelligence
99
azure-cognitiveservices-speech
10+
azure-cosmos
1011
azure-search-documents==11.6.0b6
1112
azure-storage-blob
1213
azure-storage-file-datalake

app/backend/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ azure-core==1.30.2
3434
# via
3535
# azure-ai-documentintelligence
3636
# azure-core-tracing-opentelemetry
37+
# azure-cosmos
3738
# azure-identity
3839
# azure-monitor-opentelemetry
3940
# azure-monitor-opentelemetry-exporter
@@ -44,6 +45,8 @@ azure-core==1.30.2
4445
# msrest
4546
azure-core-tracing-opentelemetry==1.0.0b11
4647
# via azure-monitor-opentelemetry
48+
azure-cosmos==4.7.0
49+
# via -r requirements.in
4750
azure-identity==1.17.1
4851
# via
4952
# -r requirements.in
@@ -402,6 +405,7 @@ typing-extensions==4.12.2
402405
# via
403406
# azure-ai-documentintelligence
404407
# azure-core
408+
# azure-cosmos
405409
# azure-identity
406410
# azure-search-documents
407411
# azure-storage-blob

0 commit comments

Comments
 (0)