Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Store chat history in Cosmos DB #2063

Merged
merged 15 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 156 additions & 2 deletions app/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SpeechSynthesizer,
)
from azure.core.exceptions import ResourceNotFoundError
from azure.cosmos.aio import ContainerProxy, CosmosClient
from azure.identity.aio import (
AzureDeveloperCliCredential,
ManagedIdentityCredential,
Expand Down Expand Up @@ -60,7 +61,9 @@
CONFIG_BLOB_CONTAINER_CLIENT,
CONFIG_CHAT_APPROACH,
CONFIG_CHAT_HISTORY_BROWSER_ENABLED,
CONFIG_CHAT_HISTORY_COSMOS_ENABLED,
CONFIG_CHAT_VISION_APPROACH,
CONFIG_COSMOS_HISTORY_CONTAINER,
CONFIG_CREDENTIAL,
CONFIG_GPT4V_DEPLOYED,
CONFIG_INGESTER,
Expand Down Expand Up @@ -224,7 +227,10 @@ async def chat(auth_claims: Dict[str, Any]):
# else creates a new session_id depending on the chat history options enabled.
session_state = request_json.get("session_state")
if session_state is None:
session_state = create_session_id(current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED])
session_state = create_session_id(
current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED],
current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED],
)
result = await approach.run(
request_json["messages"],
context=context,
Expand Down Expand Up @@ -255,7 +261,10 @@ async def chat_stream(auth_claims: Dict[str, Any]):
# else creates a new session_id depending on the chat history options enabled.
session_state = request_json.get("session_state")
if session_state is None:
session_state = create_session_id(current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED])
session_state = create_session_id(
current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED],
current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED],
)
result = await approach.run_stream(
request_json["messages"],
context=context,
Expand Down Expand Up @@ -289,6 +298,7 @@ def config():
"showSpeechOutputBrowser": current_app.config[CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED],
"showSpeechOutputAzure": current_app.config[CONFIG_SPEECH_OUTPUT_AZURE_ENABLED],
"showChatHistoryBrowser": current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED],
"showChatHistoryCosmos": current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED],
}
)

Expand Down Expand Up @@ -397,6 +407,129 @@ async def list_uploaded(auth_claims: dict[str, Any]):
return jsonify(files), 200


@bp.post("/chat_history")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think the chat history endpoints could be in a different file/blueprint? That would make it to bring in additional providers (I've been asked about Azure SQL, for example) without bloating app.py.
I know we only have a single file/blueprint for routes now though, so it's possible that I haven't organized it in such a way that it's feasible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly, app.py is getting too large, so I think it's good to separate the blueprint. I'll try to restructure it to separate it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configure separate blueprint in 44bfc40

@authenticated
async def post_chat_history(auth_claims: Dict[str, Any]):
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
return jsonify({"error": "Chat history not enabled"}), 405

container = cast(ContainerProxy, current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER])
if not container:
return jsonify({"error": "Chat history not enabled"}), 405

entra_oid = auth_claims.get("oid")
if not entra_oid:
return jsonify({"error": "User OID not found"}), 401

try:
request_json = await request.get_json()
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]

await container.upsert_item({"id": id, "entra_oid": entra_oid, "title": title, "answers": answers})

return jsonify({}), 201
except Exception as error:
return error_response(error, "/chat_history")


@bp.post("/chat_history/items")
@authenticated
async def get_chat_history(auth_claims: Dict[str, Any]):
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
return jsonify({"error": "Chat history not enabled"}), 405

container = cast(ContainerProxy, current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER])
if not container:
return jsonify({"error": "Chat history not enabled"}), 405

entra_oid = auth_claims.get("oid")
if not entra_oid:
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")

res = container.query_items(
query="SELECT c.id, c.entra_oid, c.title, c._ts FROM c WHERE c.entra_oid = @entra_oid ORDER BY c._ts DESC",
parameters=[dict(name="@entra_oid", value=entra_oid)],
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
try:
page = await pager.__anext__()
continuation_token = pager.continuation_token # type: ignore

items = []
async for item in page:
items.append(item)

# If there are no page, StopAsyncIteration is raised
except StopAsyncIteration:
items = []
continuation_token = None

return jsonify({"items": items, "continuation_token": continuation_token}), 200

except Exception as error:
return error_response(error, "/chat_history/items")


@bp.get("/chat_history/items/<path>")
@authenticated_path
async def get_chat_history_session(path: str, auth_claims: Dict[str, Any]):
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
return jsonify({"error": "Chat history not enabled"}), 405

container = cast(ContainerProxy, current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER])
if not container:
return jsonify({"error": "Chat history not enabled"}), 405

if not path:
return jsonify({"error": "Invalid path"}), 400

entra_oid = auth_claims.get("oid")
if not entra_oid:
return jsonify({"error": "User OID not found"}), 401

