diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index cd408336..4d98a744 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -6,7 +6,7 @@ services: context: .. dockerfile: .devcontainer/Dockerfile args: - IMAGE: python:3.11 + IMAGE: python:3.12 volumes: - ..:/workspace:cached diff --git a/.github/workflows/app-tests.yaml b/.github/workflows/app-tests.yaml index 32b45d15..1222aa77 100644 --- a/.github/workflows/app-tests.yaml +++ b/.github/workflows/app-tests.yaml @@ -28,8 +28,10 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "macos-latest-xlarge", "macos-13", "windows-latest"] - python_version: ["3.10"] + python_version: ["3.9", "3.10", "3.11", "3.12"] exclude: + - os: macos-latest-xlarge + python_version: "3.9" - os: macos-latest-xlarge python_version: "3.10" env: diff --git a/README.md b/README.md index 6ede71d3..9e906688 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ A related option is VS Code Dev Containers, which will open the project in your * [Azure Developer CLI (azd)](https://aka.ms/install-azd) * [Node.js 18+](https://nodejs.org/download/) - * [Python 3.10+](https://www.python.org/downloads/) + * [Python 3.9+](https://www.python.org/downloads/) * [PostgreSQL 14+](https://www.postgresql.org/download/) * [pgvector](https://github.com/pgvector/pgvector) * [Docker Desktop](https://www.docker.com/products/docker-desktop/) diff --git a/evals/generate_ground_truth.py b/evals/generate_ground_truth.py index 8d760dcd..f5807a7f 100644 --- a/evals/generate_ground_truth.py +++ b/evals/generate_ground_truth.py @@ -3,6 +3,7 @@ import os from collections.abc import Generator from pathlib import Path +from typing import Union from azure.identity import AzureDeveloperCliCredential, get_bearer_token_provider from dotenv_azd import load_azd_env @@ -77,9 +78,9 @@ def answer_formatter(answer, source) -> str: return f"{answer} [{source['id']}]" -def get_openai_client() -> tuple[AzureOpenAI | OpenAI, str]: +def get_openai_client() -> tuple[Union[AzureOpenAI, OpenAI], str]: """Return an OpenAI client based on the environment variables""" - openai_client: AzureOpenAI | OpenAI + openai_client: Union[AzureOpenAI, OpenAI] OPENAI_CHAT_HOST = os.getenv("OPENAI_CHAT_HOST") if OPENAI_CHAT_HOST == "azure": if api_key := os.getenv("AZURE_OPENAI_KEY"): diff --git a/pyproject.toml b/pyproject.toml index 00ca09ac..aa248487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [tool.ruff] line-length = 120 -target-version = "py312" +target-version = "py39" lint.select = ["E", "F", "I", "UP"] lint.ignore = ["D203"] lint.isort.known-first-party = ["fastapi_app"] [tool.mypy] check_untyped_defs = true -python_version = 3.12 +python_version = 3.9 exclude = [".venv/*"] [tool.pytest.ini_options] diff --git a/src/backend/fastapi_app/__init__.py b/src/backend/fastapi_app/__init__.py index c1be19e5..5510a2f0 100644 --- a/src/backend/fastapi_app/__init__.py +++ b/src/backend/fastapi_app/__init__.py @@ -2,7 +2,7 @@ import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import TypedDict +from typing import TypedDict, Union import fastapi from azure.monitor.opentelemetry import configure_azure_monitor @@ -27,8 +27,8 @@ class State(TypedDict): sessionmaker: async_sessionmaker[AsyncSession] context: FastAPIAppContext - chat_client: AsyncOpenAI | AsyncAzureOpenAI - embed_client: AsyncOpenAI | AsyncAzureOpenAI + chat_client: Union[AsyncOpenAI, AsyncAzureOpenAI] + embed_client: Union[AsyncOpenAI, AsyncAzureOpenAI] @asynccontextmanager diff --git a/src/backend/fastapi_app/api_models.py b/src/backend/fastapi_app/api_models.py index 61027830..446967ad 100644 --- a/src/backend/fastapi_app/api_models.py +++ b/src/backend/fastapi_app/api_models.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any +from typing import Any, Optional from openai.types.chat import ChatCompletionMessageParam from pydantic import BaseModel @@ -27,8 +27,8 @@ class ChatRequestOverrides(BaseModel): temperature: float = 0.3 retrieval_mode: RetrievalMode = RetrievalMode.HYBRID use_advanced_flow: bool = True - prompt_template: str | None = None - seed: int | None = None + prompt_template: Optional[str] = None + seed: Optional[int] = None class ChatRequestContext(BaseModel): @@ -38,7 +38,7 @@ class ChatRequestContext(BaseModel): class ChatRequest(BaseModel): messages: list[ChatCompletionMessageParam] context: ChatRequestContext - sessionState: Any | None = None + sessionState: Optional[Any] = None class ThoughtStep(BaseModel): @@ -50,7 +50,7 @@ class ThoughtStep(BaseModel): class RAGContext(BaseModel): data_points: dict[int, dict[str, Any]] thoughts: list[ThoughtStep] - followup_questions: list[str] | None = None + followup_questions: Optional[list[str]] = None class ErrorResponse(BaseModel): @@ -60,13 +60,13 @@ class ErrorResponse(BaseModel): class RetrievalResponse(BaseModel): message: Message context: RAGContext - sessionState: Any | None = None + sessionState: Optional[Any] = None class RetrievalResponseDelta(BaseModel): - delta: Message | None = None - context: RAGContext | None = None - sessionState: Any | None = None + delta: Optional[Message] = None + context: Optional[RAGContext] = None + sessionState: Optional[Any] = None class ItemPublic(BaseModel): diff --git a/src/backend/fastapi_app/dependencies.py b/src/backend/fastapi_app/dependencies.py index 5876c1eb..a06b246e 100644 --- a/src/backend/fastapi_app/dependencies.py +++ b/src/backend/fastapi_app/dependencies.py @@ -1,7 +1,7 @@ import logging import os from collections.abc import AsyncGenerator -from typing import Annotated +from typing import Annotated, Optional, Union import azure.identity from fastapi import Depends, Request @@ -17,7 +17,7 @@ class OpenAIClient(BaseModel): OpenAI client """ - client: AsyncOpenAI | AsyncAzureOpenAI + client: Union[AsyncOpenAI, AsyncAzureOpenAI] model_config = {"arbitrary_types_allowed": True} @@ -28,9 +28,9 @@ class FastAPIAppContext(BaseModel): openai_chat_model: str openai_embed_model: str - openai_embed_dimensions: int | None - openai_chat_deployment: str | None - openai_embed_deployment: str | None + openai_embed_dimensions: Optional[int] + openai_chat_deployment: Optional[str] + openai_embed_deployment: Optional[str] embedding_column: str @@ -77,9 +77,9 @@ async def common_parameters(): async def get_azure_credential() -> ( - azure.identity.AzureDeveloperCliCredential | azure.identity.ManagedIdentityCredential + Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential] ): - azure_credential: azure.identity.AzureDeveloperCliCredential | azure.identity.ManagedIdentityCredential + azure_credential: Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential] try: if client_id := os.getenv("APP_IDENTITY_ID"): # Authenticate using a user-assigned managed identity on Azure diff --git a/src/backend/fastapi_app/embeddings.py b/src/backend/fastapi_app/embeddings.py index 6f333da2..0dccec3e 100644 --- a/src/backend/fastapi_app/embeddings.py +++ b/src/backend/fastapi_app/embeddings.py @@ -1,16 +1,14 @@ -from typing import ( - TypedDict, -) +from typing import Optional, TypedDict, Union from openai import AsyncAzureOpenAI, AsyncOpenAI async def compute_text_embedding( q: str, - openai_client: AsyncOpenAI | AsyncAzureOpenAI, + openai_client: Union[AsyncOpenAI, AsyncAzureOpenAI], embed_model: str, - embed_deployment: str | None = None, - embedding_dimensions: int | None = None, + embed_deployment: Optional[str] = None, + embedding_dimensions: Optional[int] = None, ) -> list[float]: SUPPORTED_DIMENSIONS_MODEL = { "text-embedding-ada-002": False, diff --git a/src/backend/fastapi_app/openai_clients.py b/src/backend/fastapi_app/openai_clients.py index 10d2f506..f0c1e6e5 100644 --- a/src/backend/fastapi_app/openai_clients.py +++ b/src/backend/fastapi_app/openai_clients.py @@ -1,5 +1,6 @@ import logging import os +from typing import Union import azure.identity import openai @@ -8,9 +9,9 @@ async def create_openai_chat_client( - azure_credential: azure.identity.AzureDeveloperCliCredential | azure.identity.ManagedIdentityCredential, -) -> openai.AsyncAzureOpenAI | openai.AsyncOpenAI: - openai_chat_client: openai.AsyncAzureOpenAI | openai.AsyncOpenAI + azure_credential: Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential], +) -> Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI]: + openai_chat_client: Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI] OPENAI_CHAT_HOST = os.getenv("OPENAI_CHAT_HOST") if OPENAI_CHAT_HOST == "azure": api_version = os.environ["AZURE_OPENAI_VERSION"] or "2024-03-01-preview" @@ -57,9 +58,9 @@ async def create_openai_chat_client( async def create_openai_embed_client( - azure_credential: azure.identity.AzureDeveloperCliCredential | azure.identity.ManagedIdentityCredential, -) -> openai.AsyncAzureOpenAI | openai.AsyncOpenAI: - openai_embed_client: openai.AsyncAzureOpenAI | openai.AsyncOpenAI + azure_credential: Union[azure.identity.AzureDeveloperCliCredential, azure.identity.ManagedIdentityCredential], +) -> Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI]: + openai_embed_client: Union[openai.AsyncAzureOpenAI, openai.AsyncOpenAI] OPENAI_EMBED_HOST = os.getenv("OPENAI_EMBED_HOST") if OPENAI_EMBED_HOST == "azure": api_version = os.environ["AZURE_OPENAI_VERSION"] or "2024-03-01-preview" diff --git a/src/backend/fastapi_app/postgres_searcher.py b/src/backend/fastapi_app/postgres_searcher.py index 82dd5b63..cf753632 100644 --- a/src/backend/fastapi_app/postgres_searcher.py +++ b/src/backend/fastapi_app/postgres_searcher.py @@ -1,3 +1,5 @@ +from typing import Optional, Union + import numpy as np from openai import AsyncAzureOpenAI, AsyncOpenAI from sqlalchemy import Float, Integer, column, select, text @@ -11,10 +13,10 @@ class PostgresSearcher: def __init__( self, db_session: AsyncSession, - openai_embed_client: AsyncOpenAI | AsyncAzureOpenAI, - embed_deployment: str | None, # Not needed for non-Azure OpenAI or for retrieval_mode="text" + openai_embed_client: Union[AsyncOpenAI, AsyncAzureOpenAI], + embed_deployment: Optional[str], # Not needed for non-Azure OpenAI or for retrieval_mode="text" embed_model: str, - embed_dimensions: int | None, + embed_dimensions: Optional[int], embedding_column: str, ): self.db_session = db_session @@ -38,7 +40,7 @@ def build_filter_clause(self, filters) -> tuple[str, str]: return "", "" async def search( - self, query_text: str | None, query_vector: list[float] | list, top: int = 5, filters: list[dict] | None = None + self, query_text: Optional[str], query_vector: list[float], top: int = 5, filters: Optional[list[dict]] = None ): filter_clause_where, filter_clause_and = self.build_filter_clause(filters) table_name = Item.__tablename__ @@ -100,11 +102,11 @@ async def search( async def search_and_embed( self, - query_text: str | None = None, + query_text: Optional[str] = None, top: int = 5, enable_vector_search: bool = False, enable_text_search: bool = False, - filters: list[dict] | None = None, + filters: Optional[list[dict]] = None, ) -> list[Item]: """ Search rows by query text. Optionally converts the query text to a vector if enable_vector_search is True. diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 48ac8a09..fe75ea5f 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -1,5 +1,5 @@ from collections.abc import AsyncGenerator -from typing import Any, Final +from typing import Any, Final, Optional, Union from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream from openai.types.chat import ChatCompletion, ChatCompletionChunk, ChatCompletionMessageParam @@ -24,9 +24,9 @@ def __init__( self, *, searcher: PostgresSearcher, - openai_chat_client: AsyncOpenAI | AsyncAzureOpenAI, + openai_chat_client: Union[AsyncOpenAI, AsyncAzureOpenAI], chat_model: str, - chat_deployment: str | None, # Not needed for non-Azure OpenAI + chat_deployment: Optional[str], # Not needed for non-Azure OpenAI ): self.searcher = searcher self.openai_chat_client = openai_chat_client @@ -39,8 +39,8 @@ async def generate_search_query( original_user_query: str, past_messages: list[ChatCompletionMessageParam], query_response_token_limit: int, - seed: int | None = None, - ) -> tuple[list[ChatCompletionMessageParam], Any | str | None, list]: + seed: Optional[int] = None, + ) -> tuple[list[ChatCompletionMessageParam], Union[Any, str, None], list]: """Generate an optimized keyword search query based on the chat history and the last question""" tools = build_search_function() diff --git a/src/backend/fastapi_app/rag_simple.py b/src/backend/fastapi_app/rag_simple.py index 4bf50d1f..79350ab7 100644 --- a/src/backend/fastapi_app/rag_simple.py +++ b/src/backend/fastapi_app/rag_simple.py @@ -1,4 +1,5 @@ from collections.abc import AsyncGenerator +from typing import Optional, Union from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream from openai.types.chat import ChatCompletion, ChatCompletionChunk, ChatCompletionMessageParam @@ -22,9 +23,9 @@ def __init__( self, *, searcher: PostgresSearcher, - openai_chat_client: AsyncOpenAI | AsyncAzureOpenAI, + openai_chat_client: Union[AsyncOpenAI, AsyncAzureOpenAI], chat_model: str, - chat_deployment: str | None, # Not needed for non-Azure OpenAI + chat_deployment: Optional[str], # Not needed for non-Azure OpenAI ): self.searcher = searcher self.openai_chat_client = openai_chat_client diff --git a/src/backend/fastapi_app/routes/api_routes.py b/src/backend/fastapi_app/routes/api_routes.py index 8dc1d8bd..6a2bd160 100644 --- a/src/backend/fastapi_app/routes/api_routes.py +++ b/src/backend/fastapi_app/routes/api_routes.py @@ -1,6 +1,7 @@ import json import logging from collections.abc import AsyncGenerator +from typing import Union import fastapi from fastapi import HTTPException @@ -93,7 +94,7 @@ async def search_handler( return [ItemPublic.model_validate(item.to_dict()) for item in results] -@router.post("/chat", response_model=RetrievalResponse | ErrorResponse) +@router.post("/chat", response_model=Union[RetrievalResponse, ErrorResponse]) async def chat_handler( context: CommonDeps, database_session: DBSession, @@ -110,7 +111,7 @@ async def chat_handler( embed_dimensions=context.openai_embed_dimensions, embedding_column=context.embedding_column, ) - rag_flow: SimpleRAGChat | AdvancedRAGChat + rag_flow: Union[SimpleRAGChat, AdvancedRAGChat] if chat_request.context.overrides.use_advanced_flow: rag_flow = AdvancedRAGChat( searcher=searcher, @@ -154,7 +155,7 @@ async def chat_stream_handler( embedding_column=context.embedding_column, ) - rag_flow: SimpleRAGChat | AdvancedRAGChat + rag_flow: Union[SimpleRAGChat, AdvancedRAGChat] if chat_request.context.overrides.use_advanced_flow: rag_flow = AdvancedRAGChat( searcher=searcher, diff --git a/tests/mocks.py b/tests/mocks.py index 2c84ef06..eb078c13 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Optional from azure.core.credentials import AccessToken, TokenCredential @@ -7,8 +7,8 @@ class MockAzureCredential(TokenCredential): def get_token( self, *scopes: str, - claims: str | None = None, - tenant_id: str | None = None, + claims: Optional[str] = None, + tenant_id: Optional[str] = None, enable_cae: bool = False, **kwargs: Any, ) -> AccessToken: @@ -22,8 +22,8 @@ def __init__(self): def get_token( self, *scopes: str, - claims: str | None = None, - tenant_id: str | None = None, + claims: Optional[str] = None, + tenant_id: Optional[str] = None, enable_cae: bool = False, **kwargs: Any, ) -> AccessToken: