diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7167e0a..78bcf95 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ "image": "mcr.microsoft.com/devcontainers/base:noble", "features": { "ghcr.io/ljtill/features/task:latest": { - "version": "3.40.1" + "version": "3.41.0" } }, "customizations": { diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 95c1c89..921815a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -83,6 +83,10 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build Flows + id: flow-build + run: task flow-build + - name: Deploy Flows id: flow-deploy run: task flow-deploy diff --git a/.task/app.yml b/.task/app.yml index 9f90df0..6cea922 100644 --- a/.task/app.yml +++ b/.task/app.yml @@ -1,4 +1,4 @@ -version: "3.40.1" +version: "3.41.0" tasks: app-build: @@ -107,14 +107,23 @@ tasks: --src-path "$ZIP_PATH" \ --type zip + app-logs: + desc: Show application logs. + silent: true + aliases: + - al + dir: infra + cmds: + - az webapp log tail -g $(terraform output -raw resource_group) -n $(terraform output -raw webapp_name) + api-run: desc: Launch local API instance. silent: true - dir: app/api deps: - task: deps-setup aliases: - ar + dir: app/api cmds: - echo -e "\033[0;32mLaunching API...\033[0m" - .venv/bin/python3 -m uvicorn main:app --reload diff --git a/.task/flow.yml b/.task/flow.yml index 2197398..1ba4c49 100644 --- a/.task/flow.yml +++ b/.task/flow.yml @@ -1,7 +1,7 @@ -version: "3.40.1" +version: "3.41.0" tasks: - flow-deploy: + flow-deploy-review: desc: Deploy flows to AI Foundry. silent: true deps: @@ -12,9 +12,10 @@ tasks: cmds: - cmd: | NAME_ARGUMENT={{if .FLOW_NAME}}"--set display_name="{{.FLOW_NAME}}{{else}}{{end}} - pushd ../infra > /dev/null - eval "$(terraform output -json | jq -r 'to_entries | .[] | "export ADR_" + (.key | ascii_upcase) + "=\"" + .value.value + "\"" ')" - popd > /dev/null + pushd ../infra + RESOURCE_GROUP=$(terraform output -raw resource_group) + APP_NAME=$(terraform output -raw webapp_name) + popd pfazure flow create \ --flow ai_doc_review \ @@ -33,14 +34,15 @@ tasks: cmds: - cmd: | NAME_ARGUMENT={{if .FLOW_NAME}}"--set display_name="{{.FLOW_NAME}}{{else}}{{end}} - pushd ../infra > /dev/null - eval "$(terraform output -json | jq -r 'to_entries | .[] | "export ADR_" + (.key | ascii_upcase) + "=\"" + .value.value + "\"" ')" - popd > /dev/null + pushd ../infra + RESOURCE_GROUP=$(terraform output -raw resource_group) + AI_HUB_PROJECT_NAME=$(terraform output -raw ai_hub_project_name) + popd pfazure flow create \ --flow ai_doc_review/agent_template \ - --workspace-name "$ADR_AI_HUB_PROJECT_NAME" \ - --resource-group "$ADR_RESOURCE_GROUP" \ + --workspace-name "$AI_HUB_PROJECT_NAME" \ + --resource-group "$RESOURCE_GROUP" \ $NAME_ARGUMENT flow-deploy-eval: @@ -54,62 +56,84 @@ tasks: cmds: - cmd: | NAME_ARGUMENT={{if .FLOW_NAME}}"--set display_name="{{.FLOW_NAME}}{{else}}{{end}} - pushd ../infra > /dev/null - eval "$(terraform output -json | jq -r 'to_entries | .[] | "export ADR_" + (.key | ascii_upcase) + "=\"" + .value.value + "\"" ')" - popd > /dev/null + pushd ../infra + RESOURCE_GROUP=$(terraform output -raw resource_group) + AI_HUB_PROJECT_NAME=$(terraform output -raw ai_hub_project_name) + popd pfazure flow create \ --flow ai_doc_review_eval \ - --workspace-name "$ADR_AI_HUB_PROJECT_NAME" \ - --resource-group "$ADR_RESOURCE_GROUP" \ + --workspace-name "$AI_HUB_PROJECT_NAME" \ + --resource-group "$RESOURCE_GROUP" \ --set type=evaluation $NAME_ARGUMENT - flow-deploy-endpoint: + flow-build: + desc: Build flow artifacts. + silent: true + deps: + - task: deps-setup + aliases: + - fb + dir: flows + cmds: + - echo -e "\033[0;32mBuilding flow artifacts...\033[0m" + - python3 -m venv .venv + - .venv/bin/pip3 install -r requirements.txt + - .venv/bin/pip3 install -r ./ai_doc_review/requirements.txt + - .venv/bin/pip3 install -r ./ai_doc_review_eval/requirements.txt + - .venv/bin/pip3 install keyrings.alt + + flow-deploy: desc: Deploy to ML endpoints. silent: true deps: - task: deps-setup aliases: - fde - dir: flows/ai_doc_review + dir: flows cmds: - cmd: | - pushd ../../infra > /dev/null - eval "$(terraform output -json | jq -r 'to_entries | .[] | "export ADR_" + (.key | ascii_upcase) + "=\"" + .value.value + "\"" ')" - popd > /dev/null + echo -e "\033[0;32mUploading flow...\033[0m" + TEMP_DIR=$(mktemp -d) + trap "rm -rf $TEMP_DIR" EXIT - cp -r ../../common/ common/ - cp ./deployment.yaml ./sub_deployment.yaml - export MODEL_VERSION=$(az ml model create --workspace-name "$ADR_AI_HUB_PROJECT_NAME" --resource-group "$ADR_RESOURCE_GROUP" --file model.yaml --query version -o tsv) + cp -a ai_doc_review "$TEMP_DIR/" + rm -rf "$TEMP_DIR/ai_doc_review/common" + cp -a ../common "$TEMP_DIR/ai_doc_review/common" + pushd $TEMP_DIR/ai_doc_review + zip -q -r --symlinks "$TEMP_DIR/flow.zip" * + popd - sed -i 's|VAR_MODEL_VERSION|'"$MODEL_VERSION"'|g' sub_deployment.yaml - sed -i 's|VAR_SUBSCRIPTION_ID|'"$ADR_SUBSCRIPTION_ID"'|g' sub_deployment.yaml - sed -i 's|VAR_RESOURCE_GROUP|'"$ADR_RESOURCE_GROUP"'|g' sub_deployment.yaml - sed -i 's|VAR_AI_HUB_PROJECT_NAME|'"$ADR_AI_HUB_PROJECT_NAME"'|g' sub_deployment.yaml - sed -i 's|VAR_ENDPOINT_NAME|'"$ADR_AML_ENDPOINT_NAME"'|g' sub_deployment.yaml - sed -i 's|VAR_AZURE_OPENAI_ENDPOINT|'"$ADR_AZURE_OPENAI_ENDPOINT"'|g' sub_deployment.yaml - sed -i 's|VAR_IDENTITY_CLIENT_ID|'"$ADR_IDENTITY_CLIENT_ID"'|g' sub_deployment.yaml - sed -i 's|VAR_DOCUMENT_INTELLIGENCE_ENDPOINT|'"$ADR_DOCUMENT_INTELLIGENCE_ENDPOINT"'|g' sub_deployment.yaml - sed -i 's|VAR_STORAGE_URL_PREFIX|'"$ADR_STORAGE_URL_PREFIX"'|g' sub_deployment.yaml + pushd ../infra + RESOURCE_GROUP=$(terraform output -raw resource_group) + FLOW_NAME=$(terraform output -raw flowapp_name) + popd - az ml online-deployment show \ - --name ai-doc-review-deployment \ - --endpoint-name "$ADR_AML_ENDPOINT_NAME" \ - --workspace-name "$ADR_AI_HUB_PROJECT_NAME" \ - --resource-group "$ADR_RESOURCE_GROUP" \ - && az ml online-deployment update \ - --file sub_deployment.yaml \ - --workspace-name "$ADR_AI_HUB_PROJECT_NAME" \ - --resource-group "$ADR_RESOURCE_GROUP" \ - || az ml online-deployment create \ - --file sub_deployment.yaml \ - --workspace-name "$ADR_AI_HUB_PROJECT_NAME" \ - --resource-group "$ADR_RESOURCE_GROUP" \ - --all-traffic + ZIP_PATH="$TEMP_DIR/flow.zip" + az webapp deploy \ + -g $RESOURCE_GROUP \ + -n $FLOW_NAME \ + --src-path "$ZIP_PATH" \ + --type zip - az ml online-endpoint update \ - --name $ADR_AML_ENDPOINT_NAME \ - --traffic "ai-doc-review-deployment=100" \ - --workspace-name "$ADR_AI_HUB_PROJECT_NAME" \ - --resource-group "$ADR_RESOURCE_GROUP" + flow-run: + desc: Launch local flow instance. + silent: true + deps: + - task: deps-setup + aliases: + - fr + dir: flows/ai_doc_review + cmds: + - echo -e "\033[0;32mLaunching flow...\033[0m" + - pf flow serve --source ./ --port 8080 --host localhost + + flow-logs: + desc: Show logs for ML endpoint. + silent: true + aliases: + - fle + dir: infra + cmds: + - az webapp log tail --name $(terraform output -raw flowapp_name) --resource-group $(terraform output -raw resource_group) diff --git a/.task/infra.yml b/.task/infra.yml index a643e42..62aa2c3 100644 --- a/.task/infra.yml +++ b/.task/infra.yml @@ -1,4 +1,4 @@ -version: "3.40.1" +version: "3.41.0" tasks: infra-init: diff --git a/Taskfile.yml b/Taskfile.yml index 1a81444..1b4b31b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,4 +1,4 @@ -version: "3.40.1" +version: "3.41.0" dotenv: [".env", "flows/.env", "{{.HOME}}/.env"] @@ -46,11 +46,6 @@ tasks: echo "Error: mkcert is not installed. Please install mkcert to continue." exit 1 fi - - | - if ! command -v pfazure > /dev/null; then - echo "Error: pfazure is not installed. Please install pfazure to continue." - exit 1 - fi - | if ! command -v git > /dev/null; then echo "Error: git is not installed. Please install git to continue." @@ -125,7 +120,7 @@ tasks: sudo curl -fsSL https://raw.githubusercontent.com/tj/n/master/bin/n -o /opt/n sudo bash /opt/n install 22 > /dev/null else - NODE_VERSION=$(node -v | sed 's/v//;s/\..*//') + NODE_VERSION=$(node -v | sed "s/v//;s/\..*//") if [ "$NODE_VERSION" -ne 22 ]; then echo -e "\033[0;33mUpdating node...\033[0m" cd "$(mktemp -d)" || exit 1 @@ -146,17 +141,6 @@ tasks: chmod +x mkcert sudo mv mkcert /usr/local/bin/mkcert fi - - | - if ! command -v pf > /dev/null; then - echo -e "\033[0;32mInstalling promptflow...\033[0m" - cd "$(mktemp -d)" || exit 1 - - sudo mkdir -p /opt/python3/venvs/promptflow - sudo python3 -m venv /opt/python3/venvs/promptflow - sudo /opt/python3/venvs/promptflow/bin/pip3 install promptflow promptflow-tools promptflow-azure --quiet - sudo ln -s /opt/python3/venvs/promptflow/bin/pf /usr/local/bin/pf - sudo ln -s /opt/python3/venvs/promptflow/bin/pfazure /usr/local/bin/pfazure - fi deps-setup: desc: Setup toolchain dependencies. @@ -178,6 +162,7 @@ tasks: feature_state=$(az feature show --name AllowNSPInPublicPreview --namespace Microsoft.Network --query 'properties.state' -o tsv) if [ "$feature_state" == "NotRegistered" ]; then - echo "Registering feature 'AllowNSPInPublicPreview'..." + echo "Registering feature ' + AllowNSPInPublicPreview'..." az feature register --name AllowNSPInPublicPreview --namespace Microsoft.Network --only-show-errors fi diff --git a/app/api/.env.tpl b/app/api/.env.tpl index 64218b0..86e9392 100644 --- a/app/api/.env.tpl +++ b/app/api/.env.tpl @@ -2,18 +2,20 @@ AAD_CLIENT_ID="${AAD_CLIENT_ID}" AAD_TENANT_ID="${AAD_TENANT_ID}" AAD_USER_IMPERSONATION_SCOPE_ID="${AAD_USER_IMPERSONATION_SCOPE_ID}" +AZURE_CLIENT_ID="${AZURE_CLIENT_ID}" # Cosmos DB configuration COSMOS_URL="${COSMOS_URL}" DATABASE_NAME="${DATABASE_NAME}" -# Azure ML configuration +# Flow configuration SUBSCRIPTION_ID="${SUBSCRIPTION_ID}" RESOURCE_GROUP="${RESOURCE_GROUP}" AI_HUB_PROJECT_NAME="${AI_HUB_PROJECT_NAME}" AI_HUB_REGION="${AI_HUB_REGION}" -AML_ENDPOINT_NAME="${AML_ENDPOINT_NAME}" -AML_STREAMING_BATCH_SIZE=10 +FLOW_STREAMING_BATCH_SIZE=100 +FLOW_ENDPOINT_NAME="${FLOW_ENDPOINT_NAME}" +FLOW_APP_NAME="${FLOW_APP_NAME}" # App logging APPINSIGHTS_INSTRUMENTATION_KEY="${APPINSIGHTS_INSTRUMENTATION_KEY}" @@ -26,4 +28,4 @@ LOG_LEVEL="INFO" DEBUG=True # To Mount the UI at the root path -SERVE_STATIC=False # Set to True or False \ No newline at end of file +SERVE_STATIC=False # Set to True or False diff --git a/app/api/config/config.py b/app/api/config/config.py index 62e19fc..45c29fd 100644 --- a/app/api/config/config.py +++ b/app/api/config/config.py @@ -20,8 +20,9 @@ class Settings(BaseSettings): resource_group: str = "" ai_hub_project_name: str = "" ai_hub_region: str = "" - aml_endpoint_name: str = "" - aml_streaming_batch_size: int = 10 + flow_endpoint_name: str = "" + flow_app_name: str = "" + flow_streaming_batch_size: int = 100 appinsights_instrumentation_key: str = "" log_level: str = "INFO" model_config = SettingsConfigDict(env_file=".env") diff --git a/app/api/database/config.py b/app/api/database/config.py index 636c9ef..ca80f12 100644 --- a/app/api/database/config.py +++ b/app/api/database/config.py @@ -11,7 +11,8 @@ def __init__(self, container_name) -> None: self.container_name = container_name # Initialize the Cosmos client - self.client = CosmosClient(self.cosmos_url, DefaultAzureCredential()) + default_credential = DefaultAzureCredential() + self.client = CosmosClient(self.cosmos_url, default_credential) def get_client(self) -> CosmosClient: """Return the initialized Cosmos client.""" @@ -20,7 +21,7 @@ def get_client(self) -> CosmosClient: def get_database_name(self) -> str: """Return the database name.""" return self.database_name - + def get_container_name(self) -> str: """Return the container name.""" return self.container_name diff --git a/app/api/dependencies.py b/app/api/dependencies.py index 34ce69b..2817e5b 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -1,8 +1,8 @@ +import os from services.aml_client import AMLClient from database.issues_repository import IssuesRepository from services.issues_service import IssuesService -from azure.identity import DefaultAzureCredential -from azure.ai.ml import MLClient +from azure.identity import DefaultAzureCredential, ClientAssertionCredential from config.config import settings @@ -10,11 +10,7 @@ def get_issues_service() -> IssuesService: return IssuesService(IssuesRepository(), get_aml_client()) def get_aml_client(): - credential = DefaultAzureCredential() - mlclient = MLClient( - credential, - settings.subscription_id, - settings.resource_group, - settings.ai_hub_project_name - ) - return AMLClient(mlclient) + default_credential = DefaultAzureCredential() + credential = ClientAssertionCredential(settings.aad_tenant_id, os.environ.get('FLOW_CLIENT_ID'), lambda: default_credential.get_token("api://AzureADTokenExchange/.default").token) + + return AMLClient(credential) diff --git a/app/api/services/aml_client.py b/app/api/services/aml_client.py index 15a6643..468d386 100644 --- a/app/api/services/aml_client.py +++ b/app/api/services/aml_client.py @@ -1,6 +1,4 @@ import json -import os -import ssl from typing import Any, AsyncGenerator import requests from http import HTTPStatus @@ -13,37 +11,36 @@ logging = get_logger(__name__) class AMLClient: - def __init__(self, ml_client_instance): - self.aml_client = ml_client_instance - + def __init__(self, credential): + self.credential = credential async def call_aml_endpoint(self, endpoint_name: str, pdf_name: str) -> AsyncGenerator[Any, Any]: """ - Calls the Azure ML endpoint with the name and data. + Calls the flow endpoint with the name and data. Args: - name (str): The name of the Azure ML endpoint. + name (str): The name of the flow endpoint. data (str): The body of the request. """ - if not os.environ.get('PYTHONHTTPSVERIFY', '') and getattr(ssl, '_create_unverified_context', None): - ssl._create_default_https_context = ssl._create_unverified_context # Get the scoring URI and API key - scoring_uri = f"https://{endpoint_name}.{settings.ai_hub_region}.inference.ml.azure.com/score" - keys = self.aml_client.online_endpoints.get_keys(name=endpoint_name) + scoring_uri = f"https://{endpoint_name}.azurewebsites.net/score" + + # Get access token from local endpoint + keys = self.credential.get_token(f"api://{settings.flow_app_name}/.default") - if not hasattr(keys, 'access_token'): - raise Exception(f"Unable to retrieve token for the Azure ML endpoint: {endpoint_name}. It may not have Entra Auth enabled.") + if not hasattr(keys, 'token'): + raise Exception(f"Unable to retrieve token for the flow endpoint: {endpoint_name}. It may not have Entra Auth enabled.") - headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {keys.access_token}', 'Accept': 'text/event-stream'} + headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {keys.token}', 'Accept': 'text/event-stream'} data = { "pdf_name": pdf_name, "stream": True, - "pagination": settings.aml_streaming_batch_size + "pagination": settings.flow_streaming_batch_size } try: - logging.info("Sending POST request to the Azure ML endpoint...") + logging.info("Sending POST request to the flow endpoint...") response = requests.post(scoring_uri, json=data, headers=headers, stream=True) response.raise_for_status() @@ -53,23 +50,23 @@ async def call_aml_endpoint(self, endpoint_name: str, pdf_name: str) -> AsyncGen client = SSEClient(response) for event in client.events(): - logging.debug(f"Received event: {event.data}") + logging.info(f"Received event: {event.data}") event_data = json.loads(event.data) if "flow_output_streaming" in event_data: yield event_data["flow_output_streaming"] elif "flow_output" in event_data: logging.debug("Ignoring non-streaming response event.") else: - raise RequestException("Unexpected event payload from Azure ML endpoint. Missing 'flow_output_streaming' property.") + raise RequestException("Unexpected event payload from flow endpoint. Missing 'flow_output_streaming' property.") else: - raise RequestException("Unexpected non-streaming response received from Azure ML endpoint.") + raise RequestException("Unexpected non-streaming response received from flow endpoint.") except HTTPError as http_err: logging.error(f"HTTP error occurred: {http_err}") raise HTTPException( status_code=response.status_code, - detail=f"Error from Azure ML: {http_err.response.text}" + detail=f"Error from flow: {http_err.response.text}" ) except RequestException as req_err: logging.error(f"Request error occurred: {req_err}") diff --git a/app/api/services/issues_service.py b/app/api/services/issues_service.py index ebdaea9..b8ca36f 100644 --- a/app/api/services/issues_service.py +++ b/app/api/services/issues_service.py @@ -52,7 +52,7 @@ async def initiate_review(self, pdf_name: str, user: User, time_stamp: datetime) logging.info(f"Initiating review for document {pdf_name}") # Initiate review to get a stream of issues - stream_data = self.aml_client.call_aml_endpoint(settings.aml_endpoint_name, pdf_name) + stream_data = self.aml_client.call_aml_endpoint(settings.flow_endpoint_name, pdf_name) async for chunk in stream_data: flow_output = FlowOutputChunk.model_validate_json(chunk) issues = [ @@ -85,7 +85,7 @@ async def accept_issue( issue_id: The ID of the issue. doc_id: The ID of the document. user: The user object. - modified_fields: optional - fields modified by user. + modified_fields: optional - fields modified by user. """ try: issue = await self.issues_repository.get_issue(doc_id, issue_id) diff --git a/docs/getting_started.md b/docs/getting_started.md index b29f6b3..7991947 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -129,30 +129,22 @@ You can click each to view more details and accept or dismiss them, and optional 1. Create a virtualenv for the API if it doesn't already exist and install the required packages: ```bash - cd app/api - python3 -m venv .venv - source .venv/bin/activate - pip install -r requirements.txt + task app-build ``` 2. Select the new venv Python interpreter in Visual Studio Code `Ctrl+Shift+P` > `Python: Select Interpreter` > `app/api/.venv/bin/python3` -3. Install the required packages for the web UI: +3. Ensure you are logged in to Azure in the VS Code terminal so the API can retrieve required credentials ```bash - cd app/ui - npm install + az login --scope "{app-id-uri}/user_impersonation" ``` -4. Ensure you are logged in to Azure in the VS Code terminal so the API can retrieve required credentials +> The `app-id-uri` can be found by navigating to the Entra ID App Registration and viewing the adr-flow-adr-{name} application. - ```bash - az login - ``` - -5. Select the compound debug profile `App (UI & API)` in Visual Studio Code and press Debug +4. Select the compound debug profile `App (UI & API)` in Visual Studio Code and press Debug #### Debugging Tips diff --git a/flows/ai_doc_review/deployment.yaml b/flows/ai_doc_review/deployment.yaml deleted file mode 100644 index 87f24aa..0000000 --- a/flows/ai_doc_review/deployment.yaml +++ /dev/null @@ -1,36 +0,0 @@ -$schema: https://azuremlschemas.azureedge.net/latest/managedOnlineDeployment.schema.json -name: ai-doc-review-deployment -endpoint_name: VAR_ENDPOINT_NAME -model: azureml:ai-doc-review-model:VAR_MODEL_VERSION -environment: - build: - path: . - dockerfile_path: Dockerfile - # inference config is used to build a serving container for online deployments - inference_config: - liveness_route: - path: /health - port: 8080 - readiness_route: - path: /health - port: 8080 - scoring_route: - path: /score - port: 8080 -instance_type: Standard_DS3_v2 -instance_count: 1 -request_settings: - # 5 min timeout - request_timeout_ms: 300000 - max_concurrent_requests_per_instance: 10 -environment_variables: - # for pulling connections from workspace - PRT_CONFIG_OVERRIDE: deployment.subscription_id=VAR_SUBSCRIPTION_ID,deployment.resource_group=VAR_RESOURCE_GROUP,deployment.workspace_name=VAR_AI_HUB_PROJECT_NAME,deployment.endpoint_name=VAR_ENDPOINT_NAME,deployment.deployment_name=ai-doc-review-deployment - AZURE_CLIENT_ID: VAR_IDENTITY_CLIENT_ID - # (Optional) When there are multiple fields in the response, using this env variable will filter the fields to expose in the response. - # For example, if there are 2 flow outputs: "answer", "context", and I only want to have "answer" in the endpoint response, I can set this env variable to '["answer"]'. - # If you don't set this environment, by default all flow outputs will be included in the endpoint response. - PROMPTFLOW_RESPONSE_INCLUDED_FIELDS: '["flow_output_streaming"]' - DOCUMENT_INTELLIGENCE_ENDPOINT: VAR_DOCUMENT_INTELLIGENCE_ENDPOINT - STORAGE_URL_PREFIX: VAR_STORAGE_URL_PREFIX - AZURE_OPENAI_ENDPOINT: VAR_AZURE_OPENAI_ENDPOINT diff --git a/flows/ai_doc_review/flow.dag.yaml b/flows/ai_doc_review/flow.dag.yaml index b0008b5..fbb041b 100644 --- a/flows/ai_doc_review/flow.dag.yaml +++ b/flows/ai_doc_review/flow.dag.yaml @@ -7,7 +7,7 @@ environment_variables: environment: python_requirements_txt: requirements.txt additional_includes: -- ../../common +- ./common inputs: pdf_name: type: string diff --git a/flows/ai_doc_review/requirements.txt b/flows/ai_doc_review/requirements.txt index 3132e0c..9f565bb 100644 --- a/flows/ai_doc_review/requirements.txt +++ b/flows/ai_doc_review/requirements.txt @@ -2,10 +2,11 @@ azure-ai-formrecognizer==3.3.3 asttokens==2.4.1 json5==0.9.5 openai==1.43.0 -promptflow_typed_llm==0.0.8 shapely==2.0.6 pymupdf==1.24.11 promptflow==1.17.1 promptflow[azure]==1.17.1 promptflow-tools==1.4.0 +promptflow_typed_llm==0.0.8 httpx==0.27.2 +uvicorn[standard]==0.32.0 diff --git a/flows/ai_doc_review_eval/requirements.txt b/flows/ai_doc_review_eval/requirements.txt index fe2dc8a..9d67dd5 100755 --- a/flows/ai_doc_review_eval/requirements.txt +++ b/flows/ai_doc_review_eval/requirements.txt @@ -1,3 +1,3 @@ -fuzzywuzzy -pandas -scikit-learn \ No newline at end of file +fuzzywuzzy==0.18.0 +pandas==2.2.3 +scikit-learn==1.6.1 diff --git a/infra/entra.tf b/infra/entra.tf index c99fd4e..6c9d390 100644 --- a/infra/entra.tf +++ b/infra/entra.tf @@ -11,6 +11,8 @@ resource "azuread_service_principal" "storage" { resource "random_uuid" "api_app_api_scope_id" {} +resource "random_uuid" "flow_app_flow_scope_id" {} + resource "azuread_application" "api_app" { display_name = local.resource_name.aad_api_app owners = [data.azurerm_client_config.current.object_id] @@ -52,12 +54,11 @@ resource "azuread_application" "api_app" { redirect_uris = [ "http://localhost:8000/", "http://localhost:8000/oauth2-redirect", - "https://${local.resource_name.web_app}.azurewebsites.net/api", - "https://${local.resource_name.web_app}.azurewebsites.net/api/oauth2-redirect", + "https://${local.resource_name.web_api_app}.azurewebsites.net/api", + "https://${local.resource_name.web_api_app}.azurewebsites.net/api/oauth2-redirect", ] } } - resource "azuread_application" "client_app" { display_name = local.resource_name.aad_client_app owners = [data.azurerm_client_config.current.object_id] @@ -74,7 +75,7 @@ resource "azuread_application" "client_app" { single_page_application { redirect_uris = [ "http://localhost:5173/", - "https://${local.resource_name.web_app}.azurewebsites.net/" + "https://${local.resource_name.web_api_app}.azurewebsites.net/" ] } @@ -86,6 +87,48 @@ resource "azuread_application" "client_app" { ] } } +resource "azuread_application" "flow_app" { + display_name = local.resource_name.aad_flow_app + owners = [data.azurerm_client_config.current.object_id] + identifier_uris = ["api://adr-flow-${var.name}-${var.environment}"] + sign_in_audience = "AzureADMyOrg" + + required_resource_access { + resource_app_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph + + resource_access { + id = azuread_service_principal.msgraph.oauth2_permission_scope_ids["User.Read"] + type = "Scope" + } + } + + api { + oauth2_permission_scope { + admin_consent_description = "Allow the application to access data on behalf of the signed-in user." + admin_consent_display_name = "Access data" + enabled = true + id = random_uuid.flow_app_flow_scope_id.result + type = "User" + user_consent_description = "Allow the application to access data on your behalf." + user_consent_display_name = "Access data" + value = "user_impersonation" + } + known_client_applications = [azuread_application.client_app.client_id] + requested_access_token_version = 2 + } + + # https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_api_access + # prevent constant changes + lifecycle { + ignore_changes = [ + required_resource_access + ] + } +} + +resource "azuread_application_password" "flow_app" { + application_id = azuread_application.flow_app.id +} # Connection from Client -> API App resource "azuread_application_api_access" "api_connection" { @@ -123,7 +166,20 @@ resource "azuread_application_api_access" "storage_user_impersonation" { resource "azuread_service_principal" "api_app" { client_id = azuread_application.api_app.client_id } - resource "azuread_service_principal" "client_app" { client_id = azuread_application.client_app.client_id } +resource "azuread_service_principal" "flow_app" { + client_id = azuread_application.flow_app.client_id +} + +resource "azuread_application_federated_identity_credential" "flow_app" { + application_id = "/applications/${azuread_application.flow_app.object_id}" + + display_name = local.resource_name.umi_api + description = "Trust the workloads UAMI to impersonate the App" + + audiences = ["api://AzureADTokenExchange"] + issuer = "https://login.microsoftonline.com/${data.azurerm_client_config.current.tenant_id}/v2.0" + subject = azurerm_user_assigned_identity.api.principal_id +} diff --git a/infra/env_files.tf b/infra/env_files.tf index 163afdf..9599ef5 100644 --- a/infra/env_files.tf +++ b/infra/env_files.tf @@ -51,13 +51,16 @@ data "template_file" "api_env" { AAD_CLIENT_ID = azuread_application.api_app.client_id AAD_TENANT_ID = data.azurerm_client_config.current.tenant_id AAD_USER_IMPERSONATION_SCOPE_ID = "${tolist(azuread_application.api_app.identifier_uris)[0]}/user_impersonation" + AZURE_CLIENT_ID = azurerm_user_assigned_identity.api.client_id COSMOS_URL = azurerm_cosmosdb_account.main.endpoint DATABASE_NAME = azurerm_cosmosdb_sql_database.state.name SUBSCRIPTION_ID = data.azurerm_subscription.primary.subscription_id RESOURCE_GROUP = azurerm_resource_group.main.name AI_HUB_PROJECT_NAME = azapi_resource.ai_project.name AI_HUB_REGION = azapi_resource.ai_hub.location - AML_ENDPOINT_NAME = azapi_resource.ai_online_endpoint.name + FLOW_CLIENT_ID = azuread_application.flow_app.client_id + FLOW_ENDPOINT_NAME = azurerm_linux_web_app.flow.name + FLOW_APP_NAME = azuread_application.flow_app.display_name APPINSIGHTS_INSTRUMENTATION_KEY = azurerm_application_insights.main.instrumentation_key } depends_on = [ @@ -67,7 +70,6 @@ data "template_file" "api_env" { azurerm_resource_group.main, azapi_resource.ai_project, azapi_resource.ai_hub, - azapi_resource.ai_online_endpoint, azurerm_application_insights.main ] } diff --git a/infra/identities.tf b/infra/identities.tf index 879a1a1..afa8492 100644 --- a/infra/identities.tf +++ b/infra/identities.tf @@ -2,6 +2,7 @@ User Provides access for the current identity (user etc) to Storage Account */ + resource "azurerm_role_assignment" "deployer_file_share_contributor" { scope = azurerm_storage_account.main.id role_definition_name = "Storage File Data Privileged Contributor" @@ -24,6 +25,7 @@ resource "azurerm_cosmosdb_sql_role_assignment" "deployer_to_cosmos" { Group (ML Engineers) Provides access for the Security Group (ML Engineers) to Storage Account, AI Foundry and Cosmos DB */ + resource "azurerm_role_assignment" "ai_studio_developer" { for_each = var.ml_engineers scope = azapi_resource.ai_hub.id @@ -69,21 +71,22 @@ resource "azurerm_cosmosdb_sql_role_assignment" "cosmos_user" { Web App Provides access for App Services to access AI Foundry, Storage Account and CosmosDB */ + resource "azurerm_role_assignment" "app_to_prompt_flow" { scope = azapi_resource.ai_project.id role_definition_name = "AzureML Data Scientist" - principal_id = azurerm_linux_web_app.main.identity[0].principal_id + principal_id = azurerm_user_assigned_identity.api.principal_id } resource "azurerm_role_assignment" "app_to_storage" { scope = azurerm_storage_account.main.id role_definition_name = "Storage Blob Data Contributor" - principal_id = azurerm_linux_web_app.main.identity[0].principal_id + principal_id = azurerm_user_assigned_identity.api.principal_id } resource "azurerm_cosmosdb_sql_role_assignment" "webapp_to_cosmos" { resource_group_name = azurerm_resource_group.main.name account_name = azurerm_cosmosdb_account.main.name role_definition_id = data.azurerm_cosmosdb_sql_role_definition.data_contributor.id - principal_id = azurerm_linux_web_app.main.identity[0].principal_id + principal_id = azurerm_user_assigned_identity.api.principal_id scope = "${azurerm_cosmosdb_account.main.id}/dbs/${azurerm_cosmosdb_sql_database.state.name}" } @@ -91,6 +94,7 @@ resource "azurerm_cosmosdb_sql_role_assignment" "webapp_to_cosmos" { AI Foundry Provides access for AI Foundry to access Storage Account */ + resource "azurerm_role_assignment" "doc_intel_blob_data_contributor" { scope = azurerm_storage_account.main.id role_definition_name = "Storage Blob Data Contributor" @@ -102,17 +106,17 @@ resource "azurerm_role_assignment" "ai_ws_file_share_contributor" { principal_id = azapi_resource.ai_project.identity[0].principal_id } - /* - Managed Identity (User) - Provides access for Managed Identity (AI Compute) to access the required resources + Managed Identity + Provides access for AI Compute to access the required resources https://learn.microsoft.com/en-us/azure/machine-learning/prompt-flow/how-to-manage-compute-session */ + resource "azurerm_user_assigned_identity" "ai_compute" { resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location - name = "uid-${var.name}-${var.environment}" + name = local.resource_name.umi_ai_compute } resource "azurerm_role_assignment" "uid_to_ai_hub" { @@ -194,3 +198,14 @@ resource "azurerm_cosmosdb_sql_role_assignment" "uid_to_cosmos" { principal_id = azurerm_user_assigned_identity.ai_compute.principal_id scope = "${azurerm_cosmosdb_account.main.id}/dbs/${azurerm_cosmosdb_sql_database.state.name}" } + +/* + Managed Identity + Provides access for the App Services to access the resources +*/ + +resource "azurerm_user_assigned_identity" "api" { + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + name = local.resource_name.umi_api +} diff --git a/infra/locals.tf b/infra/locals.tf index 817dc53..6897d2a 100644 --- a/infra/locals.tf +++ b/infra/locals.tf @@ -6,15 +6,18 @@ locals { ai_services = "ais-${var.name}-${var.environment}" ai_hub = "aih-${var.name}-${var.environment}" ai_project = "aip-${var.name}-${var.environment}" - ai_online_endpoint = "ept-${var.name}-${var.environment}" storage_account = "str${var.name}${var.environment}" container_registry = "acr${var.name}${var.environment}" cosmos_db = "cdb-${var.name}-${var.environment}" app_service_plan = "asp-${var.name}-${var.environment}" - web_app = "app-${var.name}-${var.environment}" + web_api_app = "as-app-${var.name}-${var.environment}" + web_flow_app = "as-flw-${var.name}-${var.environment}" application_insights = "ai-${var.name}-${var.environment}" aad_api_app = "adr-api-${var.name}-${var.environment}" aad_client_app = "adr-client-${var.name}-${var.environment}" + aad_flow_app = "adr-flow-${var.name}-${var.environment}" + umi_ai_compute = "uid-aic-${var.name}-${var.environment}" + umi_api = "uid-api-${var.name}-${var.environment}" } common_tags = {} diff --git a/infra/outputs.tf b/infra/outputs.tf index 4cd167f..c50649c 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -1,51 +1,6 @@ -# Client App Vars -output "vite_tenant_id" { - value = data.azurerm_client_config.current.tenant_id -} - -output "vite_client_id" { - value = azuread_application.client_app.client_id -} - -output "vite_api_scope" { - value = "${tolist(azuread_application.api_app.identifier_uris)[0]}/user_impersonation" -} - -output "vite_storage_account" { - value = azurerm_storage_account.main.primary_blob_endpoint -} - -output "vite_storage_document_container" { - value = azurerm_storage_container.documents.name -} - # API App Vars output "webapp_name" { - value = azurerm_linux_web_app.main.name -} - -output "webapp_url" { - value = azurerm_linux_web_app.main.default_hostname -} - -output "aad_client_id" { - value = azuread_application.api_app.client_id -} - -output "aad_tenant_id" { - value = data.azurerm_client_config.current.tenant_id -} - -output "cosmos_url" { - value = azurerm_cosmosdb_account.main.endpoint -} - -output "database_name" { - value = azurerm_cosmosdb_sql_database.state.name -} - -output "subscription_id" { - value = data.azurerm_subscription.primary.subscription_id + value = azurerm_linux_web_app.app.name } output "resource_group" { @@ -56,35 +11,6 @@ output "ai_hub_project_name" { value = azapi_resource.ai_project.name } -output "ai_hub_region" { - value = azapi_resource.ai_hub.location -} - -output "aml_endpoint_name" { - value = azapi_resource.ai_online_endpoint.name -} - -output "document_intelligence_endpoint" { - value = "https://ais${var.name}${var.environment}.cognitiveservices.azure.com" -} - -output "azure_openai_endpoint" { - value = "https://ais${var.name}${var.environment}.openai.azure.com" -} - -output "storage_url_prefix" { - value = "${azurerm_storage_account.main.primary_blob_endpoint}/${azurerm_storage_container.documents.name}" -} - -output "appinsights_instrumentation_key" { - value = azurerm_application_insights.main.instrumentation_key - sensitive = true -} - -# Flow vars -output "identity_client_id" { - value = azurerm_user_assigned_identity.ai_compute.client_id -} -output "identity_resource_id" { - value = azurerm_user_assigned_identity.ai_compute.id +output "flowapp_name" { + value = azurerm_linux_web_app.flow.name } diff --git a/infra/resources.tf b/infra/resources.tf index 3e6661f..062ff7e 100644 --- a/infra/resources.tf +++ b/infra/resources.tf @@ -1,3 +1,8 @@ +/* + Resource + - Resource Group +*/ + resource "azurerm_resource_group" "main" { name = local.resource_name.resource_group location = var.location @@ -64,7 +69,6 @@ resource "azapi_resource" "nsp_association" { - AI Foundry - AI Hub - AI Project - - AI Online Endpoint - AI Connections */ @@ -85,7 +89,8 @@ resource "azapi_resource" "ai_services" { apiProperties = { statisticsEnabled = false, } - restore = contains([for service in data.azapi_resource_list.ai_services.output.value : service.name], local.resource_name.ai_services) + restore = contains([for service in data.azapi_resource_list.ai_services.output.value : service.name], local.resource_name.ai_services) + publicNetworkAccess = "Enabled" } kind = "AIServices" sku = { @@ -173,27 +178,6 @@ resource "azapi_resource" "ai_project" { tags = merge(local.common_tags, {}) } -resource "azapi_resource" "ai_online_endpoint" { - type = "Microsoft.MachineLearningServices/workspaces/onlineEndpoints@2024-10-01" - name = local.resource_name.ai_online_endpoint - parent_id = azapi_resource.ai_project.id - - body = { - properties = { - authMode = "AADToken" - } - kind = "Managed" - location = azurerm_resource_group.main.location - } - - identity { - type = "UserAssigned" - identity_ids = [azurerm_user_assigned_identity.ai_compute.id] - } - - response_export_values = ["*"] -} - resource "azapi_resource" "ai_services_connection" { type = "Microsoft.MachineLearningServices/workspaces/connections@2024-10-01" name = "aisconns" @@ -247,13 +231,13 @@ resource "azurerm_service_plan" "main" { resource_group_name = azurerm_resource_group.main.name os_type = "Linux" - sku_name = "B1" + sku_name = "P0v3" tags = merge(local.common_tags, {}) } -resource "azurerm_linux_web_app" "main" { - name = local.resource_name.web_app +resource "azurerm_linux_web_app" "app" { + name = local.resource_name.web_api_app location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name @@ -264,7 +248,8 @@ resource "azurerm_linux_web_app" "main" { webdeploy_publish_basic_authentication_enabled = false identity { - type = "SystemAssigned" + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.api.id] } site_config { @@ -285,19 +270,108 @@ resource "azurerm_linux_web_app" "main" { "DATABASE_NAME" = azurerm_cosmosdb_sql_database.state.name "AAD_CLIENT_ID" = azuread_application.api_app.client_id "AAD_TENANT_ID" = data.azurerm_client_config.current.tenant_id + "AZURE_CLIENT_ID" = azurerm_user_assigned_identity.api.client_id "SUBSCRIPTION_ID" = data.azurerm_subscription.primary.subscription_id "RESOURCE_GROUP" = azurerm_resource_group.main.name "AI_HUB_PROJECT_NAME" = azapi_resource.ai_project.name "AI_HUB_REGION" = azapi_resource.ai_hub.location - "AML_ENDPOINT_NAME" = azapi_resource.ai_online_endpoint.name - "AML_STREAMING_BATCH_SIZE" = 10 + "FLOW_CLIENT_ID" = azuread_application.flow_app.client_id + "FLOW_ENDPOINT_NAME" = azurerm_linux_web_app.flow.name + "FLOW_APP_NAME" = azuread_application.flow_app.display_name + "FLOW_STREAMING_BATCH_SIZE" = 100 "APPINSIGHTS_INSTRUMENTATION_KEY" = azurerm_application_insights.main.instrumentation_key "LOG_LEVEL" = "INFO" } + logs { + application_logs { + file_system_level = "Verbose" + } + http_logs { + file_system { + retention_in_days = "1" + retention_in_mb = "35" + } + } + detailed_error_messages = true + failed_request_tracing = true + } + tags = merge(local.common_tags, {}) } +resource "azurerm_linux_web_app" "flow" { + name = local.resource_name.web_flow_app + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + + service_plan_id = azurerm_service_plan.main.id + public_network_access_enabled = true + https_only = true + ftp_publish_basic_authentication_enabled = false + webdeploy_publish_basic_authentication_enabled = false + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.ai_compute.id] + } + + site_config { + websockets_enabled = true + app_command_line = "pf flow serve --source . --port 8000 --host 0.0.0.0 --engine fastapi --skip-open-browser" + + application_stack { + python_version = "3.12" + } + } + + app_settings = { + "SCM_DO_BUILD_DURING_DEPLOYMENT" = "true" + "DEBUG" = "True" + "DOCUMENT_INTELLIGENCE_ENDPOINT" = "https://ais${var.name}${var.environment}.cognitiveservices.azure.com" + "STORAGE_URL_PREFIX" = "${azurerm_storage_account.main.primary_blob_endpoint}/${azurerm_storage_container.documents.name}" + "AZURE_OPENAI_ENDPOINT" = "https://ais${var.name}${var.environment}.openai.azure.com" + "AZURE_CLIENT_ID" = azurerm_user_assigned_identity.ai_compute.client_id + "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET" = azuread_application_password.flow_app.value + "USER_AGENT" = "promptflow-appservice" + } + + auth_settings_v2 { + auth_enabled = true + require_authentication = true + unauthenticated_action = "Return401" + require_https = true + active_directory_v2 { + tenant_auth_endpoint = "https://login.microsoftonline.com/${data.azurerm_client_config.current.tenant_id}/v2.0" + client_id = azuread_application.flow_app.client_id + client_secret_setting_name = "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET" + allowed_audiences = ["${azuread_application.flow_app.client_id}"] + allowed_applications = [ + "${azuread_application.flow_app.client_id}", + "04b07795-8ddb-461a-bbee-02f9e1bf7b46" # Azure CLI + ] + www_authentication_disabled = true + } + login { + token_store_enabled = true + } + } + + logs { + application_logs { + file_system_level = "Verbose" + } + http_logs { + file_system { + retention_in_days = "1" + retention_in_mb = "35" + } + } + detailed_error_messages = true + failed_request_tracing = true + } +} + resource "azurerm_application_insights" "main" { name = local.resource_name.application_insights location = azurerm_resource_group.main.location @@ -320,7 +394,6 @@ resource "azurerm_key_vault" "main" { tags = merge(local.common_tags, {}) } - resource "azurerm_storage_account" "main" { name = local.resource_name.storage_account location = azurerm_resource_group.main.location @@ -344,7 +417,7 @@ resource "azurerm_storage_account" "main" { "https://ai.azure.com", "https://*.ai.azure.com", "http://localhost:5173", - "https://${local.resource_name.web_app}.azurewebsites.net" + "https://${local.resource_name.web_api_app}.azurewebsites.net" ] exposed_headers = ["*"] max_age_in_seconds = 3600