try:
res = await container.read_item(item=path, partition_key=entra_oid)
return jsonify(res), 200
except Exception as error:
return error_response(error, f"/chat_history/items/{path}")


@bp.delete("/chat_history/items/<path>")
@authenticated_path
async def delete_chat_history_session(path: str, auth_claims: Dict[str, Any]):
if not current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED]:
return jsonify({"error": "Chat history not enabled"}), 405

container = cast(ContainerProxy, current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER])
if not container:
return jsonify({"error": "Chat history not enabled"}), 405

if not path:
return jsonify({"error": "Invalid path"}), 400

entra_oid = auth_claims.get("oid")
if not entra_oid:
return jsonify({"error": "User OID not found"}), 401

try:
await container.delete_item(item=path, partition_key=entra_oid)
return jsonify({}), 200
except Exception as error:
return error_response(error, f"/chat_history/items/{path}")


@bp.before_app_serving
async def setup_clients():
# Replace these with your own values, either in environment variables or directly here
Expand Down Expand Up @@ -452,7 +585,12 @@ async def setup_clients():
USE_SPEECH_INPUT_BROWSER = os.getenv("USE_SPEECH_INPUT_BROWSER", "").lower() == "true"
USE_SPEECH_OUTPUT_BROWSER = os.getenv("USE_SPEECH_OUTPUT_BROWSER", "").lower() == "true"
USE_SPEECH_OUTPUT_AZURE = os.getenv("USE_SPEECH_OUTPUT_AZURE", "").lower() == "true"

USE_CHAT_HISTORY_BROWSER = os.getenv("USE_CHAT_HISTORY_BROWSER", "").lower() == "true"
USE_CHAT_HISTORY_COSMOS = os.getenv("USE_CHAT_HISTORY_COSMOS", "").lower() == "true"
AZURE_COSMOSDB_ACCOUNT = os.getenv("AZURE_COSMOSDB_ACCOUNT")
AZURE_CHAT_HISTORY_DATABASE = os.getenv("AZURE_CHAT_HISTORY_DATABASE")
AZURE_CHAT_HISTORY_CONTAINER = os.getenv("AZURE_CHAT_HISTORY_CONTAINER")

# WEBSITE_HOSTNAME is always set by App Service, RUNNING_IN_PRODUCTION is set in main.bicep
RUNNING_ON_AZURE = os.getenv("WEBSITE_HOSTNAME") is not None or os.getenv("RUNNING_IN_PRODUCTION") is not None
Expand Down Expand Up @@ -556,6 +694,21 @@ async def setup_clients():
)
current_app.config[CONFIG_INGESTER] = ingester

if USE_CHAT_HISTORY_COSMOS:
current_app.logger.info("USE_CHAT_HISTORY_COSMOS is true, setting up CosmosDB client")
if not AZURE_COSMOSDB_ACCOUNT:
raise ValueError("AZURE_COSMOSDB_ACCOUNT must be set when USE_CHAT_HISTORY_COSMOS is true")
if not AZURE_CHAT_HISTORY_DATABASE:
raise ValueError("AZURE_CHAT_HISTORY_DATABASE must be set when USE_CHAT_HISTORY_COSMOS is true")
if not AZURE_CHAT_HISTORY_CONTAINER:
raise ValueError("AZURE_CHAT_HISTORY_CONTAINER must be set when USE_CHAT_HISTORY_COSMOS is true")
cosmos_client = CosmosClient(
url=f"https://{AZURE_COSMOSDB_ACCOUNT}.documents.azure.com:443/", credential=azure_credential
)
cosmos_db = cosmos_client.get_database_client(AZURE_CHAT_HISTORY_DATABASE)
cosmos_container = cosmos_db.get_container_client(AZURE_CHAT_HISTORY_CONTAINER)
current_app.config[CONFIG_COSMOS_HISTORY_CONTAINER] = cosmos_container

# Used by the OpenAI SDK
openai_client: AsyncOpenAI

Expand Down Expand Up @@ -624,6 +777,7 @@ async def setup_clients():
current_app.config[CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED] = USE_SPEECH_OUTPUT_BROWSER
current_app.config[CONFIG_SPEECH_OUTPUT_AZURE_ENABLED] = USE_SPEECH_OUTPUT_AZURE
current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED] = USE_CHAT_HISTORY_BROWSER
current_app.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED] = USE_CHAT_HISTORY_COSMOS

# Various approaches to integrate GPT and external knowledge, most applications will use a single one of these patterns
# or some derivative, here we include several for exploration purposes
Expand Down
2 changes: 2 additions & 0 deletions app/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@
CONFIG_SPEECH_SERVICE_TOKEN = "speech_service_token"
CONFIG_SPEECH_SERVICE_VOICE = "speech_service_voice"
CONFIG_CHAT_HISTORY_BROWSER_ENABLED = "chat_history_browser_enabled"
CONFIG_CHAT_HISTORY_COSMOS_ENABLED = "chat_history_cosmos_enabled"
CONFIG_COSMOS_HISTORY_CONTAINER = "cosmos_history_container"
6 changes: 5 additions & 1 deletion app/backend/core/sessionhelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
from typing import Union


