From 6fac970ebc41490ac036834acb51ba465ee8e4a7 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Tue, 7 Jan 2025 12:47:05 -0800 Subject: [PATCH 01/13] Configure Azure Developer Pipeline From fbb51357d6bff9573f2c74a3f033c53cb0ddf90d Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 9 Jan 2025 09:10:00 -0800 Subject: [PATCH 02/13] CosmosDB v2 changes --- app/backend/chat_history/cosmosdb.py | 30 +++++++++++++++++-- .../components/LoginButton/LoginButton.tsx | 1 + infra/main.bicep | 15 +++++----- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index 49760970f7..f112343dcf 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -39,10 +39,33 @@ async def post_chat_history(auth_claims: Dict[str, Any]): title = answers[0][0][:50] + "..." if len(answers[0][0]) > 50 else answers[0][0] timestamp = int(time.time() * 1000) + # Insert the session item: await container.upsert_item( - {"id": id, "entra_oid": entra_oid, "title": title, "answers": answers, "timestamp": timestamp} + { + "id": id, + "session_id": id, + "entra_oid": entra_oid, + "type": "session", + "title": title, + "timestamp": timestamp, + } ) + # Now insert a message item for each question/response pair: + for ind, message_pair in enumerate(zip(answers[::2], answers[1::2])): + # TODO: Can I do a batch upsert? + await container.upsert_item( + { + "id": f"{id}-{ind}", + "session_id": id, + "entra_oid": entra_oid, + "type": "message", + "question": message_pair[0], + "response": message_pair[1], + "timestamp": timestamp, + } + ) + return jsonify({}), 201 except Exception as error: return error_response(error, "/chat_history") @@ -68,8 +91,9 @@ async def get_chat_history(auth_claims: Dict[str, Any]): continuation_token = request_json.get("continuation_token") res = container.query_items( - query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.entra_oid = @entra_oid ORDER BY c.timestamp DESC", - parameters=[dict(name="@entra_oid", value=entra_oid)], + query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.entra_oid = @entra_oid AND c.type = @type ORDER BY c.timestamp DESC", + parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@type", value="session")], + partition_key=entra_oid, max_item_count=count, ) diff --git a/app/frontend/src/components/LoginButton/LoginButton.tsx b/app/frontend/src/components/LoginButton/LoginButton.tsx index 3d8c64323a..c9889f9c3d 100644 --- a/app/frontend/src/components/LoginButton/LoginButton.tsx +++ b/app/frontend/src/components/LoginButton/LoginButton.tsx @@ -35,6 +35,7 @@ export const LoginButton = () => { }) .catch(error => console.log(error)) .then(async () => { + debugger; setLoggedIn(await checkLoggedIn(instance)); setUsername((await getUsername(instance)) ?? ""); }); diff --git a/infra/main.bicep b/infra/main.bicep index 5c181cd525..ae0eb8a7e1 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -69,7 +69,7 @@ param cosmosDbLocation string = '' param cosmosDbAccountName string = '' param cosmosDbThroughput int = 400 param chatHistoryDatabaseName string = 'chat-database' -param chatHistoryContainerName string = 'chat-history' +param chatHistoryContainerName string = 'chat-history-container' // https://learn.microsoft.com/azure/ai-services/openai/concepts/models?tabs=python-secure%2Cstandard%2Cstandard-chat-completions#standard-deployment-model-availability @description('Location for the OpenAI resource group') @@ -800,24 +800,25 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.6.1' = if (use name: chatHistoryContainerName paths: [ '/entra_oid' + '/session_id' ] indexingPolicy: { indexingMode: 'consistent' automatic: true includedPaths: [ { - path: '/*' + path: '/entra_oid/?' } - ] - excludedPaths: [ { - path: '/title/?' + path: '/session_id/?' } { - path: '/answers/*' + path: '/timestamp/?' } + ] + excludedPaths: [ { - path: '/"_etag"/?' + path: '/*' } ] } From 28848fd6b7e1242be32dec3db49062c3eeba0673 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 23 Jan 2025 13:42:19 -0800 Subject: [PATCH 03/13] CosmosDB progress --- app/backend/chat_history/cosmosdb.py | 91 +++++++++++++++++++++------- app/frontend/src/api/api.ts | 13 ++-- infra/main.bicep | 3 + 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index f112343dcf..07bac66d1a 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -1,8 +1,11 @@ +import logging import os import time from typing import Any, Dict, Union +from azure.cosmos import exceptions from azure.cosmos.aio import ContainerProxy, CosmosClient +from azure.cosmos.partition_key import PartitionKey from azure.identity.aio import AzureDeveloperCliCredential, ManagedIdentityCredential from quart import Blueprint, current_app, jsonify, request @@ -15,9 +18,19 @@ from decorators import authenticated from error import error_response +logger = logging.getLogger("scripts") + chat_history_cosmosdb_bp = Blueprint("chat_history_cosmos", __name__, static_folder="static") +def make_partition_key(entra_id, session_id=None): + if entra_id and session_id: + # Need multihash for hierachical partitioning + return PartitionKey(path=["/entra_id", "/session_id"], kind="MultiHash") + else: + return PartitionKey(path="/entra_id") + + @chat_history_cosmosdb_bp.post("/chat_history") @authenticated async def post_chat_history(auth_claims: Dict[str, Any]): @@ -34,44 +47,69 @@ async def post_chat_history(auth_claims: Dict[str, Any]): try: request_json = await request.get_json() - id = request_json.get("id") + session_id = request_json.get("id") answers = request_json.get("answers") title = answers[0][0][:50] + "..." if len(answers[0][0]) > 50 else answers[0][0] timestamp = int(time.time() * 1000) # Insert the session item: - await container.upsert_item( - { - "id": id, - "session_id": id, - "entra_oid": entra_oid, - "type": "session", - "title": title, - "timestamp": timestamp, - } - ) - + session_item = { + "id": id, + "session_id": session_id, + "entra_oid": entra_oid, + "type": "session", + "title": title, + "timestamp": timestamp, + } + + message_items = [] # Now insert a message item for each question/response pair: for ind, message_pair in enumerate(zip(answers[::2], answers[1::2])): - # TODO: Can I do a batch upsert? - await container.upsert_item( + # The id: what if you delete a message and then add a new one? The id will be the same. + # If we had delete mechanism, and you deleted item 5 in a history, then item 6 would still hang around + # and youd have two of item 6. + # abc-0 + # abc-1 + # abc-2 <-- DELETE + # abc-3 + # One approach would be to delete EVERYTHING, then upsert everything. + # Another approach would be to delete item plus everything after, then upsert everything after. + # Or: Change the frontend? + # We can do this first, and change the frontend after + message_items.append( { - "id": f"{id}-{ind}", + "id": f"{session_id}-{ind}", "session_id": id, "entra_oid": entra_oid, "type": "message", "question": message_pair[0], "response": message_pair[1], - "timestamp": timestamp, + "timestamp": timestamp, # <-- This is the timestamp of the session, not the message } ) + batch_operations = [("upsert", tuple([session_item] + message_items), {})] + + try: + # Run that list of operations + batch_results = container.execute_item_batch( + batch_operations=batch_operations, partition_key=make_partition_key(entra_oid, session_id) + ) + # Batch results are returned as a list of item operation results - or raise a CosmosBatchOperationError if + # one of the operations failed within your batch request. + print(f"\nResults for the batch operations: {batch_results}\n") + except exceptions.CosmosBatchOperationError as e: + error_operation_index = e.error_index + error_operation_response = e.operation_responses[error_operation_index] + error_operation = batch_operations[error_operation_index] + logger.error(f"Batch operation failed: {error_operation_response} for operation {error_operation}") + return jsonify({"error": "Batch operation failed"}), 400 return jsonify({}), 201 except Exception as error: return error_response(error, "/chat_history") -@chat_history_cosmosdb_bp.post("/chat_history/items") +@chat_history_cosmosdb_bp.get("/chat_history/items") @authenticated async def get_chat_history(auth_claims: Dict[str, Any]): if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]: @@ -86,14 +124,15 @@ async def get_chat_history(auth_claims: Dict[str, Any]): return jsonify({"error": "User OID not found"}), 401 try: - request_json = await request.get_json() - count = request_json.get("count", 20) - continuation_token = request_json.get("continuation_token") + # get the count and continuation token from the request URL + count = request.args.get("count", 10) + continuation_token = request.args.get("continuation_token") res = container.query_items( + # TODO: do we need distinct? per Mark's code - Mark says no! query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.entra_oid = @entra_oid AND c.type = @type ORDER BY c.timestamp DESC", parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@type", value="session")], - partition_key=entra_oid, + partition_key=make_partition_key(entra_oid), max_item_count=count, ) @@ -142,6 +181,14 @@ async def get_chat_history_session(auth_claims: Dict[str, Any], item_id: str): return jsonify({"error": "User OID not found"}), 401 try: + res = container.query_items( + # TODO: do we need distinct? per Mark's code + query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.session_id = @session_id ORDER BY c.timestamp DESC", + parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@session_id", value=item_id)], + partition_key=make_partition_key(entra_oid, item_id), + # max_item_count=? + ) + res = await container.read_item(item=item_id, partition_key=entra_oid) return ( jsonify( @@ -175,6 +222,8 @@ async def delete_chat_history_session(auth_claims: Dict[str, Any], item_id: str) try: await container.delete_item(item=item_id, partition_key=entra_oid) + # Delete session, and all the message items associated with it + # TODO: Delete all the message items as well return jsonify({}), 204 except Exception as error: return error_response(error, f"/chat_history/items/{item_id}") diff --git a/app/frontend/src/api/api.ts b/app/frontend/src/api/api.ts index 76636d4d05..8711ff0848 100644 --- a/app/frontend/src/api/api.ts +++ b/app/frontend/src/api/api.ts @@ -145,10 +145,15 @@ export async function postChatHistoryApi(item: any, idToken: string): Promise { const headers = await getHeaders(idToken); - const response = await fetch("/chat_history/items", { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify({ count: count, continuation_token: continuationToken }) + const url = new URL("/chat_history/items", BACKEND_URI); + url.searchParams.append("count", count.toString()); + if (continuationToken) { + url.searchParams.append("continuationToken", continuationToken); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { ...headers, "Content-Type": "application/json" } }); if (!response.ok) { diff --git a/infra/main.bicep b/infra/main.bicep index ae0eb8a7e1..da162ea115 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -815,6 +815,9 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.6.1' = if (use { path: '/timestamp/?' } + { + path: '/type/?' + } ] excludedPaths: [ { From 4681f800135ab2d45c836e43a3fed34daec8160e Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Mon, 27 Jan 2025 12:16:38 -0800 Subject: [PATCH 04/13] Fix CosmosDB API --- app/backend/chat_history/cosmosdb.py | 127 ++++++++---------- app/frontend/src/api/api.ts | 12 +- app/frontend/src/api/models.ts | 2 +- .../components/HistoryProviders/CosmosDB.ts | 8 +- infra/main.bicep | 1 + 5 files changed, 68 insertions(+), 82 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index 07bac66d1a..fca1384834 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -5,9 +5,8 @@ from azure.cosmos import exceptions from azure.cosmos.aio import ContainerProxy, CosmosClient -from azure.cosmos.partition_key import PartitionKey from azure.identity.aio import AzureDeveloperCliCredential, ManagedIdentityCredential -from quart import Blueprint, current_app, jsonify, request +from quart import Blueprint, current_app, jsonify, make_response, request from config import ( CONFIG_CHAT_HISTORY_COSMOS_ENABLED, @@ -23,14 +22,6 @@ chat_history_cosmosdb_bp = Blueprint("chat_history_cosmos", __name__, static_folder="static") -def make_partition_key(entra_id, session_id=None): - if entra_id and session_id: - # Need multihash for hierachical partitioning - return PartitionKey(path=["/entra_id", "/session_id"], kind="MultiHash") - else: - return PartitionKey(path="/entra_id") - - @chat_history_cosmosdb_bp.post("/chat_history") @authenticated async def post_chat_history(auth_claims: Dict[str, Any]): @@ -48,13 +39,14 @@ async def post_chat_history(auth_claims: Dict[str, Any]): try: request_json = await request.get_json() session_id = request_json.get("id") - answers = request_json.get("answers") - title = answers[0][0][:50] + "..." if len(answers[0][0]) > 50 else answers[0][0] + message_pairs = request_json.get("answers") + first_message_question = message_pairs[0][0] + title = first_message_question + "..." if len(first_message_question) > 50 else first_message_question timestamp = int(time.time() * 1000) # Insert the session item: - session_item = { - "id": id, + session = { + "id": session_id, "session_id": session_id, "entra_oid": entra_oid, "type": "session", @@ -62,42 +54,25 @@ async def post_chat_history(auth_claims: Dict[str, Any]): "timestamp": timestamp, } - message_items = [] + messages = [] # Now insert a message item for each question/response pair: - for ind, message_pair in enumerate(zip(answers[::2], answers[1::2])): - # The id: what if you delete a message and then add a new one? The id will be the same. - # If we had delete mechanism, and you deleted item 5 in a history, then item 6 would still hang around - # and youd have two of item 6. - # abc-0 - # abc-1 - # abc-2 <-- DELETE - # abc-3 - # One approach would be to delete EVERYTHING, then upsert everything. - # Another approach would be to delete item plus everything after, then upsert everything after. - # Or: Change the frontend? - # We can do this first, and change the frontend after - message_items.append( + for ind, message_pair in enumerate(message_pairs): + messages.append( { "id": f"{session_id}-{ind}", - "session_id": id, + "session_id": session_id, "entra_oid": entra_oid, "type": "message", "question": message_pair[0], "response": message_pair[1], - "timestamp": timestamp, # <-- This is the timestamp of the session, not the message + "timestamp": None, } ) - batch_operations = [("upsert", tuple([session_item] + message_items), {})] + batch_operations = [("upsert", (session,))] + [("upsert", (message,)) for message in messages] try: - # Run that list of operations - batch_results = container.execute_item_batch( - batch_operations=batch_operations, partition_key=make_partition_key(entra_oid, session_id) - ) - # Batch results are returned as a list of item operation results - or raise a CosmosBatchOperationError if - # one of the operations failed within your batch request. - print(f"\nResults for the batch operations: {batch_results}\n") + await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) except exceptions.CosmosBatchOperationError as e: error_operation_index = e.error_index error_operation_response = e.operation_responses[error_operation_index] @@ -109,9 +84,9 @@ async def post_chat_history(auth_claims: Dict[str, Any]): return error_response(error, "/chat_history") -@chat_history_cosmosdb_bp.get("/chat_history/items") +@chat_history_cosmosdb_bp.get("/chat_history/sessions") @authenticated -async def get_chat_history(auth_claims: Dict[str, Any]): +async def get_chat_history_sessions(auth_claims: Dict[str, Any]): if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]: return jsonify({"error": "Chat history not enabled"}), 400 @@ -125,14 +100,13 @@ async def get_chat_history(auth_claims: Dict[str, Any]): try: # get the count and continuation token from the request URL - count = request.args.get("count", 10) + count = int(request.args.get("count", 10)) continuation_token = request.args.get("continuation_token") res = container.query_items( - # TODO: do we need distinct? per Mark's code - Mark says no! query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.entra_oid = @entra_oid AND c.type = @type ORDER BY c.timestamp DESC", parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@type", value="session")], - partition_key=make_partition_key(entra_oid), + partition_key=[entra_oid], max_item_count=count, ) @@ -140,13 +114,13 @@ async def get_chat_history(auth_claims: Dict[str, Any]): pager = res.by_page(continuation_token) # Get the first page, and the continuation token + sessions = [] try: page = await pager.__anext__() continuation_token = pager.continuation_token # type: ignore - items = [] async for item in page: - items.append( + sessions.append( { "id": item.get("id"), "entra_oid": item.get("entra_oid"), @@ -157,18 +131,17 @@ async def get_chat_history(auth_claims: Dict[str, Any]): # If there are no more pages, StopAsyncIteration is raised except StopAsyncIteration: - items = [] continuation_token = None - return jsonify({"items": items, "continuation_token": continuation_token}), 200 + return jsonify({"sessions": sessions, "continuation_token": continuation_token}), 200 except Exception as error: - return error_response(error, "/chat_history/items") + return error_response(error, "/chat_history/sessions") -@chat_history_cosmosdb_bp.get("/chat_history/items/") +@chat_history_cosmosdb_bp.get("/chat_history/sessions/") @authenticated -async def get_chat_history_session(auth_claims: Dict[str, Any], item_id: str): +async def get_chat_history_session(auth_claims: Dict[str, Any], session_id: str): if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]: return jsonify({"error": "Chat history not enabled"}), 400 @@ -182,33 +155,39 @@ async def get_chat_history_session(auth_claims: Dict[str, Any], item_id: str): try: res = container.query_items( - # TODO: do we need distinct? per Mark's code - query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.session_id = @session_id ORDER BY c.timestamp DESC", - parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@session_id", value=item_id)], - partition_key=make_partition_key(entra_oid, item_id), - # max_item_count=? + query="SELECT * FROM c WHERE c.session_id = @session_id", + parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@session_id", value=session_id)], + partition_key=[entra_oid, session_id], ) - res = await container.read_item(item=item_id, partition_key=entra_oid) + message_pairs = [] + session = None + async for page in res.by_page(): + async for item in page: + if item.get("type") == "session": + session = item + else: + message_pairs.append([item["question"], item["response"]]) + return ( jsonify( { - "id": res.get("id"), - "entra_oid": res.get("entra_oid"), - "title": res.get("title", "untitled"), - "timestamp": res.get("timestamp"), - "answers": res.get("answers", []), + "id": session.get("id"), + "entra_oid": entra_oid, + "title": session.get("title"), + "timestamp": session.get("timestamp"), + "answers": message_pairs, } ), 200, ) except Exception as error: - return error_response(error, f"/chat_history/items/{item_id}") + return error_response(error, f"/chat_history/sessions/{session_id}") -@chat_history_cosmosdb_bp.delete("/chat_history/items/") +@chat_history_cosmosdb_bp.delete("/chat_history/sessions/") @authenticated -async def delete_chat_history_session(auth_claims: Dict[str, Any], item_id: str): +async def delete_chat_history_session(auth_claims: Dict[str, Any], session_id: str): if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]: return jsonify({"error": "Chat history not enabled"}), 400 @@ -221,12 +200,22 @@ async def delete_chat_history_session(auth_claims: Dict[str, Any], item_id: str) return jsonify({"error": "User OID not found"}), 401 try: - await container.delete_item(item=item_id, partition_key=entra_oid) - # Delete session, and all the message items associated with it - # TODO: Delete all the message items as well - return jsonify({}), 204 + res = container.query_items( + query="SELECT * FROM c WHERE c.session_id = @session_id", + parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@session_id", value=session_id)], + partition_key=[entra_oid, session_id], + ) + + ids_to_delete = [] + async for page in res.by_page(): + async for item in page: + ids_to_delete.append(item["id"]) + + batch_operations = [("delete", (id,)) for id in ids_to_delete] + await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) + return make_response("", 204) except Exception as error: - return error_response(error, f"/chat_history/items/{item_id}") + return error_response(error, f"/chat_history/sessions/{session_id}") @chat_history_cosmosdb_bp.before_app_serving diff --git a/app/frontend/src/api/api.ts b/app/frontend/src/api/api.ts index 8711ff0848..cef74125ba 100644 --- a/app/frontend/src/api/api.ts +++ b/app/frontend/src/api/api.ts @@ -145,10 +145,9 @@ export async function postChatHistoryApi(item: any, idToken: string): Promise { const headers = await getHeaders(idToken); - const url = new URL("/chat_history/items", BACKEND_URI); - url.searchParams.append("count", count.toString()); + let url = `${BACKEND_URI}/chat_history/sessions?count=${count}`; if (continuationToken) { - url.searchParams.append("continuationToken", continuationToken); + url += `&continuationToken=${continuationToken}`; } const response = await fetch(url.toString(), { @@ -166,7 +165,7 @@ export async function getChatHistoryListApi(count: number, continuationToken: st export async function getChatHistoryApi(id: string, idToken: string): Promise { const headers = await getHeaders(idToken); - const response = await fetch(`/chat_history/items/${id}`, { + const response = await fetch(`/chat_history/sessions/${id}`, { method: "GET", headers: { ...headers, "Content-Type": "application/json" } }); @@ -181,7 +180,7 @@ export async function getChatHistoryApi(id: string, idToken: string): Promise { const headers = await getHeaders(idToken); - const response = await fetch(`/chat_history/items/${id}`, { + const response = await fetch(`/chat_history/sessions/${id}`, { method: "DELETE", headers: { ...headers, "Content-Type": "application/json" } }); @@ -189,7 +188,4 @@ export async function deleteChatHistoryApi(id: string, idToken: string): Promise if (!response.ok) { throw new Error(`Deleting chat history failed: ${response.statusText}`); } - - const dataResponse: any = await response.json(); - return dataResponse; } diff --git a/app/frontend/src/api/models.ts b/app/frontend/src/api/models.ts index ef1fa154b0..c3d26fb87f 100644 --- a/app/frontend/src/api/models.ts +++ b/app/frontend/src/api/models.ts @@ -107,7 +107,7 @@ export interface SpeechConfig { } export type HistoryListApiResponse = { - items: { + sessions: { id: string; entra_oid: string; title: string; diff --git a/app/frontend/src/components/HistoryProviders/CosmosDB.ts b/app/frontend/src/components/HistoryProviders/CosmosDB.ts index 4d613b28a8..5da9df5493 100644 --- a/app/frontend/src/components/HistoryProviders/CosmosDB.ts +++ b/app/frontend/src/components/HistoryProviders/CosmosDB.ts @@ -23,10 +23,10 @@ export class CosmosDBProvider implements IHistoryProvider { if (!this.continuationToken) { this.isItemEnd = true; } - return response.items.map(item => ({ - id: item.id, - title: item.title, - timestamp: item.timestamp + return response.sessions.map(session => ({ + id: session.id, + title: session.title, + timestamp: session.timestamp })); } catch (e) { console.error(e); diff --git a/infra/main.bicep b/infra/main.bicep index ad1b1d9c92..4b24775755 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -799,6 +799,7 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.6.1' = if (use containers: [ { name: chatHistoryContainerName + kind: 'MultiHash' paths: [ '/entra_oid' '/session_id' From a0dd6b97d4e4e55f41e044818dd827915896d3e5 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Mon, 27 Jan 2025 12:20:30 -0800 Subject: [PATCH 05/13] Revert unneeded changes --- app/backend/chat_history/cosmosdb.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index fca1384834..15817ce3e4 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -1,9 +1,7 @@ -import logging import os import time from typing import Any, Dict, Union -from azure.cosmos import exceptions from azure.cosmos.aio import ContainerProxy, CosmosClient from azure.identity.aio import AzureDeveloperCliCredential, ManagedIdentityCredential from quart import Blueprint, current_app, jsonify, make_response, request @@ -17,8 +15,6 @@ from decorators import authenticated from error import error_response -logger = logging.getLogger("scripts") - chat_history_cosmosdb_bp = Blueprint("chat_history_cosmos", __name__, static_folder="static") @@ -71,14 +67,7 @@ async def post_chat_history(auth_claims: Dict[str, Any]): batch_operations = [("upsert", (session,))] + [("upsert", (message,)) for message in messages] - try: - await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) - except exceptions.CosmosBatchOperationError as e: - error_operation_index = e.error_index - error_operation_response = e.operation_responses[error_operation_index] - error_operation = batch_operations[error_operation_index] - logger.error(f"Batch operation failed: {error_operation_response} for operation {error_operation}") - return jsonify({"error": "Batch operation failed"}), 400 + await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) return jsonify({}), 201 except Exception as error: return error_response(error, "/chat_history") @@ -99,7 +88,6 @@ async def get_chat_history_sessions(auth_claims: Dict[str, Any]): return jsonify({"error": "User OID not found"}), 401 try: - # get the count and continuation token from the request URL count = int(request.args.get("count", 10)) continuation_token = request.args.get("continuation_token") @@ -110,7 +98,6 @@ async def get_chat_history_sessions(auth_claims: Dict[str, Any]): max_item_count=count, ) - # set the continuation token for the next page pager = res.by_page(continuation_token) # Get the first page, and the continuation token From 80329c82c76296ea50ccad779fa89fd3f235e7b1 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Mon, 27 Jan 2025 16:42:55 -0800 Subject: [PATCH 06/13] Fix tests --- app/backend/chat_history/cosmosdb.py | 5 +- .../auth_public_documents_client0/result.json | 3 +- .../auth_public_documents_client0/result.json | 2 +- .../auth_public_documents_client0/result.json | 2 +- tests/test_cosmosdb.py | 183 ++++++++++-------- 5 files changed, 108 insertions(+), 87 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index 15817ce3e4..d7d7341278 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -156,6 +156,9 @@ async def get_chat_history_session(auth_claims: Dict[str, Any], session_id: str) else: message_pairs.append([item["question"], item["response"]]) + if session is None: + return jsonify({"error": "Session not found"}), 404 + return ( jsonify( { @@ -200,7 +203,7 @@ async def delete_chat_history_session(auth_claims: Dict[str, Any], session_id: s batch_operations = [("delete", (id,)) for id in ids_to_delete] await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) - return make_response("", 204) + return await make_response("", 204) except Exception as error: return error_response(error, f"/chat_history/sessions/{session_id}") diff --git a/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json b/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json index 62f6f0f0f5..b46424ec55 100644 --- a/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json +++ b/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json @@ -1,7 +1,8 @@ { "answers": [ [ - "This is a test message" + "This is a test message", + "This is a test answer" ] ], "entra_oid": "OID_X", diff --git a/tests/snapshots/test_cosmosdb/test_chathistory_query/auth_public_documents_client0/result.json b/tests/snapshots/test_cosmosdb/test_chathistory_query/auth_public_documents_client0/result.json index 1c3422fbca..1d78dee8b3 100644 --- a/tests/snapshots/test_cosmosdb/test_chathistory_query/auth_public_documents_client0/result.json +++ b/tests/snapshots/test_cosmosdb/test_chathistory_query/auth_public_documents_client0/result.json @@ -1,6 +1,6 @@ { "continuation_token": "next", - "items": [ + "sessions": [ { "entra_oid": "OID_X", "id": "123", diff --git a/tests/snapshots/test_cosmosdb/test_chathistory_query_continuation/auth_public_documents_client0/result.json b/tests/snapshots/test_cosmosdb/test_chathistory_query_continuation/auth_public_documents_client0/result.json index 2983ef0689..9dca87c8e0 100644 --- a/tests/snapshots/test_cosmosdb/test_chathistory_query_continuation/auth_public_documents_client0/result.json +++ b/tests/snapshots/test_cosmosdb/test_chathistory_query_continuation/auth_public_documents_client0/result.json @@ -1,4 +1,4 @@ { "continuation_token": null, - "items": [] + "sessions": [] } \ No newline at end of file diff --git a/tests/test_cosmosdb.py b/tests/test_cosmosdb.py index 5495d8447a..4a0078b503 100644 --- a/tests/test_cosmosdb.py +++ b/tests/test_cosmosdb.py @@ -1,3 +1,4 @@ +import copy import json import pytest @@ -5,23 +6,44 @@ from .mocks import MockAsyncPageIterator +for_sessions_query = [ + [ + { + "id": "123", + "session_id": "123", + "entra_oid": "OID_X", + "title": "This is a test message", + "timestamp": 123456789, + "type": "session", + } + ] +] + +for_session_id_query = [ + [ + { + "id": "123", + "session_id": "123", + "entra_oid": "OID_X", + "title": "This is a test message", + "timestamp": 123456789, + "type": "session", + }, + { + "id": "123-0", + "session_id": "123", + "entra_oid": "OID_X", + "question": "This is a test message", + "response": "This is a test answer", + "type": "message", + }, + ] +] + class MockCosmosDBResultsIterator: - def __init__(self, empty=False): - if empty: - self.data = [] - else: - self.data = [ - [ - { - "id": "123", - "entra_oid": "OID_X", - "title": "This is a test message", - "timestamp": 123456789, - "answers": [["This is a test message"]], - } - ] - ] + def __init__(self, data=[]): + self.data = copy.deepcopy(data) def __aiter__(self): return self @@ -45,20 +67,33 @@ def by_page(self, continuation_token=None): @pytest.mark.asyncio async def test_chathistory_newitem(auth_public_documents_client, monkeypatch): - async def mock_upsert_item(container_proxy, item, **kwargs): - assert item["id"] == "123" - assert item["answers"] == [["This is a test message"]] - assert item["entra_oid"] == "OID_X" - assert item["title"] == "This is a test message" - - monkeypatch.setattr(ContainerProxy, "upsert_item", mock_upsert_item) + async def mock_execute_item_batch(container_proxy, **kwargs): + partition_key = kwargs["partition_key"] + assert partition_key == ["OID_X", "123"] + operations = kwargs["batch_operations"] + assert len(operations) == 2 + assert operations[0][0] == "upsert" + assert operations[1][0] == "upsert" + session = operations[0][1][0] + assert session["id"] == "123" + assert session["session_id"] == "123" + assert session["entra_oid"] == "OID_X" + assert session["title"] == "This is a test message" + message = operations[1][1][0] + assert message["id"] == "123-0" + assert message["session_id"] == "123" + assert message["entra_oid"] == "OID_X" + assert message["question"] == "This is a test message" + assert message["response"] == "This is a test answer" + + monkeypatch.setattr(ContainerProxy, "execute_item_batch", mock_execute_item_batch) response = await auth_public_documents_client.post( "/chat_history", headers={"Authorization": "Bearer MockToken"}, json={ "id": "123", - "answers": [["This is a test message"]], + "answers": [["This is a test message", "This is a test answer"]], }, ) assert response.status_code == 201 @@ -122,7 +157,7 @@ async def mock_upsert_item(container_proxy, item, **kwargs): ) assert response.status_code == 500 assert (await response.get_json()) == { - "error": "The app encountered an error processing your request.\nIf you are an administrator of the app, view the full error in the logs. See aka.ms/appservice-logs for more information.\nError type: \n" + "error": "The app encountered an error processing your request.\nIf you are an administrator of the app, view the full error in the logs. See aka.ms/appservice-logs for more information.\nError type: \n" } @@ -130,14 +165,12 @@ async def mock_upsert_item(container_proxy, item, **kwargs): async def test_chathistory_query(auth_public_documents_client, monkeypatch, snapshot): def mock_query_items(container_proxy, query, **kwargs): - return MockCosmosDBResultsIterator() + return MockCosmosDBResultsIterator(for_sessions_query) monkeypatch.setattr(ContainerProxy, "query_items", mock_query_items) - response = await auth_public_documents_client.post( - "/chat_history/items", - headers={"Authorization": "Bearer MockToken"}, - json={"count": 20}, + response = await auth_public_documents_client.get( + "/chat_history/sessions?count=20", headers={"Authorization": "Bearer MockToken"} ) assert response.status_code == 200 result = await response.get_json() @@ -148,14 +181,12 @@ def mock_query_items(container_proxy, query, **kwargs): async def test_chathistory_query_continuation(auth_public_documents_client, monkeypatch, snapshot): def mock_query_items(container_proxy, query, **kwargs): - return MockCosmosDBResultsIterator(empty=True) + return MockCosmosDBResultsIterator() monkeypatch.setattr(ContainerProxy, "query_items", mock_query_items) - response = await auth_public_documents_client.post( - "/chat_history/items", - headers={"Authorization": "Bearer MockToken"}, - json={"count": 20}, + response = await auth_public_documents_client.get( + "/chat_history/sessions?count=20&continuation_token=123", headers={"Authorization": "Bearer MockToken"} ) assert response.status_code == 200 result = await response.get_json() @@ -165,40 +196,22 @@ def mock_query_items(container_proxy, query, **kwargs): @pytest.mark.asyncio async def test_chathistory_query_error_disabled(client, monkeypatch): - response = await client.post( - "/chat_history/items", - headers={"Authorization": "Bearer MockToken"}, - json={ - "id": "123", - "answers": [["This is a test message"]], - }, - ) + response = await client.get("/chat_history/sessions", headers={"Authorization": "Bearer MockToken"}) assert response.status_code == 400 @pytest.mark.asyncio async def test_chathistory_query_error_container(auth_public_documents_client, monkeypatch): auth_public_documents_client.app.config["cosmos_history_container"] = None - response = await auth_public_documents_client.post( - "/chat_history/items", - headers={"Authorization": "Bearer MockToken"}, - json={ - "id": "123", - "answers": [["This is a test message"]], - }, + response = await auth_public_documents_client.get( + "/chat_history/sessions", headers={"Authorization": "Bearer MockToken"} ) assert response.status_code == 400 @pytest.mark.asyncio async def test_chathistory_query_error_entra(auth_public_documents_client, monkeypatch): - response = await auth_public_documents_client.post( - "/chat_history/items", - json={ - "id": "123", - "answers": [["This is a test message"]], - }, - ) + response = await auth_public_documents_client.get("/chat_history/sessions") assert response.status_code == 401 @@ -210,10 +223,8 @@ def mock_query_items(container_proxy, query, **kwargs): monkeypatch.setattr(ContainerProxy, "query_items", mock_query_items) - response = await auth_public_documents_client.post( - "/chat_history/items", - headers={"Authorization": "Bearer MockToken"}, - json={"count": 20}, + response = await auth_public_documents_client.get( + "/chat_history/sessions?count=20", headers={"Authorization": "Bearer MockToken"} ) assert response.status_code == 500 assert (await response.get_json()) == { @@ -225,19 +236,13 @@ def mock_query_items(container_proxy, query, **kwargs): @pytest.mark.asyncio async def test_chathistory_getitem(auth_public_documents_client, monkeypatch, snapshot): - async def mock_read_item(container_proxy, item, partition_key, **kwargs): - return { - "id": "123", - "entra_oid": "OID_X", - "title": "This is a test message", - "timestamp": 123456789, - "answers": [["This is a test message"]], - } + def mock_query_items(container_proxy, query, **kwargs): + return MockCosmosDBResultsIterator(for_session_id_query) - monkeypatch.setattr(ContainerProxy, "read_item", mock_read_item) + monkeypatch.setattr(ContainerProxy, "query_items", mock_query_items) response = await auth_public_documents_client.get( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "Bearer MockToken"}, ) assert response.status_code == 200 @@ -250,7 +255,7 @@ async def mock_read_item(container_proxy, item, partition_key, **kwargs): async def test_chathistory_getitem_error_disabled(client, monkeypatch): response = await client.get( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "BearerMockToken"}, ) assert response.status_code == 400 @@ -260,7 +265,7 @@ async def test_chathistory_getitem_error_disabled(client, monkeypatch): async def test_chathistory_getitem_error_container(auth_public_documents_client, monkeypatch): auth_public_documents_client.app.config["cosmos_history_container"] = None response = await auth_public_documents_client.get( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "BearerMockToken"}, ) assert response.status_code == 400 @@ -269,7 +274,7 @@ async def test_chathistory_getitem_error_container(auth_public_documents_client, @pytest.mark.asyncio async def test_chathistory_getitem_error_entra(auth_public_documents_client, monkeypatch): response = await auth_public_documents_client.get( - "/chat_history/items/123", + "/chat_history/sessions/123", ) assert response.status_code == 401 @@ -283,7 +288,7 @@ async def mock_read_item(container_proxy, item, partition_key, **kwargs): monkeypatch.setattr(ContainerProxy, "read_item", mock_read_item) response = await auth_public_documents_client.get( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "Bearer MockToken"}, ) assert response.status_code == 500 @@ -293,14 +298,26 @@ async def mock_read_item(container_proxy, item, partition_key, **kwargs): @pytest.mark.asyncio async def test_chathistory_deleteitem(auth_public_documents_client, monkeypatch): - async def mock_delete_item(container_proxy, item, partition_key, **kwargs): - assert item == "123" - assert partition_key == "OID_X" + def mock_query_items(container_proxy, query, **kwargs): + return MockCosmosDBResultsIterator(for_session_id_query) - monkeypatch.setattr(ContainerProxy, "delete_item", mock_delete_item) + monkeypatch.setattr(ContainerProxy, "query_items", mock_query_items) + + # mock the batch delete operation + async def mock_execute_item_batch(container_proxy, **kwargs): + partition_key = kwargs["partition_key"] + assert partition_key == ["OID_X", "123"] + operations = kwargs["batch_operations"] + assert len(operations) == 2 + assert operations[0][0] == "delete" + assert operations[1][0] == "delete" + assert operations[0][1][0] == "123" + assert operations[1][1][0] == "123-0" + + monkeypatch.setattr(ContainerProxy, "execute_item_batch", mock_execute_item_batch) response = await auth_public_documents_client.delete( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "Bearer MockToken"}, ) assert response.status_code == 204 @@ -310,7 +327,7 @@ async def mock_delete_item(container_proxy, item, partition_key, **kwargs): async def test_chathistory_deleteitem_error_disabled(client, monkeypatch): response = await client.delete( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "Bearer MockToken"}, ) assert response.status_code == 400 @@ -320,7 +337,7 @@ async def test_chathistory_deleteitem_error_disabled(client, monkeypatch): async def test_chathistory_deleteitem_error_container(auth_public_documents_client, monkeypatch): auth_public_documents_client.app.config["cosmos_history_container"] = None response = await auth_public_documents_client.delete( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "Bearer MockToken"}, ) assert response.status_code == 400 @@ -329,7 +346,7 @@ async def test_chathistory_deleteitem_error_container(auth_public_documents_clie @pytest.mark.asyncio async def test_chathistory_deleteitem_error_entra(auth_public_documents_client, monkeypatch): response = await auth_public_documents_client.delete( - "/chat_history/items/123", + "/chat_history/sessions/123", ) assert response.status_code == 401 @@ -343,7 +360,7 @@ async def mock_delete_item(container_proxy, item, partition_key, **kwargs): monkeypatch.setattr(ContainerProxy, "delete_item", mock_delete_item) response = await auth_public_documents_client.delete( - "/chat_history/items/123", + "/chat_history/sessions/123", headers={"Authorization": "Bearer MockToken"}, ) assert response.status_code == 500 From 9fb2fb1eadcf33a28e8eb8809913970cb2e95228 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Tue, 28 Jan 2025 12:33:06 -0800 Subject: [PATCH 07/13] Rename message to message_pair --- app/backend/chat_history/cosmosdb.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index d7d7341278..75697400f6 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -36,12 +36,12 @@ async def post_chat_history(auth_claims: Dict[str, Any]): request_json = await request.get_json() session_id = request_json.get("id") message_pairs = request_json.get("answers") - first_message_question = message_pairs[0][0] - title = first_message_question + "..." if len(first_message_question) > 50 else first_message_question + first_question = message_pairs[0][0] + title = first_question + "..." if len(first_question) > 50 else first_question timestamp = int(time.time() * 1000) # Insert the session item: - session = { + session_item = { "id": session_id, "session_id": session_id, "entra_oid": entra_oid, @@ -50,22 +50,24 @@ async def post_chat_history(auth_claims: Dict[str, Any]): "timestamp": timestamp, } - messages = [] + message_pair_items = [] # Now insert a message item for each question/response pair: for ind, message_pair in enumerate(message_pairs): - messages.append( + message_pair_items.append( { "id": f"{session_id}-{ind}", "session_id": session_id, "entra_oid": entra_oid, - "type": "message", + "type": "message_pair", "question": message_pair[0], "response": message_pair[1], "timestamp": None, } ) - batch_operations = [("upsert", (session,))] + [("upsert", (message,)) for message in messages] + batch_operations = [("upsert", (session_item,))] + [ + ("upsert", (message_pair_item,)) for message_pair_item in message_pair_items + ] await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) return jsonify({}), 201 @@ -153,7 +155,7 @@ async def get_chat_history_session(auth_claims: Dict[str, Any], session_id: str) async for item in page: if item.get("type") == "session": session = item - else: + elif item.get("type") == "message_pair": message_pairs.append([item["question"], item["response"]]) if session is None: From 5aa84cade2f7b4da507bf6ed628d78fc352f1f65 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Tue, 28 Jan 2025 17:26:12 -0800 Subject: [PATCH 08/13] Address Matt's feedback --- app/backend/chat_history/cosmosdb.py | 7 ++++++- app/backend/config.py | 1 + app/frontend/src/components/LoginButton/LoginButton.tsx | 1 - infra/main.bicep | 5 ++++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index 75697400f6..61cc8d15c8 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -10,6 +10,7 @@ CONFIG_CHAT_HISTORY_COSMOS_ENABLED, CONFIG_COSMOS_HISTORY_CLIENT, CONFIG_COSMOS_HISTORY_CONTAINER, + CONFIG_COSMOS_HISTORY_VERSION, CONFIG_CREDENTIAL, ) from decorators import authenticated @@ -43,6 +44,7 @@ async def post_chat_history(auth_claims: Dict[str, Any]): # Insert the session item: session_item = { "id": session_id, + "version": current_app.config[CONFIG_COSMOS_HISTORY_VERSION], "session_id": session_id, "entra_oid": entra_oid, "type": "session", @@ -56,11 +58,13 @@ async def post_chat_history(auth_claims: Dict[str, Any]): message_pair_items.append( { "id": f"{session_id}-{ind}", + "version": current_app.config[CONFIG_COSMOS_HISTORY_VERSION], "session_id": session_id, "entra_oid": entra_oid, "type": "message_pair", "question": message_pair[0], "response": message_pair[1], + "order": ind, "timestamp": None, } ) @@ -193,7 +197,7 @@ async def delete_chat_history_session(auth_claims: Dict[str, Any], session_id: s try: res = container.query_items( - query="SELECT * FROM c WHERE c.session_id = @session_id", + query="SELECT c.id FROM c WHERE c.session_id = @session_id", parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@session_id", value=session_id)], partition_key=[entra_oid, session_id], ) @@ -237,6 +241,7 @@ async def setup_clients(): current_app.config[CONFIG_COSMOS_HISTORY_CLIENT] = cosmos_client current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER] = cosmos_container + current_app.config[CONFIG_COSMOS_HISTORY_VERSION] = os.environ["AZURE_CHAT_HISTORY_VERSION"] @chat_history_cosmosdb_bp.after_app_serving diff --git a/app/backend/config.py b/app/backend/config.py index eaba154116..a9315df6c0 100644 --- a/app/backend/config.py +++ b/app/backend/config.py @@ -26,3 +26,4 @@ CONFIG_CHAT_HISTORY_COSMOS_ENABLED = "chat_history_cosmos_enabled" CONFIG_COSMOS_HISTORY_CLIENT = "cosmos_history_client" CONFIG_COSMOS_HISTORY_CONTAINER = "cosmos_history_container" +CONFIG_COSMOS_HISTORY_VERSION = "cosmos_history_version" diff --git a/app/frontend/src/components/LoginButton/LoginButton.tsx b/app/frontend/src/components/LoginButton/LoginButton.tsx index c9889f9c3d..3d8c64323a 100644 --- a/app/frontend/src/components/LoginButton/LoginButton.tsx +++ b/app/frontend/src/components/LoginButton/LoginButton.tsx @@ -35,7 +35,6 @@ export const LoginButton = () => { }) .catch(error => console.log(error)) .then(async () => { - debugger; setLoggedIn(await checkLoggedIn(instance)); setUsername((await getUsername(instance)) ?? ""); }); diff --git a/infra/main.bicep b/infra/main.bicep index 4b24775755..e52a38c09d 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -70,7 +70,8 @@ param cosmosDbLocation string = '' param cosmosDbAccountName string = '' param cosmosDbThroughput int = 400 param chatHistoryDatabaseName string = 'chat-database' -param chatHistoryContainerName string = 'chat-history-container' +param chatHistoryContainerName string = 'chat-history-v2' +param chatHistoryVersion string = 'cosmosdb-v2' // https://learn.microsoft.com/azure/ai-services/openai/concepts/models?tabs=python-secure%2Cstandard%2Cstandard-chat-completions#standard-deployment-model-availability @description('Location for the OpenAI resource group') @@ -375,6 +376,7 @@ var appEnvVariables = { AZURE_COSMOSDB_ACCOUNT: (useAuthentication && useChatHistoryCosmos) ? cosmosDb.outputs.name : '' AZURE_CHAT_HISTORY_DATABASE: chatHistoryDatabaseName AZURE_CHAT_HISTORY_CONTAINER: chatHistoryContainerName + AZURE_CHAT_HISTORY_VERSION: chatHistoryVersion // Shared by all OpenAI deployments OPENAI_HOST: openAiHost AZURE_OPENAI_EMB_MODEL_NAME: embedding.modelName @@ -1215,6 +1217,7 @@ output AZURE_SEARCH_SERVICE_ASSIGNED_USERID string = searchService.outputs.princ output AZURE_COSMOSDB_ACCOUNT string = (useAuthentication && useChatHistoryCosmos) ? cosmosDb.outputs.name : '' output AZURE_CHAT_HISTORY_DATABASE string = chatHistoryDatabaseName output AZURE_CHAT_HISTORY_CONTAINER string = chatHistoryContainerName +output AZURE_CHAT_HISTORY_VERSION string = chatHistoryVersion output AZURE_STORAGE_ACCOUNT string = storage.outputs.name output AZURE_STORAGE_CONTAINER string = storageContainerName From b12a06a5c73ed7d033db2302412df1a0009b73ab Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 29 Jan 2025 09:19:15 -0800 Subject: [PATCH 09/13] Add version env var --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index 90d6e112aa..431ac4b9bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -423,6 +423,7 @@ async def auth_public_documents_client( monkeypatch.setenv("AZURE_COSMOSDB_ACCOUNT", "test-cosmosdb-account") monkeypatch.setenv("AZURE_CHAT_HISTORY_DATABASE", "test-cosmosdb-database") monkeypatch.setenv("AZURE_CHAT_HISTORY_CONTAINER", "test-cosmosdb-container") + monkeypatch.setenv("AZURE_CHAT_HISTORY_VERSION", "cosmosdb-v2") for key, value in request.param.items(): monkeypatch.setenv(key, value) From d0eb8c17a0ae4e05841da749ccd2b2b0a94cae55 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 29 Jan 2025 09:43:12 -0800 Subject: [PATCH 10/13] Reformat with latest black --- .pre-commit-config.yaml | 6 +++--- tests/test_listfilestrategy.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 401a97262c..9585de49f2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,15 +7,15 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.9.3 hooks: - id: ruff - repo: https://github.com/psf/black - rev: 24.10.0 + rev: 25.1.0 hooks: - id: black - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + rev: v4.0.0-alpha.8 hooks: - id: prettier types_or: [css, javascript, ts, tsx, html] diff --git a/tests/test_listfilestrategy.py b/tests/test_listfilestrategy.py index c0247f55ae..4937a3aa26 100644 --- a/tests/test_listfilestrategy.py +++ b/tests/test_listfilestrategy.py @@ -41,7 +41,7 @@ def test_file_filename_to_id(): # test ascii filename assert File(empty).filename_to_id() == "file-foo_pdf-666F6F2E706466" # test filename containing unicode - empty.name = "foo\u00A9.txt" + empty.name = "foo\u00a9.txt" assert File(empty).filename_to_id() == "file-foo__txt-666F6FC2A92E747874" # test filenaming starting with unicode empty.name = "ファイル名.pdf" From 952917c442d4c246a33d7bbd9b7abbf063117841 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 29 Jan 2025 12:59:02 -0800 Subject: [PATCH 11/13] Minor updates and test fix --- app/backend/chat_history/cosmosdb.py | 4 ++-- infra/main.bicep | 3 +++ .../auth_public_documents_client0/result.json | 17 +++++++++++---- tests/test_cosmosdb.py | 21 ++++++++++++++----- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index 61cc8d15c8..9547e66ce2 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -149,7 +149,7 @@ async def get_chat_history_session(auth_claims: Dict[str, Any], session_id: str) try: res = container.query_items( query="SELECT * FROM c WHERE c.session_id = @session_id", - parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@session_id", value=session_id)], + parameters=[dict(name="@session_id", value=session_id)], partition_key=[entra_oid, session_id], ) @@ -198,7 +198,7 @@ async def delete_chat_history_session(auth_claims: Dict[str, Any], session_id: s try: res = container.query_items( query="SELECT c.id FROM c WHERE c.session_id = @session_id", - parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@session_id", value=session_id)], + parameters=[dict(name="@session_id", value=session_id)], partition_key=[entra_oid, session_id], ) diff --git a/infra/main.bicep b/infra/main.bicep index e52a38c09d..26d2320bd6 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -822,6 +822,9 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.6.1' = if (use { path: '/type/?' } + { + path: '/order/?' + } ] excludedPaths: [ { diff --git a/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json b/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json index b46424ec55..18f2ed3d37 100644 --- a/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json +++ b/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json @@ -1,12 +1,21 @@ { "answers": [ [ - "This is a test message", - "This is a test answer" + "What does a Product Manager do?", + { + "delta": { + "role": "assistant" + }, + "message": { + "content": "A Product Manager is responsible for leading the product management team and providing guidance on product strategy, design, development, and launch. They collaborate with internal teams and external partners to ensure successful product execution. They also develop and implement product life-cycle management processes, monitor industry trends, develop product marketing plans, research customer needs, collaborate with internal teams, develop pricing strategies, oversee product portfolio, analyze product performance, and identify areas for improvement [role_library.pdf#page=29][role_library.pdf#page=12][role_library.pdf#page=23].", + "role": "assistant" + }, + "session_state": "143c0240-b2ee-4090-8e90-2a1c58124894" + } ] ], "entra_oid": "OID_X", "id": "123", - "timestamp": 123456789, - "title": "This is a test message" + "timestamp": 1738174630204, + "title": "What does a Product Manager do?" } \ No newline at end of file diff --git a/tests/test_cosmosdb.py b/tests/test_cosmosdb.py index 4a0078b503..9a6e201d76 100644 --- a/tests/test_cosmosdb.py +++ b/tests/test_cosmosdb.py @@ -23,19 +23,30 @@ [ { "id": "123", + "version": "cosmosdb-v2", "session_id": "123", "entra_oid": "OID_X", - "title": "This is a test message", - "timestamp": 123456789, "type": "session", + "title": "What does a Product Manager do?", + "timestamp": 1738174630204, }, { "id": "123-0", + "version": "cosmosdb-v2", "session_id": "123", "entra_oid": "OID_X", - "question": "This is a test message", - "response": "This is a test answer", - "type": "message", + "type": "message_pair", + "question": "What does a Product Manager do?", + "response": { + "delta": {"role": "assistant"}, + "session_state": "143c0240-b2ee-4090-8e90-2a1c58124894", + "message": { + "content": "A Product Manager is responsible for leading the product management team and providing guidance on product strategy, design, development, and launch. They collaborate with internal teams and external partners to ensure successful product execution. They also develop and implement product life-cycle management processes, monitor industry trends, develop product marketing plans, research customer needs, collaborate with internal teams, develop pricing strategies, oversee product portfolio, analyze product performance, and identify areas for improvement [role_library.pdf#page=29][role_library.pdf#page=12][role_library.pdf#page=23].", + "role": "assistant", + }, + }, + "order": 0, + "timestamp": None, }, ] ] From e46dc9f36e9646bf329feebccc3ec26f282c0a06 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 29 Jan 2025 14:16:21 -0800 Subject: [PATCH 12/13] Changes based on Marks call --- app/backend/chat_history/cosmosdb.py | 20 +- app/backend/requirements.txt | 2 +- app/frontend/src/api/api.ts | 340 ++++++++++-------- app/frontend/src/api/models.ts | 152 ++++---- infra/main.bicep | 3 - .../auth_public_documents_client0/result.json | 4 +- tests/test_cosmosdb.py | 34 +- 7 files changed, 297 insertions(+), 258 deletions(-) diff --git a/app/backend/chat_history/cosmosdb.py b/app/backend/chat_history/cosmosdb.py index 9547e66ce2..fca1585b93 100644 --- a/app/backend/chat_history/cosmosdb.py +++ b/app/backend/chat_history/cosmosdb.py @@ -64,15 +64,12 @@ async def post_chat_history(auth_claims: Dict[str, Any]): "type": "message_pair", "question": message_pair[0], "response": message_pair[1], - "order": ind, - "timestamp": None, } ) batch_operations = [("upsert", (session_item,))] + [ ("upsert", (message_pair_item,)) for message_pair_item in message_pair_items ] - await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) return jsonify({}), 201 except Exception as error: @@ -148,30 +145,21 @@ async def get_chat_history_session(auth_claims: Dict[str, Any], session_id: str) try: res = container.query_items( - query="SELECT * FROM c WHERE c.session_id = @session_id", - parameters=[dict(name="@session_id", value=session_id)], + query="SELECT * FROM c WHERE c.session_id = @session_id AND c.type = @type", + parameters=[dict(name="@session_id", value=session_id), dict(name="@type", value="message_pair")], partition_key=[entra_oid, session_id], ) message_pairs = [] - session = None async for page in res.by_page(): async for item in page: - if item.get("type") == "session": - session = item - elif item.get("type") == "message_pair": - message_pairs.append([item["question"], item["response"]]) - - if session is None: - return jsonify({"error": "Session not found"}), 404 + message_pairs.append([item["question"], item["response"]]) return ( jsonify( { - "id": session.get("id"), + "id": session_id, "entra_oid": entra_oid, - "title": session.get("title"), - "timestamp": session.get("timestamp"), "answers": message_pairs, } ), diff --git a/app/backend/requirements.txt b/app/backend/requirements.txt index 5712aae47e..17a44aa8a3 100644 --- a/app/backend/requirements.txt +++ b/app/backend/requirements.txt @@ -47,7 +47,7 @@ azure-core==1.30.2 # msrest azure-core-tracing-opentelemetry==1.0.0b11 # via azure-monitor-opentelemetry -azure-cosmos==4.7.0 +azure-cosmos==4.9.0 # via -r requirements.in azure-identity==1.17.1 # via diff --git a/app/frontend/src/api/api.ts b/app/frontend/src/api/api.ts index cef74125ba..40fc53f69e 100644 --- a/app/frontend/src/api/api.ts +++ b/app/frontend/src/api/api.ts @@ -1,191 +1,227 @@ const BACKEND_URI = ""; -import { ChatAppResponse, ChatAppResponseOrError, ChatAppRequest, Config, SimpleAPIResponse, HistoryListApiResponse, HistroyApiResponse } from "./models"; +import { + ChatAppResponse, + ChatAppResponseOrError, + ChatAppRequest, + Config, + SimpleAPIResponse, + HistoryListApiResponse, + HistoryApiResponse, +} from "./models"; import { useLogin, getToken, isUsingAppServicesLogin } from "../authConfig"; -export async function getHeaders(idToken: string | undefined): Promise> { - // If using login and not using app services, add the id token of the logged in account as the authorization - if (useLogin && !isUsingAppServicesLogin) { - if (idToken) { - return { Authorization: `Bearer ${idToken}` }; - } +export async function getHeaders( + idToken: string | undefined, +): Promise> { + // If using login and not using app services, add the id token of the logged in account as the authorization + if (useLogin && !isUsingAppServicesLogin) { + if (idToken) { + return { Authorization: `Bearer ${idToken}` }; } + } - return {}; + return {}; } export async function configApi(): Promise { - const response = await fetch(`${BACKEND_URI}/config`, { - method: "GET" - }); + const response = await fetch(`${BACKEND_URI}/config`, { + method: "GET", + }); - return (await response.json()) as Config; + return (await response.json()) as Config; } -export async function askApi(request: ChatAppRequest, idToken: string | undefined): Promise { - const headers = await getHeaders(idToken); - const response = await fetch(`${BACKEND_URI}/ask`, { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify(request) - }); - - if (response.status > 299 || !response.ok) { - throw Error(`Request failed with status ${response.status}`); - } - const parsedResponse: ChatAppResponseOrError = await response.json(); - if (parsedResponse.error) { - throw Error(parsedResponse.error); - } - - return parsedResponse as ChatAppResponse; +export async function askApi( + request: ChatAppRequest, + idToken: string | undefined, +): Promise { + const headers = await getHeaders(idToken); + const response = await fetch(`${BACKEND_URI}/ask`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + + if (response.status > 299 || !response.ok) { + throw Error(`Request failed with status ${response.status}`); + } + const parsedResponse: ChatAppResponseOrError = await response.json(); + if (parsedResponse.error) { + throw Error(parsedResponse.error); + } + + return parsedResponse as ChatAppResponse; } -export async function chatApi(request: ChatAppRequest, shouldStream: boolean, idToken: string | undefined): Promise { - let url = `${BACKEND_URI}/chat`; - if (shouldStream) { - url += "/stream"; - } - const headers = await getHeaders(idToken); - return await fetch(url, { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify(request) - }); +export async function chatApi( + request: ChatAppRequest, + shouldStream: boolean, + idToken: string | undefined, +): Promise { + let url = `${BACKEND_URI}/chat`; + if (shouldStream) { + url += "/stream"; + } + const headers = await getHeaders(idToken); + return await fetch(url, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); } export async function getSpeechApi(text: string): Promise { - return await fetch("/speech", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - text: text - }) + return await fetch("/speech", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: text, + }), + }) + .then((response) => { + if (response.status == 200) { + return response.blob(); + } else if (response.status == 400) { + console.log("Speech synthesis is not enabled."); + return null; + } else { + console.error("Unable to get speech synthesis."); + return null; + } }) - .then(response => { - if (response.status == 200) { - return response.blob(); - } else if (response.status == 400) { - console.log("Speech synthesis is not enabled."); - return null; - } else { - console.error("Unable to get speech synthesis."); - return null; - } - }) - .then(blob => (blob ? URL.createObjectURL(blob) : null)); + .then((blob) => (blob ? URL.createObjectURL(blob) : null)); } export function getCitationFilePath(citation: string): string { - return `${BACKEND_URI}/content/${citation}`; + return `${BACKEND_URI}/content/${citation}`; } -export async function uploadFileApi(request: FormData, idToken: string): Promise { - const response = await fetch("/upload", { - method: "POST", - headers: await getHeaders(idToken), - body: request - }); - - if (!response.ok) { - throw new Error(`Uploading files failed: ${response.statusText}`); - } - - const dataResponse: SimpleAPIResponse = await response.json(); - return dataResponse; +export async function uploadFileApi( + request: FormData, + idToken: string, +): Promise { + const response = await fetch("/upload", { + method: "POST", + headers: await getHeaders(idToken), + body: request, + }); + + if (!response.ok) { + throw new Error(`Uploading files failed: ${response.statusText}`); + } + + const dataResponse: SimpleAPIResponse = await response.json(); + return dataResponse; } -export async function deleteUploadedFileApi(filename: string, idToken: string): Promise { - const headers = await getHeaders(idToken); - const response = await fetch("/delete_uploaded", { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify({ filename }) - }); - - if (!response.ok) { - throw new Error(`Deleting file failed: ${response.statusText}`); - } - - const dataResponse: SimpleAPIResponse = await response.json(); - return dataResponse; +export async function deleteUploadedFileApi( + filename: string, + idToken: string, +): Promise { + const headers = await getHeaders(idToken); + const response = await fetch("/delete_uploaded", { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }); + + if (!response.ok) { + throw new Error(`Deleting file failed: ${response.statusText}`); + } + + const dataResponse: SimpleAPIResponse = await response.json(); + return dataResponse; } export async function listUploadedFilesApi(idToken: string): Promise { - const response = await fetch(`/list_uploaded`, { - method: "GET", - headers: await getHeaders(idToken) - }); + const response = await fetch(`/list_uploaded`, { + method: "GET", + headers: await getHeaders(idToken), + }); - if (!response.ok) { - throw new Error(`Listing files failed: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Listing files failed: ${response.statusText}`); + } - const dataResponse: string[] = await response.json(); - return dataResponse; + const dataResponse: string[] = await response.json(); + return dataResponse; } -export async function postChatHistoryApi(item: any, idToken: string): Promise { - const headers = await getHeaders(idToken); - const response = await fetch("/chat_history", { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify(item) - }); - - if (!response.ok) { - throw new Error(`Posting chat history failed: ${response.statusText}`); - } - - const dataResponse: any = await response.json(); - return dataResponse; +export async function postChatHistoryApi( + item: any, + idToken: string, +): Promise { + const headers = await getHeaders(idToken); + const response = await fetch("/chat_history", { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(item), + }); + + if (!response.ok) { + throw new Error(`Posting chat history failed: ${response.statusText}`); + } + + const dataResponse: any = await response.json(); + return dataResponse; } -export async function getChatHistoryListApi(count: number, continuationToken: string | undefined, idToken: string): Promise { - const headers = await getHeaders(idToken); - let url = `${BACKEND_URI}/chat_history/sessions?count=${count}`; - if (continuationToken) { - url += `&continuationToken=${continuationToken}`; - } - - const response = await fetch(url.toString(), { - method: "GET", - headers: { ...headers, "Content-Type": "application/json" } - }); - - if (!response.ok) { - throw new Error(`Getting chat histories failed: ${response.statusText}`); - } - - const dataResponse: HistoryListApiResponse = await response.json(); - return dataResponse; +export async function getChatHistoryListApi( + count: number, + continuationToken: string | undefined, + idToken: string, +): Promise { + const headers = await getHeaders(idToken); + let url = `${BACKEND_URI}/chat_history/sessions?count=${count}`; + if (continuationToken) { + url += `&continuationToken=${continuationToken}`; + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { ...headers, "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error(`Getting chat histories failed: ${response.statusText}`); + } + + const dataResponse: HistoryListApiResponse = await response.json(); + return dataResponse; } -export async function getChatHistoryApi(id: string, idToken: string): Promise { - const headers = await getHeaders(idToken); - const response = await fetch(`/chat_history/sessions/${id}`, { - method: "GET", - headers: { ...headers, "Content-Type": "application/json" } - }); - - if (!response.ok) { - throw new Error(`Getting chat history failed: ${response.statusText}`); - } - - const dataResponse: HistroyApiResponse = await response.json(); - return dataResponse; +export async function getChatHistoryApi( + id: string, + idToken: string, +): Promise { + const headers = await getHeaders(idToken); + const response = await fetch(`/chat_history/sessions/${id}`, { + method: "GET", + headers: { ...headers, "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error(`Getting chat history failed: ${response.statusText}`); + } + + const dataResponse: HistoryApiResponse = await response.json(); + return dataResponse; } -export async function deleteChatHistoryApi(id: string, idToken: string): Promise { - const headers = await getHeaders(idToken); - const response = await fetch(`/chat_history/sessions/${id}`, { - method: "DELETE", - headers: { ...headers, "Content-Type": "application/json" } - }); - - if (!response.ok) { - throw new Error(`Deleting chat history failed: ${response.statusText}`); - } +export async function deleteChatHistoryApi( + id: string, + idToken: string, +): Promise { + const headers = await getHeaders(idToken); + const response = await fetch(`/chat_history/sessions/${id}`, { + method: "DELETE", + headers: { ...headers, "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error(`Deleting chat history failed: ${response.statusText}`); + } } diff --git a/app/frontend/src/api/models.ts b/app/frontend/src/api/models.ts index c3d26fb87f..008912c34c 100644 --- a/app/frontend/src/api/models.ts +++ b/app/frontend/src/api/models.ts @@ -1,125 +1,123 @@ export const enum RetrievalMode { - Hybrid = "hybrid", - Vectors = "vectors", - Text = "text" + Hybrid = "hybrid", + Vectors = "vectors", + Text = "text", } export const enum GPT4VInput { - TextAndImages = "textAndImages", - Images = "images", - Texts = "texts" + TextAndImages = "textAndImages", + Images = "images", + Texts = "texts", } export const enum VectorFieldOptions { - Embedding = "embedding", - ImageEmbedding = "imageEmbedding", - Both = "both" + Embedding = "embedding", + ImageEmbedding = "imageEmbedding", + Both = "both", } export type ChatAppRequestOverrides = { - retrieval_mode?: RetrievalMode; - semantic_ranker?: boolean; - semantic_captions?: boolean; - include_category?: string; - exclude_category?: string; - seed?: number; - top?: number; - temperature?: number; - minimum_search_score?: number; - minimum_reranker_score?: number; - prompt_template?: string; - prompt_template_prefix?: string; - prompt_template_suffix?: string; - suggest_followup_questions?: boolean; - use_oid_security_filter?: boolean; - use_groups_security_filter?: boolean; - use_gpt4v?: boolean; - gpt4v_input?: GPT4VInput; - vector_fields: VectorFieldOptions[]; - language: string; + retrieval_mode?: RetrievalMode; + semantic_ranker?: boolean; + semantic_captions?: boolean; + include_category?: string; + exclude_category?: string; + seed?: number; + top?: number; + temperature?: number; + minimum_search_score?: number; + minimum_reranker_score?: number; + prompt_template?: string; + prompt_template_prefix?: string; + prompt_template_suffix?: string; + suggest_followup_questions?: boolean; + use_oid_security_filter?: boolean; + use_groups_security_filter?: boolean; + use_gpt4v?: boolean; + gpt4v_input?: GPT4VInput; + vector_fields: VectorFieldOptions[]; + language: string; }; export type ResponseMessage = { - content: string; - role: string; + content: string; + role: string; }; export type Thoughts = { - title: string; - description: any; // It can be any output from the api - props?: { [key: string]: string }; + title: string; + description: any; // It can be any output from the api + props?: { [key: string]: string }; }; export type ResponseContext = { - data_points: string[]; - followup_questions: string[] | null; - thoughts: Thoughts[]; + data_points: string[]; + followup_questions: string[] | null; + thoughts: Thoughts[]; }; export type ChatAppResponseOrError = { - message: ResponseMessage; - delta: ResponseMessage; - context: ResponseContext; - session_state: any; - error?: string; + message: ResponseMessage; + delta: ResponseMessage; + context: ResponseContext; + session_state: any; + error?: string; }; export type ChatAppResponse = { - message: ResponseMessage; - delta: ResponseMessage; - context: ResponseContext; - session_state: any; + message: ResponseMessage; + delta: ResponseMessage; + context: ResponseContext; + session_state: any; }; export type ChatAppRequestContext = { - overrides?: ChatAppRequestOverrides; + overrides?: ChatAppRequestOverrides; }; export type ChatAppRequest = { - messages: ResponseMessage[]; - context?: ChatAppRequestContext; - session_state: any; + messages: ResponseMessage[]; + context?: ChatAppRequestContext; + session_state: any; }; export type Config = { - showGPT4VOptions: boolean; - showSemanticRankerOption: boolean; - showVectorOption: boolean; - showUserUpload: boolean; - showLanguagePicker: boolean; - showSpeechInput: boolean; - showSpeechOutputBrowser: boolean; - showSpeechOutputAzure: boolean; - showChatHistoryBrowser: boolean; - showChatHistoryCosmos: boolean; + showGPT4VOptions: boolean; + showSemanticRankerOption: boolean; + showVectorOption: boolean; + showUserUpload: boolean; + showLanguagePicker: boolean; + showSpeechInput: boolean; + showSpeechOutputBrowser: boolean; + showSpeechOutputAzure: boolean; + showChatHistoryBrowser: boolean; + showChatHistoryCosmos: boolean; }; export type SimpleAPIResponse = { - message?: string; + message?: string; }; export interface SpeechConfig { - speechUrls: (string | null)[]; - setSpeechUrls: (urls: (string | null)[]) => void; - audio: HTMLAudioElement; - isPlaying: boolean; - setIsPlaying: (isPlaying: boolean) => void; + speechUrls: (string | null)[]; + setSpeechUrls: (urls: (string | null)[]) => void; + audio: HTMLAudioElement; + isPlaying: boolean; + setIsPlaying: (isPlaying: boolean) => void; } export type HistoryListApiResponse = { - sessions: { - id: string; - entra_oid: string; - title: string; - timestamp: number; - }[]; - continuation_token?: string; -}; - -export type HistroyApiResponse = { + sessions: { id: string; entra_oid: string; title: string; - answers: any; timestamp: number; + }[]; + continuation_token?: string; +}; + +export type HistoryApiResponse = { + id: string; + entra_oid: string; + answers: any; }; diff --git a/infra/main.bicep b/infra/main.bicep index 26d2320bd6..e52a38c09d 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -822,9 +822,6 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.6.1' = if (use { path: '/type/?' } - { - path: '/order/?' - } ] excludedPaths: [ { diff --git a/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json b/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json index 18f2ed3d37..ab59969d83 100644 --- a/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json +++ b/tests/snapshots/test_cosmosdb/test_chathistory_getitem/auth_public_documents_client0/result.json @@ -15,7 +15,5 @@ ] ], "entra_oid": "OID_X", - "id": "123", - "timestamp": 1738174630204, - "title": "What does a Product Manager do?" + "id": "123" } \ No newline at end of file diff --git a/tests/test_cosmosdb.py b/tests/test_cosmosdb.py index 9a6e201d76..6efc9b7f3a 100644 --- a/tests/test_cosmosdb.py +++ b/tests/test_cosmosdb.py @@ -19,16 +19,15 @@ ] ] -for_session_id_query = [ +for_deletion_query = [ [ { "id": "123", - "version": "cosmosdb-v2", "session_id": "123", "entra_oid": "OID_X", + "title": "This is a test message", + "timestamp": 123456789, "type": "session", - "title": "What does a Product Manager do?", - "timestamp": 1738174630204, }, { "id": "123-0", @@ -51,6 +50,29 @@ ] ] +for_message_pairs_query = [ + [ + { + "id": "123-0", + "version": "cosmosdb-v2", + "session_id": "123", + "entra_oid": "OID_X", + "type": "message_pair", + "question": "What does a Product Manager do?", + "response": { + "delta": {"role": "assistant"}, + "session_state": "143c0240-b2ee-4090-8e90-2a1c58124894", + "message": { + "content": "A Product Manager is responsible for leading the product management team and providing guidance on product strategy, design, development, and launch. They collaborate with internal teams and external partners to ensure successful product execution. They also develop and implement product life-cycle management processes, monitor industry trends, develop product marketing plans, research customer needs, collaborate with internal teams, develop pricing strategies, oversee product portfolio, analyze product performance, and identify areas for improvement [role_library.pdf#page=29][role_library.pdf#page=12][role_library.pdf#page=23].", + "role": "assistant", + }, + }, + "order": 0, + "timestamp": None, + }, + ] +] + class MockCosmosDBResultsIterator: def __init__(self, data=[]): @@ -248,7 +270,7 @@ def mock_query_items(container_proxy, query, **kwargs): async def test_chathistory_getitem(auth_public_documents_client, monkeypatch, snapshot): def mock_query_items(container_proxy, query, **kwargs): - return MockCosmosDBResultsIterator(for_session_id_query) + return MockCosmosDBResultsIterator(for_message_pairs_query) monkeypatch.setattr(ContainerProxy, "query_items", mock_query_items) @@ -310,7 +332,7 @@ async def mock_read_item(container_proxy, item, partition_key, **kwargs): async def test_chathistory_deleteitem(auth_public_documents_client, monkeypatch): def mock_query_items(container_proxy, query, **kwargs): - return MockCosmosDBResultsIterator(for_session_id_query) + return MockCosmosDBResultsIterator(for_deletion_query) monkeypatch.setattr(ContainerProxy, "query_items", mock_query_items) From d83b1498645c99050b315f7f6a6b07a08e753621 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 29 Jan 2025 14:32:46 -0800 Subject: [PATCH 13/13] Fix the frontend for the HistoryList API --- .pre-commit-config.yaml | 2 +- app/frontend/src/api/api.ts | 340 +++++++++++++++------------------ app/frontend/src/api/models.ts | 152 +++++++-------- 3 files changed, 229 insertions(+), 265 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9585de49f2..aa106e2f47 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: hooks: - id: black - repo: https://github.com/pre-commit/mirrors-prettier - rev: v4.0.0-alpha.8 + rev: v3.1.0 hooks: - id: prettier types_or: [css, javascript, ts, tsx, html] diff --git a/app/frontend/src/api/api.ts b/app/frontend/src/api/api.ts index 40fc53f69e..df95f801b5 100644 --- a/app/frontend/src/api/api.ts +++ b/app/frontend/src/api/api.ts @@ -1,227 +1,191 @@ const BACKEND_URI = ""; -import { - ChatAppResponse, - ChatAppResponseOrError, - ChatAppRequest, - Config, - SimpleAPIResponse, - HistoryListApiResponse, - HistoryApiResponse, -} from "./models"; +import { ChatAppResponse, ChatAppResponseOrError, ChatAppRequest, Config, SimpleAPIResponse, HistoryListApiResponse, HistoryApiResponse } from "./models"; import { useLogin, getToken, isUsingAppServicesLogin } from "../authConfig"; -export async function getHeaders( - idToken: string | undefined, -): Promise> { - // If using login and not using app services, add the id token of the logged in account as the authorization - if (useLogin && !isUsingAppServicesLogin) { - if (idToken) { - return { Authorization: `Bearer ${idToken}` }; +export async function getHeaders(idToken: string | undefined): Promise> { + // If using login and not using app services, add the id token of the logged in account as the authorization + if (useLogin && !isUsingAppServicesLogin) { + if (idToken) { + return { Authorization: `Bearer ${idToken}` }; + } } - } - return {}; + return {}; } export async function configApi(): Promise { - const response = await fetch(`${BACKEND_URI}/config`, { - method: "GET", - }); + const response = await fetch(`${BACKEND_URI}/config`, { + method: "GET" + }); - return (await response.json()) as Config; + return (await response.json()) as Config; } -export async function askApi( - request: ChatAppRequest, - idToken: string | undefined, -): Promise { - const headers = await getHeaders(idToken); - const response = await fetch(`${BACKEND_URI}/ask`, { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify(request), - }); - - if (response.status > 299 || !response.ok) { - throw Error(`Request failed with status ${response.status}`); - } - const parsedResponse: ChatAppResponseOrError = await response.json(); - if (parsedResponse.error) { - throw Error(parsedResponse.error); - } - - return parsedResponse as ChatAppResponse; +export async function askApi(request: ChatAppRequest, idToken: string | undefined): Promise { + const headers = await getHeaders(idToken); + const response = await fetch(`${BACKEND_URI}/ask`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(request) + }); + + if (response.status > 299 || !response.ok) { + throw Error(`Request failed with status ${response.status}`); + } + const parsedResponse: ChatAppResponseOrError = await response.json(); + if (parsedResponse.error) { + throw Error(parsedResponse.error); + } + + return parsedResponse as ChatAppResponse; } -export async function chatApi( - request: ChatAppRequest, - shouldStream: boolean, - idToken: string | undefined, -): Promise { - let url = `${BACKEND_URI}/chat`; - if (shouldStream) { - url += "/stream"; - } - const headers = await getHeaders(idToken); - return await fetch(url, { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify(request), - }); +export async function chatApi(request: ChatAppRequest, shouldStream: boolean, idToken: string | undefined): Promise { + let url = `${BACKEND_URI}/chat`; + if (shouldStream) { + url += "/stream"; + } + const headers = await getHeaders(idToken); + return await fetch(url, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(request) + }); } export async function getSpeechApi(text: string): Promise { - return await fetch("/speech", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - text: text, - }), - }) - .then((response) => { - if (response.status == 200) { - return response.blob(); - } else if (response.status == 400) { - console.log("Speech synthesis is not enabled."); - return null; - } else { - console.error("Unable to get speech synthesis."); - return null; - } + return await fetch("/speech", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + text: text + }) }) - .then((blob) => (blob ? URL.createObjectURL(blob) : null)); + .then(response => { + if (response.status == 200) { + return response.blob(); + } else if (response.status == 400) { + console.log("Speech synthesis is not enabled."); + return null; + } else { + console.error("Unable to get speech synthesis."); + return null; + } + }) + .then(blob => (blob ? URL.createObjectURL(blob) : null)); } export function getCitationFilePath(citation: string): string { - return `${BACKEND_URI}/content/${citation}`; + return `${BACKEND_URI}/content/${citation}`; } -export async function uploadFileApi( - request: FormData, - idToken: string, -): Promise { - const response = await fetch("/upload", { - method: "POST", - headers: await getHeaders(idToken), - body: request, - }); - - if (!response.ok) { - throw new Error(`Uploading files failed: ${response.statusText}`); - } - - const dataResponse: SimpleAPIResponse = await response.json(); - return dataResponse; +export async function uploadFileApi(request: FormData, idToken: string): Promise { + const response = await fetch("/upload", { + method: "POST", + headers: await getHeaders(idToken), + body: request + }); + + if (!response.ok) { + throw new Error(`Uploading files failed: ${response.statusText}`); + } + + const dataResponse: SimpleAPIResponse = await response.json(); + return dataResponse; } -export async function deleteUploadedFileApi( - filename: string, - idToken: string, -): Promise { - const headers = await getHeaders(idToken); - const response = await fetch("/delete_uploaded", { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify({ filename }), - }); - - if (!response.ok) { - throw new Error(`Deleting file failed: ${response.statusText}`); - } - - const dataResponse: SimpleAPIResponse = await response.json(); - return dataResponse; +export async function deleteUploadedFileApi(filename: string, idToken: string): Promise { + const headers = await getHeaders(idToken); + const response = await fetch("/delete_uploaded", { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ filename }) + }); + + if (!response.ok) { + throw new Error(`Deleting file failed: ${response.statusText}`); + } + + const dataResponse: SimpleAPIResponse = await response.json(); + return dataResponse; } export async function listUploadedFilesApi(idToken: string): Promise { - const response = await fetch(`/list_uploaded`, { - method: "GET", - headers: await getHeaders(idToken), - }); + const response = await fetch(`/list_uploaded`, { + method: "GET", + headers: await getHeaders(idToken) + }); - if (!response.ok) { - throw new Error(`Listing files failed: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Listing files failed: ${response.statusText}`); + } - const dataResponse: string[] = await response.json(); - return dataResponse; + const dataResponse: string[] = await response.json(); + return dataResponse; } -export async function postChatHistoryApi( - item: any, - idToken: string, -): Promise { - const headers = await getHeaders(idToken); - const response = await fetch("/chat_history", { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify(item), - }); - - if (!response.ok) { - throw new Error(`Posting chat history failed: ${response.statusText}`); - } - - const dataResponse: any = await response.json(); - return dataResponse; +export async function postChatHistoryApi(item: any, idToken: string): Promise { + const headers = await getHeaders(idToken); + const response = await fetch("/chat_history", { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(item) + }); + + if (!response.ok) { + throw new Error(`Posting chat history failed: ${response.statusText}`); + } + + const dataResponse: any = await response.json(); + return dataResponse; } -export async function getChatHistoryListApi( - count: number, - continuationToken: string | undefined, - idToken: string, -): Promise { - const headers = await getHeaders(idToken); - let url = `${BACKEND_URI}/chat_history/sessions?count=${count}`; - if (continuationToken) { - url += `&continuationToken=${continuationToken}`; - } - - const response = await fetch(url.toString(), { - method: "GET", - headers: { ...headers, "Content-Type": "application/json" }, - }); - - if (!response.ok) { - throw new Error(`Getting chat histories failed: ${response.statusText}`); - } - - const dataResponse: HistoryListApiResponse = await response.json(); - return dataResponse; +export async function getChatHistoryListApi(count: number, continuationToken: string | undefined, idToken: string): Promise { + const headers = await getHeaders(idToken); + let url = `${BACKEND_URI}/chat_history/sessions?count=${count}`; + if (continuationToken) { + url += `&continuationToken=${continuationToken}`; + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { ...headers, "Content-Type": "application/json" } + }); + + if (!response.ok) { + throw new Error(`Getting chat histories failed: ${response.statusText}`); + } + + const dataResponse: HistoryListApiResponse = await response.json(); + return dataResponse; } -export async function getChatHistoryApi( - id: string, - idToken: string, -): Promise { - const headers = await getHeaders(idToken); - const response = await fetch(`/chat_history/sessions/${id}`, { - method: "GET", - headers: { ...headers, "Content-Type": "application/json" }, - }); - - if (!response.ok) { - throw new Error(`Getting chat history failed: ${response.statusText}`); - } - - const dataResponse: HistoryApiResponse = await response.json(); - return dataResponse; +export async function getChatHistoryApi(id: string, idToken: string): Promise { + const headers = await getHeaders(idToken); + const response = await fetch(`/chat_history/sessions/${id}`, { + method: "GET", + headers: { ...headers, "Content-Type": "application/json" } + }); + + if (!response.ok) { + throw new Error(`Getting chat history failed: ${response.statusText}`); + } + + const dataResponse: HistoryApiResponse = await response.json(); + return dataResponse; } -export async function deleteChatHistoryApi( - id: string, - idToken: string, -): Promise { - const headers = await getHeaders(idToken); - const response = await fetch(`/chat_history/sessions/${id}`, { - method: "DELETE", - headers: { ...headers, "Content-Type": "application/json" }, - }); - - if (!response.ok) { - throw new Error(`Deleting chat history failed: ${response.statusText}`); - } +export async function deleteChatHistoryApi(id: string, idToken: string): Promise { + const headers = await getHeaders(idToken); + const response = await fetch(`/chat_history/sessions/${id}`, { + method: "DELETE", + headers: { ...headers, "Content-Type": "application/json" } + }); + + if (!response.ok) { + throw new Error(`Deleting chat history failed: ${response.statusText}`); + } } diff --git a/app/frontend/src/api/models.ts b/app/frontend/src/api/models.ts index 008912c34c..f560271325 100644 --- a/app/frontend/src/api/models.ts +++ b/app/frontend/src/api/models.ts @@ -1,123 +1,123 @@ export const enum RetrievalMode { - Hybrid = "hybrid", - Vectors = "vectors", - Text = "text", + Hybrid = "hybrid", + Vectors = "vectors", + Text = "text" } export const enum GPT4VInput { - TextAndImages = "textAndImages", - Images = "images", - Texts = "texts", + TextAndImages = "textAndImages", + Images = "images", + Texts = "texts" } export const enum VectorFieldOptions { - Embedding = "embedding", - ImageEmbedding = "imageEmbedding", - Both = "both", + Embedding = "embedding", + ImageEmbedding = "imageEmbedding", + Both = "both" } export type ChatAppRequestOverrides = { - retrieval_mode?: RetrievalMode; - semantic_ranker?: boolean; - semantic_captions?: boolean; - include_category?: string; - exclude_category?: string; - seed?: number; - top?: number; - temperature?: number; - minimum_search_score?: number; - minimum_reranker_score?: number; - prompt_template?: string; - prompt_template_prefix?: string; - prompt_template_suffix?: string; - suggest_followup_questions?: boolean; - use_oid_security_filter?: boolean; - use_groups_security_filter?: boolean; - use_gpt4v?: boolean; - gpt4v_input?: GPT4VInput; - vector_fields: VectorFieldOptions[]; - language: string; + retrieval_mode?: RetrievalMode; + semantic_ranker?: boolean; + semantic_captions?: boolean; + include_category?: string; + exclude_category?: string; + seed?: number; + top?: number; + temperature?: number; + minimum_search_score?: number; + minimum_reranker_score?: number; + prompt_template?: string; + prompt_template_prefix?: string; + prompt_template_suffix?: string; + suggest_followup_questions?: boolean; + use_oid_security_filter?: boolean; + use_groups_security_filter?: boolean; + use_gpt4v?: boolean; + gpt4v_input?: GPT4VInput; + vector_fields: VectorFieldOptions[]; + language: string; }; export type ResponseMessage = { - content: string; - role: string; + content: string; + role: string; }; export type Thoughts = { - title: string; - description: any; // It can be any output from the api - props?: { [key: string]: string }; + title: string; + description: any; // It can be any output from the api + props?: { [key: string]: string }; }; export type ResponseContext = { - data_points: string[]; - followup_questions: string[] | null; - thoughts: Thoughts[]; + data_points: string[]; + followup_questions: string[] | null; + thoughts: Thoughts[]; }; export type ChatAppResponseOrError = { - message: ResponseMessage; - delta: ResponseMessage; - context: ResponseContext; - session_state: any; - error?: string; + message: ResponseMessage; + delta: ResponseMessage; + context: ResponseContext; + session_state: any; + error?: string; }; export type ChatAppResponse = { - message: ResponseMessage; - delta: ResponseMessage; - context: ResponseContext; - session_state: any; + message: ResponseMessage; + delta: ResponseMessage; + context: ResponseContext; + session_state: any; }; export type ChatAppRequestContext = { - overrides?: ChatAppRequestOverrides; + overrides?: ChatAppRequestOverrides; }; export type ChatAppRequest = { - messages: ResponseMessage[]; - context?: ChatAppRequestContext; - session_state: any; + messages: ResponseMessage[]; + context?: ChatAppRequestContext; + session_state: any; }; export type Config = { - showGPT4VOptions: boolean; - showSemanticRankerOption: boolean; - showVectorOption: boolean; - showUserUpload: boolean; - showLanguagePicker: boolean; - showSpeechInput: boolean; - showSpeechOutputBrowser: boolean; - showSpeechOutputAzure: boolean; - showChatHistoryBrowser: boolean; - showChatHistoryCosmos: boolean; + showGPT4VOptions: boolean; + showSemanticRankerOption: boolean; + showVectorOption: boolean; + showUserUpload: boolean; + showLanguagePicker: boolean; + showSpeechInput: boolean; + showSpeechOutputBrowser: boolean; + showSpeechOutputAzure: boolean; + showChatHistoryBrowser: boolean; + showChatHistoryCosmos: boolean; }; export type SimpleAPIResponse = { - message?: string; + message?: string; }; export interface SpeechConfig { - speechUrls: (string | null)[]; - setSpeechUrls: (urls: (string | null)[]) => void; - audio: HTMLAudioElement; - isPlaying: boolean; - setIsPlaying: (isPlaying: boolean) => void; + speechUrls: (string | null)[]; + setSpeechUrls: (urls: (string | null)[]) => void; + audio: HTMLAudioElement; + isPlaying: boolean; + setIsPlaying: (isPlaying: boolean) => void; } export type HistoryListApiResponse = { - sessions: { - id: string; - entra_oid: string; - title: string; - timestamp: number; - }[]; - continuation_token?: string; + sessions: { + id: string; + entra_oid: string; + title: string; + timestamp: number; + }[]; + continuation_token?: string; }; export type HistoryApiResponse = { - id: string; - entra_oid: string; - answers: any; + id: string; + entra_oid: string; + answers: any; };