def create_session_id(config_chat_history_browser_enabled: bool) -> Union[str, None]:
def create_session_id(
config_chat_history_cosmos_enabled: bool, config_chat_history_browser_enabled: bool
) -> Union[str, None]:
if config_chat_history_cosmos_enabled:
return str(uuid.uuid4())
if config_chat_history_browser_enabled:
return str(uuid.uuid4())
return None
1 change: 1 addition & 0 deletions app/backend/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ tiktoken
tenacity
azure-ai-documentintelligence
azure-cognitiveservices-speech
azure-cosmos
azure-search-documents==11.6.0b6
azure-storage-blob
azure-storage-file-datalake
Expand Down
5 changes: 5 additions & 0 deletions app/backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ azure-core==1.30.2
# via
# azure-ai-documentintelligence
# azure-core-tracing-opentelemetry
# azure-cosmos
# azure-identity
# azure-monitor-opentelemetry
# azure-monitor-opentelemetry-exporter
Expand All @@ -44,6 +45,8 @@ azure-core==1.30.2
# msrest
azure-core-tracing-opentelemetry==1.0.0b11
# via azure-monitor-opentelemetry
azure-cosmos==4.7.0
# via -r requirements.in
azure-identity==1.17.1
# via
# -r requirements.in
Expand Down Expand Up @@ -402,7 +405,9 @@ typing-extensions==4.12.2
# via
# azure-ai-documentintelligence
# azure-core
# azure-cosmos
# azure-identity
# azure-search-documents
# azure-storage-blob
# azure-storage-file-datalake
# openai
Expand Down
64 changes: 63 additions & 1 deletion app/frontend/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const BACKEND_URI = "";

import { ChatAppResponse, ChatAppResponseOrError, ChatAppRequest, Config, SimpleAPIResponse } from "./models";
import { ChatAppResponse, ChatAppResponseOrError, ChatAppRequest, Config, SimpleAPIResponse, HistoryListApiResponse, HistroyApiResponse } from "./models";
import { useLogin, getToken, isUsingAppServicesLogin } from "../authConfig";

export async function getHeaders(idToken: string | undefined): Promise<Record<string, string>> {
Expand Down Expand Up @@ -126,3 +126,65 @@ export async function listUploadedFilesApi(idToken: string): Promise<string[]> {
const dataResponse: string[] = await response.json();
return dataResponse;
}

export async function postChatHistoryApi(item: any, idToken: string): Promise<any> {
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<HistoryListApiResponse> {
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 })
});

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<HistroyApiResponse> {
const headers = await getHeaders(idToken);
const response = await fetch(`/chat_history/items/${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 deleteChatHistoryApi(id: string, idToken: string): Promise<any> {
const headers = await getHeaders(idToken);
const response = await fetch(`/chat_history/items/${id}`, {
method: "DELETE",
headers: { ...headers, "Content-Type": "application/json" }
});

if (!response.ok) {
throw new Error(`Deleting chat history failed: ${response.statusText}`);
}

const dataResponse: any = await response.json();
return dataResponse;
}
19 changes: 19 additions & 0 deletions app/frontend/src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export type Config = {
showSpeechOutputBrowser: boolean;
showSpeechOutputAzure: boolean;
showChatHistoryBrowser: boolean;
showChatHistoryCosmos: boolean;
};

export type SimpleAPIResponse = {
Expand All @@ -103,3 +104,21 @@ export interface SpeechConfig {
isPlaying: boolean;
setIsPlaying: (isPlaying: boolean) => void;
}

export type HistoryListApiResponse = {
items: {
id: string;
entra_id: string;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is oid, right? Maybe entra_oid would be slightly clearer (versus an Entra group id)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a typo. I will fix it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0189dfc

title?: string;
_ts: number;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What/why is _ts? It's a funny variable name, so it could benefit from either an inline comment or a rename.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ts is a timestamp record that CosmosDB assigns by default.
We can define and assign a timestamp record defined in our application, but which is better?

https://learn.microsoft.com/en-us/rest/api/cosmos-db/documents

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, maybe we should change it in the server-side when we pass back the results? I'm imagining that if we had an Azure SQL history provider, then we wouldn't want to use _ts, we'd want them both to pass back timestamp. And ideally use the same TypeScript models. Does that make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 11c4d9f

}[];
continuation_token?: string;
};

export type HistroyApiResponse = {
id: string;
entra_id: string;
title?: string;
answers: any;
_ts: number;
};
Loading
Loading