diff --git a/.github/workflows/AdminWebpage-Deploy-WF.yml b/.github/workflows/AdminWebpage-Deploy-WF.yml index aa82265..4d24a5b 100644 --- a/.github/workflows/AdminWebpage-Deploy-WF.yml +++ b/.github/workflows/AdminWebpage-Deploy-WF.yml @@ -22,17 +22,17 @@ permissions: contents: read env: - RESOURCE_GROUP: ewu-deliverybotsystem-rg - APP_SERVICE_NAME: WA-DeliveryBot-Admin-dev - BOTNET_API_URL: https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io - SIMULATOR_API_URL: https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io - ORDER_SERVICE_URL: https://deliverybot-order-service.mangocoast-332176b0.westus2.azurecontainerapps.io + RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} + APP_SERVICE_NAME: ${{ vars.ADMIN_APP_SERVICE_NAME || 'WA-DeliveryBot-Admin-dev' }} + BOTNET_API_URL: ${{ vars.VITE_BOTNET_API_URL || 'https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io' }} + SIMULATOR_API_URL: ${{ vars.VITE_SIMULATOR_API_URL || 'https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io' }} + ORDER_SERVICE_URL: ${{ vars.VITE_ORDER_SERVICE_URL || 'https://deliverybot-order-service.mangocoast-332176b0.westus2.azurecontainerapps.io' }} # Entra ID staff sign-in (issue #54). Blank → auth disabled (app runs open). # Fill these in from the app registration to switch sign-in on, then push. # Client/tenant/group IDs are not secrets (a public SPA exposes them anyway). - ENTRA_CLIENT_ID: "b5a029c3-d046-4005-9497-23ba18df70b2" - ENTRA_TENANT_ID: "37321907-14a5-4390-987d-ec0c66c655cd" - ENTRA_ADMIN_GROUP_ID: "14fcd995-e89f-4020-b5ff-4a9b48a5824e" + ENTRA_CLIENT_ID: ${{ vars.ENTRA_CLIENT_ID }} + ENTRA_TENANT_ID: ${{ vars.ENTRA_TENANT_ID }} + ENTRA_ADMIN_GROUP_ID: ${{ vars.ENTRA_ADMIN_GROUP_ID }} jobs: build-and-deploy: diff --git a/.github/workflows/CustomerWebpage-Deploy-WF.yml b/.github/workflows/CustomerWebpage-Deploy-WF.yml index aafb9fa..2bb1bac 100644 --- a/.github/workflows/CustomerWebpage-Deploy-WF.yml +++ b/.github/workflows/CustomerWebpage-Deploy-WF.yml @@ -44,10 +44,17 @@ jobs: - name: Lint application run: npm run lint + - name: Run unit tests + run: npm test + - name: Build application run: npm run build env: + VITE_API_MANAGEMENT_BASE_URL: ${{ vars.VITE_API_MANAGEMENT_BASE_URL }} + VITE_AGENT_API_URL: ${{ vars.VITE_AGENT_API_URL }} VITE_MAP_TILE_URL: ${{ vars.VITE_MAP_TILE_URL }} + VITE_ORDER_SERVICE_URL: ${{ vars.VITE_ORDER_SERVICE_URL }} + VITE_OSRM_API_URL: ${{ vars.VITE_OSRM_API_URL }} VITE_SIMULATOR_API_BASE: ${{ vars.VITE_SIMULATOR_API_BASE }} - name: Azure Login @@ -62,5 +69,5 @@ jobs: if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' uses: azure/webapps-deploy@v3 with: - app-name: WA-DeliveryBot-dev + app-name: ${{ vars.CUSTOMER_FRONTEND_APP_SERVICE_NAME || 'WA-DeliveryBot-dev' }} package: frontend/customer-webapp/dist diff --git a/.github/workflows/agentservice-deploy.yml b/.github/workflows/agentservice-deploy.yml new file mode 100644 index 0000000..d36476d --- /dev/null +++ b/.github/workflows/agentservice-deploy.yml @@ -0,0 +1,89 @@ +name: Build and Deploy Agent Service + +on: + push: + branches: [main] + paths: + - "AgentService/**" + - ".github/workflows/agentservice-deploy.yml" + pull_request: + branches: [main] + paths: + - "AgentService/**" + - ".github/workflows/agentservice-deploy.yml" + workflow_dispatch: + workflow_run: + workflows: ["Infrastructure — Apply All Services"] + types: + - completed + +permissions: + id-token: write + contents: read + +env: + RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} + ACR_NAME: ${{ vars.ACR_NAME || 'DeliverybotCR' }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER || 'deliverybotcr.azurecr.io' }} + CONTAINER_APP_NAME: ${{ vars.AGENT_SERVICE_CONTAINER_APP_NAME || 'deliverybot-agent-dev' }} + IMAGE_NAME: agentservice + +jobs: + build-and-deploy: + if: github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Run backend checks + run: | + dotnet build AgentService/AgentService/AgentService.csproj --configuration Release + dotnet test AgentService/AgentService.Tests/AgentService.Tests.csproj --configuration Release + + - name: Build Docker image for pull request + if: github.event_name == 'pull_request' + run: docker build -t agentservice-pr -f AgentService/AgentService/Dockerfile AgentService + + - name: Azure Login (OIDC) + if: github.event_name != 'pull_request' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Log in to Azure Container Registry + if: github.event_name != 'pull_request' + run: az acr login --name "$ACR_NAME" + + - name: Build and push Docker image + if: github.event_name != 'pull_request' + run: | + IMAGE_TAG="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${{ github.sha }}" + docker build -t "$IMAGE_TAG" -f AgentService/AgentService/Dockerfile AgentService + docker push "$IMAGE_TAG" + echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV" + + - name: Update Container App image + if: github.event_name != 'pull_request' + run: | + az containerapp update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --image "$IMAGE_TAG" + + - name: Print deployment URL + if: github.event_name != 'pull_request' + run: | + FQDN=$(az containerapp show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query properties.configuration.ingress.fqdn -o tsv) + echo "Agent Service live at: https://${FQDN}" + echo "POST https://${FQDN}/chat" diff --git a/.github/workflows/botNetApi-deploy.yml b/.github/workflows/botNetApi-deploy.yml index fd315f5..c0bf96a 100644 --- a/.github/workflows/botNetApi-deploy.yml +++ b/.github/workflows/botNetApi-deploy.yml @@ -13,18 +13,18 @@ permissions: contents: read env: - RESOURCE_GROUP: ewu-deliverybotsystem-rg + RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} # ACR — pre-created, admin credentials stored as Container App registry secret - ACR_NAME: DeliverybotCR - ACR_LOGIN_SERVER: deliverybotcr.azurecr.io + ACR_NAME: ${{ vars.ACR_NAME || 'DeliverybotCR' }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER || 'deliverybotcr.azurecr.io' }} # Container App — pre-created with system-assigned managed identity - CONTAINER_APP_NAME: ewu-deliverybotsystem-api + CONTAINER_APP_NAME: ${{ vars.BOT_API_CONTAINER_APP_NAME || 'ewu-deliverybotsystem-api' }} # Azure SQL — pre-created; Container App MI already has db_owner in BotNetApiDb - SQL_SERVER_NAME: deliverybotsystem-sql - SQL_DB_NAME: BotNetApiDb + SQL_SERVER_NAME: ${{ vars.BOT_API_SQL_SERVER_NAME || 'deliverybotsystem-sql' }} + SQL_DB_NAME: ${{ vars.BOT_API_SQL_DATABASE_NAME || 'BotNetApiDb' }} IMAGE_NAME: botnetapi diff --git a/.github/workflows/iac.yml b/.github/workflows/iac.yml index d4ea71c..3a9f0c0 100644 --- a/.github/workflows/iac.yml +++ b/.github/workflows/iac.yml @@ -30,8 +30,10 @@ permissions: contents: read env: - TFSTATE_STORAGE_ACCOUNT: dbstfstate01 - TFSTATE_CONTAINER: tfstate + TFSTATE_RESOURCE_GROUP: ${{ vars.TFSTATE_RESOURCE_GROUP || 'ewu-deliverybotsystem-rg' }} + TFSTATE_STORAGE_ACCOUNT: ${{ vars.TFSTATE_STORAGE_ACCOUNT || 'dbstfstate01' }} + TFSTATE_CONTAINER: ${{ vars.TFSTATE_CONTAINER || 'tfstate' }} + TFSTATE_KEY: ${{ vars.TFSTATE_KEY || 'deliverybot.tfstate' }} jobs: terraform: @@ -59,6 +61,26 @@ jobs: # Event Hub — shared by Order Service and Robot Simulator. TF_VAR_eventhub_connection_string: ${{ secrets.AZURE_EVENTHUB_CONNECTION_STRING }} + TF_VAR_azure_openai_endpoint: ${{ vars.AZURE_OPENAI_ENDPOINT }} + TF_VAR_azure_openai_deployment: ${{ vars.AZURE_OPENAI_DEPLOYMENT }} + TF_VAR_azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }} + TF_VAR_resource_group_name: ${{ vars.RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} + TF_VAR_acr_name: ${{ vars.ACR_NAME || 'DeliverybotCR' }} + TF_VAR_container_app_environment_name: ${{ vars.CONTAINER_APP_ENVIRONMENT_NAME || 'managedEnvironment-ewudeliverybots-aa2f' }} + TF_VAR_eventhub_namespace_name: ${{ vars.EVENTHUB_NAMESPACE_NAME || 'DeliverybotSimulator-EVHNS' }} + TF_VAR_existing_container_app_environment_resource_group_name: ${{ vars.CONTAINER_APP_ENVIRONMENT_RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} + TF_VAR_existing_app_service_plan_resource_group_name: ${{ vars.APP_SERVICE_PLAN_RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} + TF_VAR_create_container_app_environment: ${{ vars.CREATE_CONTAINER_APP_ENVIRONMENT || 'false' }} + TF_VAR_create_app_service_plan: ${{ vars.CREATE_APP_SERVICE_PLAN || 'false' }} + TF_VAR_app_service_plan_name: ${{ vars.APP_SERVICE_PLAN_NAME || 'ASP-RGDeliveryBotdev-8b82' }} + TF_VAR_customer_frontend_app_service_name: ${{ vars.CUSTOMER_FRONTEND_APP_SERVICE_NAME || 'WA-DeliveryBot-dev' }} + TF_VAR_admin_app_service_name: ${{ vars.ADMIN_APP_SERVICE_NAME || 'WA-DeliveryBot-Admin-dev' }} + TF_VAR_bot_api_container_app_name: ${{ vars.BOT_API_CONTAINER_APP_NAME || 'ewu-deliverybotsystem-api' }} + TF_VAR_order_service_container_app_name: ${{ vars.ORDER_SERVICE_CONTAINER_APP_NAME || 'deliverybot-order-service' }} + TF_VAR_simulator_container_app_name: ${{ vars.SIMULATOR_CONTAINER_APP_NAME || 'deliverybot-robot-simulator' }} + TF_VAR_bot_api_sql_server_name: ${{ vars.BOT_API_SQL_SERVER_NAME || 'deliverybotsystem-sql' }} + TF_VAR_botnet_api_url: ${{ vars.VITE_BOTNET_API_URL || 'https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io' }} + TF_VAR_simulator_api_url: ${{ vars.VITE_SIMULATOR_API_URL || 'https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io' }} steps: - name: Checkout repository @@ -112,12 +134,18 @@ jobs: terraform_version: "1.9.5" - name: Terraform Init - run: terraform init -input=false + run: | + terraform init -input=false \ + -backend-config="resource_group_name=$TFSTATE_RESOURCE_GROUP" \ + -backend-config="storage_account_name=$TFSTATE_STORAGE_ACCOUNT" \ + -backend-config="container_name=$TFSTATE_CONTAINER" \ + -backend-config="key=$TFSTATE_KEY" \ + -backend-config="use_oidc=true" \ + -backend-config="use_azuread_auth=true" - name: Terraform Plan run: terraform plan -input=false -out=tfplan - # Apply only on merge to main — PRs stop at plan for review. - name: Terraform Apply - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') run: terraform apply -input=false tfplan diff --git a/.github/workflows/orderservice-deploy.yml b/.github/workflows/orderservice-deploy.yml index c87bc50..7dc7487 100644 --- a/.github/workflows/orderservice-deploy.yml +++ b/.github/workflows/orderservice-deploy.yml @@ -24,10 +24,10 @@ permissions: contents: read env: - RESOURCE_GROUP: ewu-deliverybotsystem-rg - ACR_NAME: DeliverybotCR - ACR_LOGIN_SERVER: deliverybotcr.azurecr.io - CONTAINER_APP_NAME: deliverybot-order-service + RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} + ACR_NAME: ${{ vars.ACR_NAME || 'DeliverybotCR' }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER || 'deliverybotcr.azurecr.io' }} + CONTAINER_APP_NAME: ${{ vars.ORDER_SERVICE_CONTAINER_APP_NAME || 'deliverybot-order-service' }} IMAGE_NAME: orderservice jobs: diff --git a/.github/workflows/readable-bot-network-update.yml b/.github/workflows/readable-bot-network-update.yml index 52ebbb8..ded1743 100644 --- a/.github/workflows/readable-bot-network-update.yml +++ b/.github/workflows/readable-bot-network-update.yml @@ -30,12 +30,12 @@ permissions: contents: read env: - RESOURCE_GROUP: ewu-deliverybotsystem-rg - FUNCTION_APP_NAME: deliverybot-rbnr-dev-rbnr-func-mtgpw6 - COSMOS_ACCOUNT_NAME: deliverybot-rbnr-dev-rbnr-mtgpw6 - COSMOS_DATABASE_NAME: bot-network - BOTS_CONTAINER_NAME: bots - DIAGNOSTICS_CONTAINER_NAME: function-diagnostics + RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} + FUNCTION_APP_NAME: ${{ vars.READABLE_BOT_NETWORK_FUNCTION_APP_NAME || 'deliverybot-rbnr-dev-rbnr-func-mtgpw6' }} + COSMOS_ACCOUNT_NAME: ${{ vars.READABLE_BOT_NETWORK_COSMOS_ACCOUNT_NAME || 'deliverybot-rbnr-dev-rbnr-mtgpw6' }} + COSMOS_DATABASE_NAME: ${{ vars.READABLE_BOT_NETWORK_COSMOS_DATABASE_NAME || 'bot-network' }} + BOTS_CONTAINER_NAME: ${{ vars.READABLE_BOT_NETWORK_COSMOS_CONTAINER_NAME || 'bots' }} + DIAGNOSTICS_CONTAINER_NAME: ${{ vars.READABLE_BOT_NETWORK_DIAGNOSTICS_CONTAINER_NAME || 'function-diagnostics' }} jobs: update-cosmos: diff --git a/.github/workflows/simulator-deploy.yml b/.github/workflows/simulator-deploy.yml index 7a67d5e..c561016 100644 --- a/.github/workflows/simulator-deploy.yml +++ b/.github/workflows/simulator-deploy.yml @@ -12,14 +12,14 @@ permissions: contents: read env: - RESOURCE_GROUP: ewu-deliverybotsystem-rg + RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP_NAME || 'ewu-deliverybotsystem-rg' }} # ACR — pre-created; admin credentials stored as Container App registry secret - ACR_NAME: DeliverybotCR - ACR_LOGIN_SERVER: deliverybotcr.azurecr.io + ACR_NAME: ${{ vars.ACR_NAME || 'DeliverybotCR' }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER || 'deliverybotcr.azurecr.io' }} # Container App — pre-created with system-assigned managed identity - CONTAINER_APP_NAME: deliverybot-robot-simulator + CONTAINER_APP_NAME: ${{ vars.SIMULATOR_CONTAINER_APP_NAME || 'deliverybot-robot-simulator' }} IMAGE_NAME: deliverybot-robot-simulator diff --git a/.gitignore b/.gitignore index b80ce98..413272c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,12 @@ Thumbs.db *.swp *.swo *~ +.dotnet/ # VS Code workspace settings (keep launch/tasks, ignore local overrides) .vscode/settings.json .vscode/*.code-workspace + +# .NET build output +**/bin/ +**/obj/ diff --git a/AgentService/AgentService.Tests/AgentService.Tests.csproj b/AgentService/AgentService.Tests/AgentService.Tests.csproj new file mode 100644 index 0000000..0fbebe6 --- /dev/null +++ b/AgentService/AgentService.Tests/AgentService.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/AgentService/AgentService.Tests/Program.cs b/AgentService/AgentService.Tests/Program.cs new file mode 100644 index 0000000..4cdb710 --- /dev/null +++ b/AgentService/AgentService.Tests/Program.cs @@ -0,0 +1,613 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using AgentService.DTOs; +using AgentService.Options; +using AgentService.Services; +using Azure.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace AgentService.Tests; + +public sealed class AzureOpenAiAgentServiceTests +{ + private static readonly AgentChatRequestDto Request = new() + { + Message = "What is the ETA?", + Context = new AgentChatContextDto + { + LatestOrder = new AgentLatestOrderDto + { + Id = "mock-178", + Status = "Assigned", + AssignedBotId = "bot-002", + DeliveryAddress = "Spokane Convention Center", + ItemsSummary = "water x1, chips x2" + }, + Route = new AgentRouteDto + { + Distance = "1.8 km", + Eta = "9 min", + Source = "osrm" + } + }, + History = + [ + new AgentChatMessageDto + { + Role = "assistant", + Text = "I can help with your latest order." + }, + new AgentChatMessageDto + { + Role = "user", + Text = "Where is the delivery going?" + } + ] + }; + + private static readonly AgentChatRequestDto RequestWithoutHistory = new() + { + Message = "What is the ETA?", + Context = new AgentChatContextDto + { + LatestOrder = new AgentLatestOrderDto + { + Id = "mock-178", + Status = "Assigned", + AssignedBotId = "bot-002", + DeliveryAddress = "Spokane Convention Center", + ItemsSummary = "water x1, chips x2" + }, + Route = new AgentRouteDto + { + Distance = "1.8 km", + Eta = "9 min", + Source = "osrm" + } + } + }; + + [Fact] + public void BuildUserPrompt_IncludesQuestionAndContext() + { + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(Request); + + Assert.Contains("What is the ETA?", prompt, StringComparison.OrdinalIgnoreCase); + Assert.Contains("bot-002", prompt, StringComparison.OrdinalIgnoreCase); + Assert.Contains("9 min", prompt, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Spokane Convention Center", prompt, StringComparison.OrdinalIgnoreCase); + Assert.Contains("water x1, chips x2", prompt, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildUserPrompt_IncludesRecentHistory() + { + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(Request); + + Assert.Contains("assistant: I can help with your latest order.", prompt, StringComparison.OrdinalIgnoreCase); + Assert.Contains("user: Where is the delivery going?", prompt, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildUserPrompt_HandlesMissingHistory() + { + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(RequestWithoutHistory); + + Assert.Contains("No earlier conversation is available.", prompt, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildUserPrompt_IncludesLiveServiceNotes() + { + var request = new AgentChatRequestDto + { + Message = Request.Message, + Context = new AgentChatContextDto + { + LatestOrder = Request.Context?.LatestOrder, + Route = Request.Context?.Route, + LiveDataSummary = "- Live order status: InTransit" + }, + History = Request.History + }; + + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(request); + + Assert.Contains("Live service data:", prompt, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Live order status: InTransit", prompt, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildUserPrompt_IncludesGroundingSummary() + { + var request = new AgentChatRequestDto + { + Message = Request.Message, + Context = new AgentChatContextDto + { + LatestOrder = Request.Context?.LatestOrder, + Route = Request.Context?.Route, + GroundingSummary = "- [1] Late Delivery Escalation (Support escalation policy): Send late deliveries to support." + }, + History = Request.History + }; + + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(request); + + Assert.Contains("Knowledge base grounding:", prompt, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Late Delivery Escalation", prompt, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildRequestBody_IncludesHistoryAndSettings() + { + var body = JsonSerializer.Serialize( + AzureOpenAiChatMapper.BuildRequestBody(Request, MakeOptions())); + + Assert.Contains("\"temperature\":0.2", body, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Where is the delivery going?", body, StringComparison.OrdinalIgnoreCase); + Assert.Contains("water x1, chips x2", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ExtractReply_ReadsFirstChoiceContent() + { + using var document = JsonDocument.Parse(""" + { + "model": "gpt-4.1-mini", + "choices": [ + { + "message": { + "content": "The current ETA is about 9 min." + } + } + ] + } + """); + + Assert.Equal("The current ETA is about 9 min.", AzureOpenAiChatMapper.ExtractReply(document)); + Assert.Equal("gpt-4.1-mini", AzureOpenAiChatMapper.ExtractModel(document)); + } + + [Fact] + public async Task ChatAsync_ThrowsWhenAzureOpenAiIsNotConfigured() + { + var service = CreateService(_ => new HttpResponseMessage(HttpStatusCode.OK), new AzureOpenAiOptions()); + + var error = await Assert.ThrowsAsync(() => service.ChatAsync(Request)); + + Assert.Contains("Azure OpenAI is not configured", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ChatAsync_UsesApiKeyHeader_WhenApiKeyConfigured() + { + string? headerValue = null; + + var service = CreateService( + request => + { + request.Headers.TryGetValues("api-key", out var values); + headerValue = values?.SingleOrDefault(); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "choices": [ + { + "message": { + "content": "The current ETA is about 9 min." + } + } + ] + } + """) + }; + }, + MakeOptions()); + + await service.ChatAsync(Request); + + Assert.Equal("test-key", headerValue); + } + + [Fact] + public async Task ChatAsync_ArchivesSuccessfulResponses() + { + var archive = new RecordingChatTranscriptArchive(); + var service = CreateService(_ => JsonResponse(""" + { + "choices": [ + { + "message": { + "content": "The current ETA is about 9 min." + } + } + ] + } + """), + MakeOptions(), + archive: archive); + + await service.ChatAsync(Request); + + Assert.Single(archive.Records); + Assert.Equal("mock-178", archive.Records[0].RelatedOrderId); + } + + [Fact] + public async Task ChatAsync_PublishesEscalationForSupportRequest() + { + var publisher = new RecordingSupportEscalationPublisher(); + var request = new AgentChatRequestDto + { + Message = "This delivery is late. I need support.", + Context = Request.Context + }; + var service = CreateService(_ => JsonResponse(""" + { + "choices": [ + { + "message": { + "content": "I am sending this to support." + } + } + ] + } + """), + MakeOptions(), + supportEscalationPublisher: publisher); + + await service.ChatAsync(request); + + Assert.Single(publisher.Records); + Assert.Equal("customer-request", publisher.Records[0].Reason); + } + + [Fact] + public async Task ChatAsync_ContinuesWhenArchiveAndEscalationFail() + { + var service = CreateService(_ => JsonResponse(""" + { + "choices": [ + { + "message": { + "content": "I am sending this to support." + } + } + ] + } + """), + MakeOptions(), + archive: new ThrowingChatTranscriptArchive(), + supportEscalationPublisher: new ThrowingSupportEscalationPublisher()); + + var result = await service.ChatAsync(new AgentChatRequestDto + { + Message = "My delivery is late", + Context = Request.Context + }); + + Assert.Equal("I am sending this to support.", result.Reply); + } + + [Fact] + public async Task ChatAsync_EnrichesRequestWithLiveOrderAndBotData() + { + string? openAiRequestBody = null; + var request = new AgentChatRequestDto + { + Message = "What is my order status?", + Context = new AgentChatContextDto + { + LatestOrder = new AgentLatestOrderDto + { + Id = "11111111-1111-1111-1111-111111111111" + } + } + }; + + var service = CreateService( + httpRequest => + { + var path = httpRequest.RequestUri?.AbsolutePath ?? ""; + + if (path.Contains("/api/orders/", StringComparison.OrdinalIgnoreCase)) + { + return JsonResponse(""" + { + "id": "11111111-1111-1111-1111-111111111111", + "customerId": "customer-1", + "assignedBotId": "bot-007", + "status": "InTransit", + "deliveryAddress": "123 Riverfront Ave", + "items": [ + { "itemId": "water", "quantity": 1 }, + { "itemId": "chips", "quantity": 2 } + ] + } + """); + } + + if (path.EndsWith("/bots/bot-007", StringComparison.OrdinalIgnoreCase)) + { + return JsonResponse(""" + { + "botId": "bot-007", + "status": "OnDelivery", + "powerLevel": 83.6, + "queuedOrderCount": 1, + "activeOrderId": "11111111-1111-1111-1111-111111111111", + "currentLocation": { + "latitude": 47.661, + "longitude": -117.42 + } + } + """); + } + + openAiRequestBody = httpRequest.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + return JsonResponse(""" + { + "model": "gpt-4.1-mini", + "choices": [ + { + "message": { + "content": "Your order is on the way." + } + } + ] + } + """); + }, + MakeOptions(), + new AgentIntegrationOptions + { + OrderServiceBaseUrl = "https://orders.example.test", + SimulatorBaseUrl = "https://simulator.example.test" + }); + + await service.ChatAsync(request); + + Assert.Equal("InTransit", request.Context?.LatestOrder?.Status); + Assert.Equal("bot-007", request.Context?.LatestOrder?.AssignedBotId); + Assert.Equal("123 Riverfront Ave", request.Context?.LatestOrder?.DeliveryAddress); + Assert.Equal("water x1, chips x2", request.Context?.LatestOrder?.ItemsSummary); + Assert.Contains("Live order status: InTransit", request.Context?.LiveDataSummary ?? "", StringComparison.OrdinalIgnoreCase); + Assert.Contains("Live bot status: OnDelivery", request.Context?.LiveDataSummary ?? "", StringComparison.OrdinalIgnoreCase); + Assert.Contains("Live bot battery: 84%", request.Context?.LiveDataSummary ?? "", StringComparison.OrdinalIgnoreCase); + Assert.Contains("Live bot queued orders: 1", openAiRequestBody ?? "", StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ChatAsync_ToleratesLiveServiceLookupFailures() + { + var request = new AgentChatRequestDto + { + Message = "Summarize my delivery", + Context = new AgentChatContextDto + { + LatestOrder = new AgentLatestOrderDto + { + Id = "22222222-2222-2222-2222-222222222222", + Status = "Assigned" + } + } + }; + + var service = CreateService( + httpRequest => + { + var path = httpRequest.RequestUri?.AbsolutePath ?? ""; + + if (path.Contains("/api/orders/", StringComparison.OrdinalIgnoreCase)) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"error\":\"down\"}") + }; + } + + return JsonResponse(""" + { + "choices": [ + { + "message": { + "content": "I only have limited live data right now." + } + } + ] + } + """); + }, + MakeOptions(), + new AgentIntegrationOptions + { + OrderServiceBaseUrl = "https://orders.example.test", + SimulatorBaseUrl = "https://simulator.example.test" + }); + + var result = await service.ChatAsync(request); + + Assert.Equal("I only have limited live data right now.", result.Reply); + Assert.Equal("Assigned", request.Context?.LatestOrder?.Status); + Assert.Null(request.Context?.LiveDataSummary); + } + + [Fact] + public async Task ChatAsync_ReturnsReplyAndModel_WhenAzureOpenAiSucceeds() + { + var service = CreateService(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "model": "gpt-4.1-mini", + "choices": [ + { + "message": { + "content": "The current ETA is about 9 min." + } + } + ] + } + """) + }, + MakeOptions()); + + var result = await service.ChatAsync(Request); + + Assert.Equal("The current ETA is about 9 min.", result.Reply); + Assert.Equal("azure-openai", result.Source); + Assert.Equal("gpt-4.1-mini", result.Model); + } + + [Fact] + public async Task ChatAsync_ThrowsWhenAzureOpenAiReturnsError() + { + var service = CreateService(_ => + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"error\":\"bad request\"}") + }, + MakeOptions()); + + var error = await Assert.ThrowsAsync(() => service.ChatAsync(Request)); + + Assert.Contains("HTTP 400", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ChatAsync_PostsToAzureOpenAiChatCompletionsEndpoint() + { + Uri? requestedUri = null; + + var service = CreateService( + request => + { + requestedUri = request.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "choices": [ + { + "message": { + "content": "ready" + } + } + ] + } + """) + }; + }, + MakeOptions()); + + await service.ChatAsync(Request); + + Assert.NotNull(requestedUri); + Assert.Contains("/openai/deployments/delivery-agent/chat/completions", requestedUri!.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("api-version=2024-10-21", requestedUri.ToString(), StringComparison.OrdinalIgnoreCase); + } + + private static AzureOpenAiAgentService CreateService( + Func respond, + AzureOpenAiOptions options) + { + return CreateService(respond, options, new AgentIntegrationOptions()); + } + + private static AzureOpenAiAgentService CreateService( + Func respond, + AzureOpenAiOptions options, + AgentIntegrationOptions? integrationOptions = null, + IAgentGroundingService? groundingService = null, + IChatTranscriptArchive? archive = null, + ISupportEscalationPublisher? supportEscalationPublisher = null) + { + var httpClient = new HttpClient(new FakeHandler(respond)); + return new AzureOpenAiAgentService( + httpClient, + Microsoft.Extensions.Options.Options.Create(options), + Microsoft.Extensions.Options.Options.Create(integrationOptions ?? new AgentIntegrationOptions()), + new StaticAzureOpenAiApiKeyProvider(options.ApiKey), + groundingService ?? new NoOpAgentGroundingService(), + archive ?? new NoOpChatTranscriptArchive(), + supportEscalationPublisher ?? new NoOpSupportEscalationPublisher(), + new StaticTokenCredential(), + NullLogger.Instance); + } + + private static AzureOpenAiOptions MakeOptions() => new() + { + Endpoint = "https://deliverybot-openai.openai.azure.com", + Deployment = "delivery-agent", + ApiKey = "test-key", + ApiVersion = "2024-10-21" + }; + + private static HttpResponseMessage JsonResponse(string json) => + new(HttpStatusCode.OK) + { + Content = new StringContent(json) + }; + + private sealed class FakeHandler(Func respond) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(respond(request)); + } + + private sealed class StaticAzureOpenAiApiKeyProvider(string? apiKey) : IAzureOpenAiApiKeyProvider + { + public Task GetApiKeyAsync(CancellationToken cancellationToken = default) => + Task.FromResult(apiKey); + } + + private sealed class StaticTokenCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) => + new("test-token", DateTimeOffset.UtcNow.AddMinutes(30)); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) => + ValueTask.FromResult(GetToken(requestContext, cancellationToken)); + } + + private sealed class RecordingChatTranscriptArchive : IChatTranscriptArchive + { + public List Records { get; } = []; + + public Task ArchiveAsync(AgentChatTranscriptRecord transcript, CancellationToken cancellationToken = default) + { + Records.Add(transcript); + return Task.CompletedTask; + } + } + + private sealed class ThrowingChatTranscriptArchive : IChatTranscriptArchive + { + public Task ArchiveAsync(AgentChatTranscriptRecord transcript, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("archive failed"); + } + + private sealed class RecordingSupportEscalationPublisher : ISupportEscalationPublisher + { + public List Records { get; } = []; + + public Task PublishAsync(SupportEscalationRecord escalation, CancellationToken cancellationToken = default) + { + Records.Add(escalation); + return Task.CompletedTask; + } + } + + private sealed class ThrowingSupportEscalationPublisher : ISupportEscalationPublisher + { + public Task PublishAsync(SupportEscalationRecord escalation, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("publish failed"); + } +} diff --git a/AgentService/AgentService/.gitignore b/AgentService/AgentService/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/AgentService/AgentService/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/AgentService/AgentService/AgentService.csproj b/AgentService/AgentService/AgentService.csproj new file mode 100644 index 0000000..8e7f7d4 --- /dev/null +++ b/AgentService/AgentService/AgentService.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + PreserveNewest + + + + diff --git a/AgentService/AgentService/Controllers/AgentController.cs b/AgentService/AgentService/Controllers/AgentController.cs new file mode 100644 index 0000000..3f89784 --- /dev/null +++ b/AgentService/AgentService/Controllers/AgentController.cs @@ -0,0 +1,47 @@ +using AgentService.DTOs; +using AgentService.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AgentService.Controllers; + +[ApiController] +[Route("")] +[Route("api/agent")] +public sealed class AgentController : ControllerBase +{ + private readonly IAgentService _agentService; + + public AgentController(IAgentService agentService) + { + _agentService = agentService; + } + + [HttpPost("chat")] + public async Task> Chat( + [FromBody] AgentChatRequestDto request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Message)) + { + return BadRequest(new ProblemDetails + { + Title = "Invalid request", + Detail = "Message is required." + }); + } + + try + { + var response = await _agentService.ChatAsync(request, cancellationToken); + return Ok(response); + } + catch (InvalidOperationException error) + { + return StatusCode(StatusCodes.Status502BadGateway, new ProblemDetails + { + Title = "Agent request failed", + Detail = error.Message + }); + } + } +} diff --git a/AgentService/AgentService/DTOs/AgentChatContextDto.cs b/AgentService/AgentService/DTOs/AgentChatContextDto.cs new file mode 100644 index 0000000..c00f5be --- /dev/null +++ b/AgentService/AgentService/DTOs/AgentChatContextDto.cs @@ -0,0 +1,9 @@ +namespace AgentService.DTOs; + +public sealed class AgentChatContextDto +{ + public AgentLatestOrderDto? LatestOrder { get; set; } + public AgentRouteDto? Route { get; set; } + public string? LiveDataSummary { get; set; } + public string? GroundingSummary { get; set; } +} diff --git a/AgentService/AgentService/DTOs/AgentChatMessageDto.cs b/AgentService/AgentService/DTOs/AgentChatMessageDto.cs new file mode 100644 index 0000000..627f8bd --- /dev/null +++ b/AgentService/AgentService/DTOs/AgentChatMessageDto.cs @@ -0,0 +1,7 @@ +namespace AgentService.DTOs; + +public sealed class AgentChatMessageDto +{ + public string Role { get; set; } = ""; + public string Text { get; set; } = ""; +} diff --git a/AgentService/AgentService/DTOs/AgentChatRequestDto.cs b/AgentService/AgentService/DTOs/AgentChatRequestDto.cs new file mode 100644 index 0000000..d5e201f --- /dev/null +++ b/AgentService/AgentService/DTOs/AgentChatRequestDto.cs @@ -0,0 +1,8 @@ +namespace AgentService.DTOs; + +public sealed class AgentChatRequestDto +{ + public string Message { get; set; } = ""; + public AgentChatContextDto? Context { get; set; } + public IReadOnlyList History { get; set; } = []; +} diff --git a/AgentService/AgentService/DTOs/AgentChatResponseDto.cs b/AgentService/AgentService/DTOs/AgentChatResponseDto.cs new file mode 100644 index 0000000..980795a --- /dev/null +++ b/AgentService/AgentService/DTOs/AgentChatResponseDto.cs @@ -0,0 +1,8 @@ +namespace AgentService.DTOs; + +public sealed class AgentChatResponseDto +{ + public string Reply { get; set; } = ""; + public string Source { get; set; } = "azure-openai"; + public string? Model { get; set; } +} diff --git a/AgentService/AgentService/DTOs/AgentLatestOrderDto.cs b/AgentService/AgentService/DTOs/AgentLatestOrderDto.cs new file mode 100644 index 0000000..d855e24 --- /dev/null +++ b/AgentService/AgentService/DTOs/AgentLatestOrderDto.cs @@ -0,0 +1,10 @@ +namespace AgentService.DTOs; + +public sealed class AgentLatestOrderDto +{ + public string? Id { get; set; } + public string? Status { get; set; } + public string? AssignedBotId { get; set; } + public string? DeliveryAddress { get; set; } + public string? ItemsSummary { get; set; } +} diff --git a/AgentService/AgentService/DTOs/AgentRouteDto.cs b/AgentService/AgentService/DTOs/AgentRouteDto.cs new file mode 100644 index 0000000..da0819f --- /dev/null +++ b/AgentService/AgentService/DTOs/AgentRouteDto.cs @@ -0,0 +1,8 @@ +namespace AgentService.DTOs; + +public sealed class AgentRouteDto +{ + public string? Distance { get; set; } + public string? Eta { get; set; } + public string? Source { get; set; } +} diff --git a/AgentService/AgentService/Dockerfile b/AgentService/AgentService/Dockerfile new file mode 100644 index 0000000..9b8807f --- /dev/null +++ b/AgentService/AgentService/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ["AgentService/AgentService.csproj", "AgentService/"] +RUN dotnet restore "AgentService/AgentService.csproj" + +COPY . . +WORKDIR "/src/AgentService" +RUN dotnet build "AgentService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "AgentService.csproj" -c Release -o /app/publish --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "AgentService.dll"] diff --git a/AgentService/AgentService/KnowledgeBase/search-documents.json b/AgentService/AgentService/KnowledgeBase/search-documents.json new file mode 100644 index 0000000..88e4823 --- /dev/null +++ b/AgentService/AgentService/KnowledgeBase/search-documents.json @@ -0,0 +1,30 @@ +[ + { + "id": "delivery-eta-policy", + "title": "Delivery ETA Guidance", + "source": "DeliveryBot support playbook", + "category": "customer-support", + "content": "When customers ask about ETA, answer from live order, route, and bot status. If live ETA is unavailable, explain that the assistant can see order status but not a reliable minute-by-minute estimate." + }, + { + "id": "bot-assignment-policy", + "title": "Bot Assignment Guidance", + "source": "DeliveryBot operations notes", + "category": "fleet-operations", + "content": "A delivery may show no assigned bot while the order is still pending. Once a bot is assigned, provide the bot id and current bot status from live data instead of inventing a location." + }, + { + "id": "late-delivery-escalation", + "title": "Late Delivery Escalation", + "source": "Support escalation policy", + "category": "escalation", + "content": "If a customer reports a late, stuck, failed, broken, or missing delivery, apologize briefly, summarize the known order details, and tell them the issue is being sent to support for follow-up." + }, + { + "id": "refund-cancellation-policy", + "title": "Refund And Cancellation Handling", + "source": "Support escalation policy", + "category": "escalation", + "content": "Refunds, cancellations, complaints, and requests for a human should be escalated. The assistant may collect the order id and issue summary but should not promise a refund outcome." + } +] diff --git a/AgentService/AgentService/Models/LiveBotSnapshot.cs b/AgentService/AgentService/Models/LiveBotSnapshot.cs new file mode 100644 index 0000000..c69aa09 --- /dev/null +++ b/AgentService/AgentService/Models/LiveBotSnapshot.cs @@ -0,0 +1,17 @@ +namespace AgentService.Models; + +public sealed class LiveBotSnapshot +{ + public string? BotId { get; set; } + public string? Status { get; set; } + public double? PowerLevel { get; set; } + public LiveBotLocationSnapshot? CurrentLocation { get; set; } + public string? ActiveOrderId { get; set; } + public int? QueuedOrderCount { get; set; } +} + +public sealed class LiveBotLocationSnapshot +{ + public double Latitude { get; set; } + public double Longitude { get; set; } +} diff --git a/AgentService/AgentService/Models/LiveOrderSnapshot.cs b/AgentService/AgentService/Models/LiveOrderSnapshot.cs new file mode 100644 index 0000000..d05692c --- /dev/null +++ b/AgentService/AgentService/Models/LiveOrderSnapshot.cs @@ -0,0 +1,17 @@ +namespace AgentService.Models; + +public sealed class LiveOrderSnapshot +{ + public string? Id { get; set; } + public string? CustomerId { get; set; } + public string? AssignedBotId { get; set; } + public string? Status { get; set; } + public string? DeliveryAddress { get; set; } + public List Items { get; set; } = []; +} + +public sealed class LiveOrderItemSnapshot +{ + public string? ItemId { get; set; } + public int Quantity { get; set; } +} diff --git a/AgentService/AgentService/Options/AgentIntegrationOptions.cs b/AgentService/AgentService/Options/AgentIntegrationOptions.cs new file mode 100644 index 0000000..7378071 --- /dev/null +++ b/AgentService/AgentService/Options/AgentIntegrationOptions.cs @@ -0,0 +1,9 @@ +namespace AgentService.Options; + +public sealed class AgentIntegrationOptions +{ + public const string SectionName = "Integrations"; + + public string OrderServiceBaseUrl { get; set; } = ""; + public string SimulatorBaseUrl { get; set; } = ""; +} diff --git a/AgentService/AgentService/Options/AzureAiSearchOptions.cs b/AgentService/AgentService/Options/AzureAiSearchOptions.cs new file mode 100644 index 0000000..b13c7f4 --- /dev/null +++ b/AgentService/AgentService/Options/AzureAiSearchOptions.cs @@ -0,0 +1,17 @@ +namespace AgentService.Options; + +public sealed class AzureAiSearchOptions +{ + public const string SectionName = "Search"; + + public bool Enabled { get; set; } + public string Endpoint { get; set; } = ""; + public string IndexName { get; set; } = "delivery-agent-knowledge"; + public int Top { get; set; } = 3; + public string SeedDocumentsPath { get; set; } = "KnowledgeBase/search-documents.json"; + + public bool IsConfigured() => + Enabled && + !string.IsNullOrWhiteSpace(Endpoint) && + !string.IsNullOrWhiteSpace(IndexName); +} diff --git a/AgentService/AgentService/Options/AzureOpenAiOptions.cs b/AgentService/AgentService/Options/AzureOpenAiOptions.cs new file mode 100644 index 0000000..c8dd552 --- /dev/null +++ b/AgentService/AgentService/Options/AzureOpenAiOptions.cs @@ -0,0 +1,18 @@ +namespace AgentService.Options; + +public sealed class AzureOpenAiOptions +{ + public const string SectionName = "AzureOpenAI"; + + public string Endpoint { get; set; } = ""; + public string Deployment { get; set; } = ""; + public string ApiKey { get; set; } = ""; + public string ApiKeySecretName { get; set; } = ""; + public string ApiVersion { get; set; } = "2024-10-21"; + public string SystemPrompt { get; set; } = + "You are the Delivery Assistant for a robot delivery system. " + + "Answer only from the order, route, and conversation context you receive. " + + "Prefer short direct answers, but include a one-sentence summary when the user asks for an overview. " + + "If a detail is unavailable, say that directly and avoid guessing. " + + "If the user asks about route, ETA, destination, assigned robot, order number, or ordered items, answer from context without adding invented details."; +} diff --git a/AgentService/AgentService/Options/KeyVaultOptions.cs b/AgentService/AgentService/Options/KeyVaultOptions.cs new file mode 100644 index 0000000..9bc600b --- /dev/null +++ b/AgentService/AgentService/Options/KeyVaultOptions.cs @@ -0,0 +1,8 @@ +namespace AgentService.Options; + +public sealed class KeyVaultOptions +{ + public const string SectionName = "KeyVault"; + + public string VaultUri { get; set; } = ""; +} diff --git a/AgentService/AgentService/Options/SupportEscalationOptions.cs b/AgentService/AgentService/Options/SupportEscalationOptions.cs new file mode 100644 index 0000000..9c4a93c --- /dev/null +++ b/AgentService/AgentService/Options/SupportEscalationOptions.cs @@ -0,0 +1,15 @@ +namespace AgentService.Options; + +public sealed class SupportEscalationOptions +{ + public const string SectionName = "ServiceBus"; + + public bool Enabled { get; set; } + public string FullyQualifiedNamespace { get; set; } = ""; + public string QueueName { get; set; } = "support-escalations"; + + public bool IsConfigured() => + Enabled && + !string.IsNullOrWhiteSpace(FullyQualifiedNamespace) && + !string.IsNullOrWhiteSpace(QueueName); +} diff --git a/AgentService/AgentService/Options/TranscriptArchiveOptions.cs b/AgentService/AgentService/Options/TranscriptArchiveOptions.cs new file mode 100644 index 0000000..c5074a4 --- /dev/null +++ b/AgentService/AgentService/Options/TranscriptArchiveOptions.cs @@ -0,0 +1,20 @@ +namespace AgentService.Options; + +public sealed class TranscriptArchiveOptions +{ + public const string SectionName = "TranscriptArchive"; + + public bool Enabled { get; set; } + public string BlobServiceUri { get; set; } = ""; + public string ContainerName { get; set; } = ""; + + public bool IsConfigured() + { + if (!Enabled || string.IsNullOrWhiteSpace(ContainerName)) + { + return false; + } + + return !string.IsNullOrWhiteSpace(BlobServiceUri); + } +} diff --git a/AgentService/AgentService/Program.cs b/AgentService/AgentService/Program.cs new file mode 100644 index 0000000..adeb933 --- /dev/null +++ b/AgentService/AgentService/Program.cs @@ -0,0 +1,77 @@ +using AgentService.Options; +using AgentService.Services; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); +var allowedOrigins = builder.Configuration["Cors:AllowedOrigins"] + ?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + ?? ["http://localhost:5173"]; + +builder.Services.Configure( + builder.Configuration.GetSection(AzureOpenAiOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(AgentIntegrationOptions.SectionName)); + +builder.Services.Configure( + builder.Configuration.GetSection(KeyVaultOptions.SectionName)); + +builder.Services.Configure( + builder.Configuration.GetSection(TranscriptArchiveOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(AzureAiSearchOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(SupportEscalationOptions.SectionName)); + +builder.Services.AddHttpClient(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + if (options.IsConfigured()) + { + return new AzureAiSearchGroundingService(options); + } + + return new NoOpAgentGroundingService(); +}); + +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + if (options.IsConfigured()) + { + return new BlobChatTranscriptArchive(options); + } + + return new NoOpChatTranscriptArchive(); +}); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + if (options.IsConfigured()) + { + return new ServiceBusSupportEscalationPublisher(options); + } + + return new NoOpSupportEscalationPublisher(); +}); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("CustomerFrontend", policy => + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod()); +}); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.UseCors("CustomerFrontend"); +app.MapControllers(); + +app.Run(); diff --git a/AgentService/AgentService/Properties/launchSettings.json b/AgentService/AgentService/Properties/launchSettings.json new file mode 100644 index 0000000..3780e85 --- /dev/null +++ b/AgentService/AgentService/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:7071", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/AgentService/AgentService/Services/AgentChatTranscriptRecord.cs b/AgentService/AgentService/Services/AgentChatTranscriptRecord.cs new file mode 100644 index 0000000..7c37d10 --- /dev/null +++ b/AgentService/AgentService/Services/AgentChatTranscriptRecord.cs @@ -0,0 +1,11 @@ +using AgentService.DTOs; + +namespace AgentService.Services; + +public sealed class AgentChatTranscriptRecord +{ + public DateTimeOffset ArchivedAtUtc { get; init; } + public string? RelatedOrderId { get; init; } + public AgentChatRequestDto Request { get; init; } = new(); + public AgentChatResponseDto Response { get; init; } = new(); +} diff --git a/AgentService/AgentService/Services/AzureAiSearchGroundingService.cs b/AgentService/AgentService/Services/AzureAiSearchGroundingService.cs new file mode 100644 index 0000000..4939eda --- /dev/null +++ b/AgentService/AgentService/Services/AzureAiSearchGroundingService.cs @@ -0,0 +1,246 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; +using Azure.Identity; +using AgentService.DTOs; +using AgentService.Options; + +namespace AgentService.Services; + +public sealed class AzureAiSearchGroundingService : IAgentGroundingService +{ + private const string ApiVersion = "2024-07-01"; + private static readonly TokenRequestContext SearchTokenRequest = + new(["https://search.azure.com/.default"]); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly AzureAiSearchOptions _options; + private readonly DefaultAzureCredential _credential = new(); + private readonly HttpClient _httpClient = new(); + private readonly SemaphoreSlim _indexLock = new(1, 1); + private bool _indexReady; + + public AzureAiSearchGroundingService(AzureAiSearchOptions options) + { + _options = options; + } + + public async Task EnrichAsync( + AgentChatRequestDto request, + CancellationToken cancellationToken = default) + { + if (!_options.IsConfigured() || string.IsNullOrWhiteSpace(request.Message)) + { + return; + } + + await EnsureIndexReadyAsync(cancellationToken); + + var searchUri = BuildUri($"/indexes/{_options.IndexName}/docs/search"); + using var searchRequest = await CreateAuthorizedRequestAsync(HttpMethod.Post, searchUri, cancellationToken); + searchRequest.Content = new StringContent( + JsonSerializer.Serialize( + new + { + search = request.Message, + top = Math.Clamp(_options.Top, 1, 5), + select = "title,source,category,content" + }, + JsonOptions), + Encoding.UTF8, + "application/json"); + + using var response = await _httpClient.SendAsync(searchRequest, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var result = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); + var documents = result?.Value + ?.Where(document => !string.IsNullOrWhiteSpace(document.Content)) + .Take(Math.Clamp(_options.Top, 1, 5)) + .ToList() ?? []; + + if (documents.Count == 0) + { + return; + } + + request.Context ??= new AgentChatContextDto(); + request.Context.GroundingSummary = string.Join( + Environment.NewLine, + documents.Select((document, index) => + $"- [{index + 1}] {document.Title ?? "DeliveryBot knowledge"} ({document.Source ?? document.Category ?? "knowledge base"}): {document.Content}")); + } + + private async Task EnsureIndexReadyAsync(CancellationToken cancellationToken) + { + if (IsIndexReady()) + { + return; + } + + await _indexLock.WaitAsync(cancellationToken); + + try + { + if (IsIndexReady()) + { + return; + } + + await CreateOrUpdateIndexAsync(cancellationToken); + await SeedDocumentsAsync(cancellationToken); + MarkIndexReady(); + } + finally + { + _indexLock.Release(); + } + } + + private async Task CreateOrUpdateIndexAsync(CancellationToken cancellationToken) + { + using var request = await CreateAuthorizedRequestAsync( + HttpMethod.Put, + BuildUri($"/indexes/{_options.IndexName}"), + cancellationToken); + request.Content = new StringContent( + JsonSerializer.Serialize( + new + { + name = _options.IndexName, + fields = new object[] + { + new { name = "id", type = "Edm.String", key = true, filterable = true }, + new { name = "title", type = "Edm.String", searchable = true }, + new { name = "source", type = "Edm.String", searchable = true, filterable = true }, + new { name = "category", type = "Edm.String", searchable = true, filterable = true }, + new { name = "content", type = "Edm.String", searchable = true } + } + }, + JsonOptions), + Encoding.UTF8, + "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException( + $"Azure AI Search returned HTTP {(int)response.StatusCode} while preparing the knowledge index: {body}"); + } + } + + private async Task SeedDocumentsAsync(CancellationToken cancellationToken) + { + var seedPath = ResolveSeedPath(); + if (!File.Exists(seedPath)) + { + return; + } + + await using var seedStream = File.OpenRead(seedPath); + var documents = await JsonSerializer.DeserializeAsync>( + seedStream, + JsonOptions, + cancellationToken) ?? []; + + if (documents.Count == 0) + { + return; + } + + var uploadDocuments = documents + .Where(document => !string.IsNullOrWhiteSpace(document.Id)) + .Select(document => document with { Action = "mergeOrUpload" }) + .ToList(); + + if (uploadDocuments.Count == 0) + { + return; + } + + using var request = await CreateAuthorizedRequestAsync( + HttpMethod.Post, + BuildUri($"/indexes/{_options.IndexName}/docs/index"), + cancellationToken); + request.Content = new StringContent( + JsonSerializer.Serialize(new { value = uploadDocuments }, JsonOptions), + Encoding.UTF8, + "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode != HttpStatusCode.OK && + response.StatusCode != HttpStatusCode.Created && + response.StatusCode != HttpStatusCode.NoContent) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException( + $"Azure AI Search returned HTTP {(int)response.StatusCode} while seeding knowledge documents: {body}"); + } + } + + private string ResolveSeedPath() + { + if (Path.IsPathRooted(_options.SeedDocumentsPath)) + { + return _options.SeedDocumentsPath; + } + + return Path.GetFullPath(_options.SeedDocumentsPath, AppContext.BaseDirectory); + } + + private bool IsIndexReady() => System.Threading.Volatile.Read(ref _indexReady); + + private void MarkIndexReady() => System.Threading.Volatile.Write(ref _indexReady, true); + + private Uri BuildUri(string path) + { + var endpoint = _options.Endpoint.TrimEnd('/'); + return new Uri($"{endpoint}{path}?api-version={ApiVersion}"); + } + + private async Task CreateAuthorizedRequestAsync( + HttpMethod method, + Uri uri, + CancellationToken cancellationToken) + { + var accessToken = await _credential.GetTokenAsync(SearchTokenRequest, cancellationToken); + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); + return request; + } + + private sealed class SearchResponse + { + public List? Value { get; set; } + } + + private sealed class SearchDocument + { + public string? Title { get; set; } + public string? Source { get; set; } + public string? Category { get; set; } + public string? Content { get; set; } + } + + private sealed record SearchSeedDocument( + string Id, + string Title, + string Source, + string Category, + string Content) + { + [JsonPropertyName("@search.action")] + public string Action { get; init; } = "mergeOrUpload"; + } +} diff --git a/AgentService/AgentService/Services/AzureOpenAiAgentService.cs b/AgentService/AgentService/Services/AzureOpenAiAgentService.cs new file mode 100644 index 0000000..98a66a9 --- /dev/null +++ b/AgentService/AgentService/Services/AzureOpenAiAgentService.cs @@ -0,0 +1,453 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using AgentService.DTOs; +using AgentService.Models; +using AgentService.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AgentService.Services; + +public sealed class AzureOpenAiAgentService : IAgentService +{ + private static readonly TokenRequestContext AzureCognitiveServicesScope = + new(["https://cognitiveservices.azure.com/.default"]); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly string[] EscalationKeywords = + [ + "human", + "support", + "refund", + "complaint", + "late", + "stuck", + "broken", + "missing", + "failed", + "cancel", + "cancellation" + ]; + + private readonly HttpClient _httpClient; + private readonly AzureOpenAiOptions _options; + private readonly AgentIntegrationOptions _integrationOptions; + private readonly IAzureOpenAiApiKeyProvider _apiKeyProvider; + private readonly IAgentGroundingService _groundingService; + private readonly IChatTranscriptArchive _chatTranscriptArchive; + private readonly ISupportEscalationPublisher _supportEscalationPublisher; + private readonly TokenCredential _credential; + private readonly ILogger _logger; + + public AzureOpenAiAgentService( + HttpClient httpClient, + IOptions options, + IOptions integrationOptions, + IAzureOpenAiApiKeyProvider apiKeyProvider, + IAgentGroundingService groundingService, + IChatTranscriptArchive chatTranscriptArchive, + ISupportEscalationPublisher supportEscalationPublisher, + ILogger logger) + : this( + httpClient, + options, + integrationOptions, + apiKeyProvider, + groundingService, + chatTranscriptArchive, + supportEscalationPublisher, + new DefaultAzureCredential(), + logger) + { + } + + public AzureOpenAiAgentService( + HttpClient httpClient, + IOptions options, + IOptions integrationOptions, + IAzureOpenAiApiKeyProvider apiKeyProvider, + IAgentGroundingService groundingService, + IChatTranscriptArchive chatTranscriptArchive, + ISupportEscalationPublisher supportEscalationPublisher, + TokenCredential credential, + ILogger logger) + { + _httpClient = httpClient; + _options = options.Value; + _integrationOptions = integrationOptions.Value; + _apiKeyProvider = apiKeyProvider; + _groundingService = groundingService; + _chatTranscriptArchive = chatTranscriptArchive; + _supportEscalationPublisher = supportEscalationPublisher; + _credential = credential; + _logger = logger; + } + + public async Task ChatAsync( + AgentChatRequestDto request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.Message)) + { + throw new ArgumentException("Message is required.", nameof(request)); + } + + await EnrichRequestAsync(request, cancellationToken); + await TryGroundRequestAsync(request, cancellationToken); + + if (string.IsNullOrWhiteSpace(_options.Endpoint) || + string.IsNullOrWhiteSpace(_options.Deployment)) + { + throw new InvalidOperationException( + "Azure OpenAI is not configured. Set AzureOpenAI:Endpoint and AzureOpenAI:Deployment, plus either AzureOpenAI:ApiKey, AzureOpenAI:ApiKeySecretName with KeyVault:VaultUri, or an Azure identity that can access the resource."); + } + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildRequestUri()); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + await AuthorizeRequestAsync(httpRequest, cancellationToken); + httpRequest.Content = new StringContent( + JsonSerializer.Serialize(AzureOpenAiChatMapper.BuildRequestBody(request, _options)), + Encoding.UTF8, + "application/json"); + + using var response = await _httpClient.SendAsync(httpRequest, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Azure OpenAI returned HTTP {(int)response.StatusCode}: {responseBody}"); + } + + using var document = JsonDocument.Parse(responseBody); + + var result = new AgentChatResponseDto + { + Reply = AzureOpenAiChatMapper.ExtractReply(document), + Source = "azure-openai", + Model = AzureOpenAiChatMapper.ExtractModel(document) + }; + + await TryArchiveTranscriptAsync(request, result, cancellationToken); + await TryPublishSupportEscalationAsync(request, result, cancellationToken); + + return result; + } + + private string BuildRequestUri() + { + var endpoint = _options.Endpoint.TrimEnd('/'); + return $"{endpoint}/openai/deployments/{_options.Deployment}/chat/completions?api-version={_options.ApiVersion}"; + } + + private async Task AuthorizeRequestAsync( + HttpRequestMessage httpRequest, + CancellationToken cancellationToken) + { + var apiKey = await _apiKeyProvider.GetApiKeyAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + httpRequest.Headers.Add("api-key", apiKey); + return; + } + + var token = await _credential.GetTokenAsync(AzureCognitiveServicesScope, cancellationToken); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + } + + private async Task EnrichRequestAsync( + AgentChatRequestDto request, + CancellationToken cancellationToken) + { + request.Context ??= new AgentChatContextDto(); + + var notes = new List(); + + var liveOrder = await TryGetLiveOrderAsync(request, cancellationToken); + if (liveOrder is not null) + { + request.Context.LatestOrder ??= new AgentLatestOrderDto(); + request.Context.LatestOrder.Id ??= liveOrder.Id; + request.Context.LatestOrder.Status = liveOrder.Status ?? request.Context.LatestOrder.Status; + request.Context.LatestOrder.AssignedBotId = liveOrder.AssignedBotId ?? request.Context.LatestOrder.AssignedBotId; + request.Context.LatestOrder.DeliveryAddress = liveOrder.DeliveryAddress ?? request.Context.LatestOrder.DeliveryAddress; + request.Context.LatestOrder.ItemsSummary ??= SummarizeItems(liveOrder.Items); + + notes.Add($"- Live order status: {liveOrder.Status ?? "Unknown"}"); + notes.Add($"- Live order assigned bot: {liveOrder.AssignedBotId ?? "None"}"); + } + + var liveBot = await TryGetLiveBotAsync(request.Context.LatestOrder?.AssignedBotId, cancellationToken); + if (liveBot is not null) + { + notes.Add($"- Live bot status: {liveBot.Status ?? "Unknown"}"); + notes.Add($"- Live bot battery: {FormatBattery(liveBot.PowerLevel)}"); + notes.Add($"- Live bot queued orders: {liveBot.QueuedOrderCount?.ToString() ?? "Unknown"}"); + } + + if (notes.Count > 0) + { + request.Context.LiveDataSummary = string.Join(Environment.NewLine, notes); + } + } + + private async Task TryGroundRequestAsync( + AgentChatRequestDto request, + CancellationToken cancellationToken) + { + try + { + await _groundingService.EnrichAsync(request, cancellationToken); + } + catch (AuthenticationFailedException error) + { + LogGroundingFailure(error); + } + catch (HttpRequestException error) + { + LogGroundingFailure(error); + } + catch (JsonException error) + { + LogGroundingFailure(error); + } + catch (NotSupportedException error) + { + LogGroundingFailure(error); + } + catch (InvalidOperationException error) + { + LogGroundingFailure(error); + } + } + + private async Task TryGetLiveOrderAsync( + AgentChatRequestDto request, + CancellationToken cancellationToken) + { + var orderId = request.Context?.LatestOrder?.Id; + if (string.IsNullOrWhiteSpace(orderId) || + string.IsNullOrWhiteSpace(_integrationOptions.OrderServiceBaseUrl)) + { + return null; + } + + var baseUrl = _integrationOptions.OrderServiceBaseUrl.TrimEnd('/'); + var requestUri = $"{baseUrl}/api/orders/{orderId}"; + + try + { + using var response = await _httpClient.GetAsync(requestUri, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync( + responseStream, + JsonOptions, + cancellationToken); + } + catch (HttpRequestException) + { + return null; + } + catch (JsonException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private async Task TryGetLiveBotAsync( + string? botId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(botId) || + string.IsNullOrWhiteSpace(_integrationOptions.SimulatorBaseUrl)) + { + return null; + } + + var baseUrl = _integrationOptions.SimulatorBaseUrl.TrimEnd('/'); + var requestUri = $"{baseUrl}/bots/{botId}"; + + try + { + using var response = await _httpClient.GetAsync(requestUri, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync( + responseStream, + JsonOptions, + cancellationToken); + } + catch (HttpRequestException) + { + return null; + } + catch (JsonException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private async Task TryArchiveTranscriptAsync( + AgentChatRequestDto request, + AgentChatResponseDto response, + CancellationToken cancellationToken) + { + try + { + await _chatTranscriptArchive.ArchiveAsync( + new AgentChatTranscriptRecord + { + ArchivedAtUtc = DateTimeOffset.UtcNow, + RelatedOrderId = request.Context?.LatestOrder?.Id, + Request = request, + Response = response + }, + cancellationToken); + } + catch (AuthenticationFailedException error) + { + LogArchiveFailure(error); + } + catch (HttpRequestException error) + { + LogArchiveFailure(error); + } + catch (JsonException error) + { + LogArchiveFailure(error); + } + catch (NotSupportedException error) + { + LogArchiveFailure(error); + } + catch (UriFormatException error) + { + LogArchiveFailure(error); + } + catch (InvalidOperationException error) + { + LogArchiveFailure(error); + } + } + + private async Task TryPublishSupportEscalationAsync( + AgentChatRequestDto request, + AgentChatResponseDto response, + CancellationToken cancellationToken) + { + var reason = DetermineEscalationReason(request); + if (string.IsNullOrWhiteSpace(reason)) + { + return; + } + + try + { + await _supportEscalationPublisher.PublishAsync( + new SupportEscalationRecord + { + CreatedAtUtc = DateTimeOffset.UtcNow, + RelatedOrderId = request.Context?.LatestOrder?.Id, + Reason = reason, + Request = request, + Response = response + }, + cancellationToken); + } + catch (AuthenticationFailedException error) + { + LogEscalationFailure(error); + } + catch (HttpRequestException error) + { + LogEscalationFailure(error); + } + catch (JsonException error) + { + LogEscalationFailure(error); + } + catch (NotSupportedException error) + { + LogEscalationFailure(error); + } + catch (UriFormatException error) + { + LogEscalationFailure(error); + } + catch (InvalidOperationException error) + { + LogEscalationFailure(error); + } + } + + private void LogGroundingFailure(Exception error) => + _logger.LogWarning(error, "Failed to enrich agent request with Azure AI Search grounding."); + + private void LogArchiveFailure(Exception error) => + _logger.LogWarning(error, "Failed to archive agent transcript."); + + private void LogEscalationFailure(Exception error) => + _logger.LogWarning(error, "Failed to publish support escalation."); + + private static string? DetermineEscalationReason(AgentChatRequestDto request) + { + var message = request.Message ?? ""; + if (EscalationKeywords.Any(keyword => + message.Contains(keyword, StringComparison.OrdinalIgnoreCase))) + { + return "customer-request"; + } + + var status = request.Context?.LatestOrder?.Status; + if (!string.IsNullOrWhiteSpace(status) && + (status.Contains("fail", StringComparison.OrdinalIgnoreCase) || + status.Contains("cancel", StringComparison.OrdinalIgnoreCase))) + { + return "order-status"; + } + + return null; + } + + private static string SummarizeItems(IEnumerable items) + { + var materialized = items + .Where(item => !string.IsNullOrWhiteSpace(item.ItemId)) + .Select(item => $"{item.ItemId} x{item.Quantity}") + .ToList(); + + return materialized.Count == 0 ? "Unknown" : string.Join(", ", materialized); + } + + private static string FormatBattery(double? powerLevel) + { + return powerLevel is null ? "Unknown" : $"{Math.Round(powerLevel.Value)}%"; + } +} diff --git a/AgentService/AgentService/Services/AzureOpenAiApiKeyProvider.cs b/AgentService/AgentService/Services/AzureOpenAiApiKeyProvider.cs new file mode 100644 index 0000000..fd9e2b8 --- /dev/null +++ b/AgentService/AgentService/Services/AzureOpenAiApiKeyProvider.cs @@ -0,0 +1,119 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using AgentService.Options; +using Microsoft.Extensions.Options; + +namespace AgentService.Services; + +public sealed class AzureOpenAiApiKeyProvider : IAzureOpenAiApiKeyProvider +{ + private static readonly TokenRequestContext KeyVaultTokenRequest = + new(["https://vault.azure.net/.default"]); + + private readonly AzureOpenAiOptions _azureOpenAiOptions; + private readonly KeyVaultOptions _keyVaultOptions; + private readonly DefaultAzureCredential _credential = new(); + private readonly HttpClient _httpClient = new(); + private readonly SemaphoreSlim _loadLock = new(1, 1); + private string? _cachedApiKey; + + public AzureOpenAiApiKeyProvider( + IOptions azureOpenAiOptions, + IOptions keyVaultOptions) + { + _azureOpenAiOptions = azureOpenAiOptions.Value; + _keyVaultOptions = keyVaultOptions.Value; + } + + public async Task GetApiKeyAsync(CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(_azureOpenAiOptions.ApiKey)) + { + return _azureOpenAiOptions.ApiKey; + } + + if (!string.IsNullOrWhiteSpace(_cachedApiKey)) + { + return _cachedApiKey; + } + + if (!Uri.TryCreate(_keyVaultOptions.VaultUri, UriKind.Absolute, out var vaultUri) || + string.IsNullOrWhiteSpace(_azureOpenAiOptions.ApiKeySecretName)) + { + return null; + } + + await _loadLock.WaitAsync(cancellationToken); + + try + { + if (!string.IsNullOrWhiteSpace(_cachedApiKey)) + { + return _cachedApiKey; + } + + try + { + var accessToken = await _credential.GetTokenAsync( + KeyVaultTokenRequest, + cancellationToken); + using var request = new HttpRequestMessage( + HttpMethod.Get, + new Uri( + $"{vaultUri.ToString().TrimEnd('/')}/secrets/{Uri.EscapeDataString(_azureOpenAiOptions.ApiKeySecretName)}?api-version=7.5")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Key Vault returned HTTP {(int)response.StatusCode}: {body}"); + } + + using var document = JsonDocument.Parse(body); + _cachedApiKey = document.RootElement.GetProperty("value").GetString(); + + if (string.IsNullOrWhiteSpace(_cachedApiKey)) + { + throw new InvalidOperationException( + $"Key Vault secret '{_azureOpenAiOptions.ApiKeySecretName}' did not contain a value."); + } + + return _cachedApiKey; + } + catch (AuthenticationFailedException error) + { + throw BuildApiKeyLoadException(error); + } + catch (HttpRequestException error) + { + throw BuildApiKeyLoadException(error); + } + catch (JsonException error) + { + throw BuildApiKeyLoadException(error); + } + catch (KeyNotFoundException error) + { + throw BuildApiKeyLoadException(error); + } + catch (InvalidOperationException error) + { + throw BuildApiKeyLoadException(error); + } + } + finally + { + _loadLock.Release(); + } + } + + private InvalidOperationException BuildApiKeyLoadException(Exception error) => + new( + $"Azure OpenAI API key could not be loaded from Key Vault secret '{_azureOpenAiOptions.ApiKeySecretName}'. {error.Message}", + error); +} diff --git a/AgentService/AgentService/Services/AzureOpenAiChatMapper.cs b/AgentService/AgentService/Services/AzureOpenAiChatMapper.cs new file mode 100644 index 0000000..73519ff --- /dev/null +++ b/AgentService/AgentService/Services/AzureOpenAiChatMapper.cs @@ -0,0 +1,154 @@ +using System.Text.Json; +using AgentService.DTOs; +using AgentService.Options; + +namespace AgentService.Services; + +public static class AzureOpenAiChatMapper +{ + public static string BuildUserPrompt(AgentChatRequestDto request) + { + var message = request.Message.Trim(); + var latestOrder = request.Context?.LatestOrder; + var route = request.Context?.Route; + var history = request.History + .Where(entry => !string.IsNullOrWhiteSpace(entry.Text)) + .TakeLast(8) + .ToList(); + + var lines = new List + { + "Customer question:", + message, + "", + "Latest order context:" + }; + + if (latestOrder is null) + { + lines.Add("- No latest order is available."); + } + else + { + lines.Add($"- Order ID: {latestOrder.Id ?? "Unknown"}"); + lines.Add($"- Status: {latestOrder.Status ?? "Unknown"}"); + lines.Add($"- Assigned bot: {latestOrder.AssignedBotId ?? "None"}"); + lines.Add($"- Delivery address: {latestOrder.DeliveryAddress ?? "Unknown"}"); + lines.Add($"- Items: {latestOrder.ItemsSummary ?? "Unknown"}"); + } + + lines.Add(""); + lines.Add("Route context:"); + + if (route is null) + { + lines.Add("- No active route is available."); + } + else + { + lines.Add($"- Distance: {route.Distance ?? "Unknown"}"); + lines.Add($"- ETA: {route.Eta ?? "Unknown"}"); + lines.Add($"- Source: {route.Source ?? "Unknown"}"); + } + + lines.Add(""); + lines.Add("Recent conversation:"); + + if (history.Count == 0) + { + lines.Add("- No earlier conversation is available."); + } + else + { + foreach (var entry in history) + { + lines.Add($"- {entry.Role}: {entry.Text}"); + } + } + + lines.Add(""); + lines.Add("Live service data:"); + + if (string.IsNullOrWhiteSpace(request.Context?.LiveDataSummary)) + { + lines.Add("- No live service enrichment is available."); + } + else + { + lines.Add(request.Context.LiveDataSummary); + } + + lines.Add(""); + lines.Add("Knowledge base grounding:"); + + if (string.IsNullOrWhiteSpace(request.Context?.GroundingSummary)) + { + lines.Add("- No grounding documents were retrieved."); + } + else + { + lines.Add(request.Context.GroundingSummary); + } + + lines.Add(""); + lines.Add("Answer the customer directly in plain language."); + lines.Add("If a detail is missing, say that directly instead of guessing."); + lines.Add("If knowledge base grounding is available and relevant, use it and mention the document title naturally."); + + return string.Join(Environment.NewLine, lines); + } + + public static object BuildRequestBody(AgentChatRequestDto request, AzureOpenAiOptions options) => + new + { + messages = new object[] + { + new + { + role = "system", + content = options.SystemPrompt + }, + new + { + role = "user", + content = BuildUserPrompt(request) + } + }, + temperature = 0.2, + max_tokens = 220 + }; + + public static string ExtractReply(JsonDocument document) + { + if (!document.RootElement.TryGetProperty("choices", out var choices) || + choices.ValueKind != JsonValueKind.Array || + choices.GetArrayLength() == 0) + { + throw new InvalidOperationException("Azure OpenAI returned no choices."); + } + + var firstChoice = choices[0]; + if (!firstChoice.TryGetProperty("message", out var messageElement)) + { + throw new InvalidOperationException("Azure OpenAI returned no message."); + } + + if (!messageElement.TryGetProperty("content", out var contentElement)) + { + throw new InvalidOperationException("Azure OpenAI returned no content."); + } + + var reply = contentElement.GetString()?.Trim(); + if (string.IsNullOrWhiteSpace(reply)) + { + throw new InvalidOperationException("Azure OpenAI returned an empty reply."); + } + + return reply; + } + + public static string? ExtractModel(JsonDocument document) => + document.RootElement.TryGetProperty("model", out var modelElement) + ? modelElement.GetString() + : null; +} diff --git a/AgentService/AgentService/Services/BlobChatTranscriptArchive.cs b/AgentService/AgentService/Services/BlobChatTranscriptArchive.cs new file mode 100644 index 0000000..0683771 --- /dev/null +++ b/AgentService/AgentService/Services/BlobChatTranscriptArchive.cs @@ -0,0 +1,158 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using AgentService.Options; + +namespace AgentService.Services; + +public sealed class BlobChatTranscriptArchive : IChatTranscriptArchive +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private static readonly TokenRequestContext StorageTokenRequest = + new(["https://storage.azure.com/.default"]); + + private readonly TranscriptArchiveOptions _options; + private readonly DefaultAzureCredential _credential = new(); + private readonly HttpClient _httpClient = new(); + private readonly SemaphoreSlim _containerLock = new(1, 1); + private bool _containerExists; + + public BlobChatTranscriptArchive(TranscriptArchiveOptions options) + { + _options = options; + } + + public async Task ArchiveAsync( + AgentChatTranscriptRecord transcript, + CancellationToken cancellationToken = default) + { + await EnsureContainerExistsAsync(cancellationToken); + + var blobUri = new Uri( + $"{_options.BlobServiceUri.TrimEnd('/')}/{_options.ContainerName}/{BuildBlobName(transcript)}"); + using var request = await CreateAuthorizedRequestAsync(HttpMethod.Put, blobUri, cancellationToken); + request.Headers.Add("x-ms-blob-type", "BlockBlob"); + request.Content = new StringContent( + JsonSerializer.Serialize(transcript, JsonOptions), + Encoding.UTF8, + "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + + if (response.StatusCode != HttpStatusCode.Created) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException( + $"Blob Storage returned HTTP {(int)response.StatusCode} while writing the transcript blob: {body}"); + } + } + + private static string BuildBlobName(AgentChatTranscriptRecord transcript) + { + var archivedAt = transcript.ArchivedAtUtc == default + ? DateTimeOffset.UtcNow + : transcript.ArchivedAtUtc; + var orderSegment = SanitizePathSegment(transcript.RelatedOrderId, "no-order"); + + return FormattableString.Invariant( + $"{archivedAt:yyyy/MM/dd}/{orderSegment}/{archivedAt:HHmmssfff}-{Guid.NewGuid():N}.json"); + } + + private static string SanitizePathSegment(string? value, string fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var builder = new char[value.Length]; + var length = 0; + + foreach (var character in value.Trim()) + { + if (char.IsLetterOrDigit(character)) + { + builder[length++] = char.ToLowerInvariant(character); + continue; + } + + if (length == 0 || builder[length - 1] == '-') + { + continue; + } + + builder[length++] = '-'; + } + + if (length == 0) + { + return fallback; + } + + return new string(builder, 0, length).Trim('-'); + } + + private async Task EnsureContainerExistsAsync(CancellationToken cancellationToken) + { + if (ContainerExists()) + { + return; + } + + await _containerLock.WaitAsync(cancellationToken); + + try + { + if (ContainerExists()) + { + return; + } + + var containerUri = new Uri( + $"{_options.BlobServiceUri.TrimEnd('/')}/{_options.ContainerName}?restype=container"); + using var request = await CreateAuthorizedRequestAsync(HttpMethod.Put, containerUri, cancellationToken); + request.Content = new ByteArrayContent([]); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + + if (response.StatusCode != HttpStatusCode.Created && + response.StatusCode != HttpStatusCode.Conflict) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException( + $"Blob Storage returned HTTP {(int)response.StatusCode} while creating the transcript container: {body}"); + } + + MarkContainerExists(); + } + finally + { + _containerLock.Release(); + } + } + + private bool ContainerExists() => System.Threading.Volatile.Read(ref _containerExists); + + private void MarkContainerExists() => System.Threading.Volatile.Write(ref _containerExists, true); + + private async Task CreateAuthorizedRequestAsync( + HttpMethod method, + Uri uri, + CancellationToken cancellationToken) + { + var accessToken = await _credential.GetTokenAsync(StorageTokenRequest, cancellationToken); + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); + request.Headers.Add("x-ms-date", DateTimeOffset.UtcNow.ToString("R", CultureInfo.InvariantCulture)); + request.Headers.Add("x-ms-version", "2023-11-03"); + return request; + } +} diff --git a/AgentService/AgentService/Services/IAgentGroundingService.cs b/AgentService/AgentService/Services/IAgentGroundingService.cs new file mode 100644 index 0000000..05eb82d --- /dev/null +++ b/AgentService/AgentService/Services/IAgentGroundingService.cs @@ -0,0 +1,8 @@ +using AgentService.DTOs; + +namespace AgentService.Services; + +public interface IAgentGroundingService +{ + Task EnrichAsync(AgentChatRequestDto request, CancellationToken cancellationToken = default); +} diff --git a/AgentService/AgentService/Services/IAgentService.cs b/AgentService/AgentService/Services/IAgentService.cs new file mode 100644 index 0000000..f0fd1b4 --- /dev/null +++ b/AgentService/AgentService/Services/IAgentService.cs @@ -0,0 +1,8 @@ +using AgentService.DTOs; + +namespace AgentService.Services; + +public interface IAgentService +{ + Task ChatAsync(AgentChatRequestDto request, CancellationToken cancellationToken = default); +} diff --git a/AgentService/AgentService/Services/IAzureOpenAiApiKeyProvider.cs b/AgentService/AgentService/Services/IAzureOpenAiApiKeyProvider.cs new file mode 100644 index 0000000..bbdeaf8 --- /dev/null +++ b/AgentService/AgentService/Services/IAzureOpenAiApiKeyProvider.cs @@ -0,0 +1,6 @@ +namespace AgentService.Services; + +public interface IAzureOpenAiApiKeyProvider +{ + Task GetApiKeyAsync(CancellationToken cancellationToken = default); +} diff --git a/AgentService/AgentService/Services/IChatTranscriptArchive.cs b/AgentService/AgentService/Services/IChatTranscriptArchive.cs new file mode 100644 index 0000000..c79a4da --- /dev/null +++ b/AgentService/AgentService/Services/IChatTranscriptArchive.cs @@ -0,0 +1,6 @@ +namespace AgentService.Services; + +public interface IChatTranscriptArchive +{ + Task ArchiveAsync(AgentChatTranscriptRecord transcript, CancellationToken cancellationToken = default); +} diff --git a/AgentService/AgentService/Services/ISupportEscalationPublisher.cs b/AgentService/AgentService/Services/ISupportEscalationPublisher.cs new file mode 100644 index 0000000..97d5272 --- /dev/null +++ b/AgentService/AgentService/Services/ISupportEscalationPublisher.cs @@ -0,0 +1,6 @@ +namespace AgentService.Services; + +public interface ISupportEscalationPublisher +{ + Task PublishAsync(SupportEscalationRecord escalation, CancellationToken cancellationToken = default); +} diff --git a/AgentService/AgentService/Services/NoOpAgentGroundingService.cs b/AgentService/AgentService/Services/NoOpAgentGroundingService.cs new file mode 100644 index 0000000..e58f37c --- /dev/null +++ b/AgentService/AgentService/Services/NoOpAgentGroundingService.cs @@ -0,0 +1,9 @@ +using AgentService.DTOs; + +namespace AgentService.Services; + +public sealed class NoOpAgentGroundingService : IAgentGroundingService +{ + public Task EnrichAsync(AgentChatRequestDto request, CancellationToken cancellationToken = default) => + Task.CompletedTask; +} diff --git a/AgentService/AgentService/Services/NoOpChatTranscriptArchive.cs b/AgentService/AgentService/Services/NoOpChatTranscriptArchive.cs new file mode 100644 index 0000000..c75b2d2 --- /dev/null +++ b/AgentService/AgentService/Services/NoOpChatTranscriptArchive.cs @@ -0,0 +1,7 @@ +namespace AgentService.Services; + +public sealed class NoOpChatTranscriptArchive : IChatTranscriptArchive +{ + public Task ArchiveAsync(AgentChatTranscriptRecord transcript, CancellationToken cancellationToken = default) => + Task.CompletedTask; +} diff --git a/AgentService/AgentService/Services/NoOpSupportEscalationPublisher.cs b/AgentService/AgentService/Services/NoOpSupportEscalationPublisher.cs new file mode 100644 index 0000000..f7a18c2 --- /dev/null +++ b/AgentService/AgentService/Services/NoOpSupportEscalationPublisher.cs @@ -0,0 +1,7 @@ +namespace AgentService.Services; + +public sealed class NoOpSupportEscalationPublisher : ISupportEscalationPublisher +{ + public Task PublishAsync(SupportEscalationRecord escalation, CancellationToken cancellationToken = default) => + Task.CompletedTask; +} diff --git a/AgentService/AgentService/Services/ServiceBusSupportEscalationPublisher.cs b/AgentService/AgentService/Services/ServiceBusSupportEscalationPublisher.cs new file mode 100644 index 0000000..d4e7b47 --- /dev/null +++ b/AgentService/AgentService/Services/ServiceBusSupportEscalationPublisher.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using AgentService.Options; + +namespace AgentService.Services; + +public sealed class ServiceBusSupportEscalationPublisher : ISupportEscalationPublisher +{ + private static readonly TokenRequestContext ServiceBusTokenRequest = + new(["https://servicebus.azure.net/.default"]); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly SupportEscalationOptions _options; + private readonly DefaultAzureCredential _credential = new(); + private readonly HttpClient _httpClient = new(); + + public ServiceBusSupportEscalationPublisher(SupportEscalationOptions options) + { + _options = options; + } + + public async Task PublishAsync( + SupportEscalationRecord escalation, + CancellationToken cancellationToken = default) + { + if (!_options.IsConfigured()) + { + return; + } + + var token = await _credential.GetTokenAsync(ServiceBusTokenRequest, cancellationToken); + var namespaceHost = _options.FullyQualifiedNamespace + .Replace("https://", "", StringComparison.OrdinalIgnoreCase) + .TrimEnd('/'); + var queueName = Uri.EscapeDataString(_options.QueueName); + using var request = new HttpRequestMessage( + HttpMethod.Post, + new Uri($"https://{namespaceHost}/{queueName}/messages")); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + request.Headers.Add("BrokerProperties", JsonSerializer.Serialize(new + { + Label = "deliverybot-support-escalation", + CorrelationId = escalation.RelatedOrderId ?? Guid.NewGuid().ToString("N") + })); + request.Content = new StringContent( + JsonSerializer.Serialize(escalation, JsonOptions), + Encoding.UTF8, + "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode != HttpStatusCode.Created && + response.StatusCode != HttpStatusCode.OK && + response.StatusCode != HttpStatusCode.Accepted) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException( + $"Service Bus returned HTTP {(int)response.StatusCode} while publishing a support escalation: {body}"); + } + } +} diff --git a/AgentService/AgentService/Services/SupportEscalationRecord.cs b/AgentService/AgentService/Services/SupportEscalationRecord.cs new file mode 100644 index 0000000..dc3c576 --- /dev/null +++ b/AgentService/AgentService/Services/SupportEscalationRecord.cs @@ -0,0 +1,12 @@ +using AgentService.DTOs; + +namespace AgentService.Services; + +public sealed class SupportEscalationRecord +{ + public DateTimeOffset CreatedAtUtc { get; set; } + public string? RelatedOrderId { get; set; } + public string Reason { get; set; } = ""; + public AgentChatRequestDto Request { get; set; } = new(); + public AgentChatResponseDto Response { get; set; } = new(); +} diff --git a/AgentService/AgentService/appsettings.json b/AgentService/AgentService/appsettings.json new file mode 100644 index 0000000..d22c291 --- /dev/null +++ b/AgentService/AgentService/appsettings.json @@ -0,0 +1,44 @@ +{ + "AzureOpenAI": { + "Endpoint": "", + "Deployment": "", + "ApiKey": "", + "ApiKeySecretName": "", + "ApiVersion": "2024-10-21", + "SystemPrompt": "You are the Delivery Assistant for a robot delivery system. Answer only from the order, route, and conversation context you receive. Prefer short direct answers, but include a one-sentence summary when the user asks for an overview. If a detail is unavailable, say that directly and avoid guessing. If the user asks about route, ETA, destination, assigned robot, order number, or ordered items, answer from context without adding invented details." + }, + "Integrations": { + "OrderServiceBaseUrl": "", + "SimulatorBaseUrl": "" + }, + "KeyVault": { + "VaultUri": "" + }, + "TranscriptArchive": { + "Enabled": false, + "BlobServiceUri": "", + "ContainerName": "agent-transcripts" + }, + "Search": { + "Enabled": false, + "Endpoint": "", + "IndexName": "delivery-agent-knowledge", + "Top": 3, + "SeedDocumentsPath": "KnowledgeBase/search-documents.json" + }, + "ServiceBus": { + "Enabled": false, + "FullyQualifiedNamespace": "", + "QueueName": "support-escalations" + }, + "Cors": { + "AllowedOrigins": "http://localhost:5173" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Iac/.terraform.lock.hcl b/Iac/.terraform.lock.hcl new file mode 100644 index 0000000..976e659 --- /dev/null +++ b/Iac/.terraform.lock.hcl @@ -0,0 +1,45 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.77.0" + constraints = "~> 4.0, >= 4.31.0, < 5.0.0" + hashes = [ + "h1:S9/+bC+q2pWHQVwY/VEix6z77EgnUOqFvdvKBljte9E=", + "h1:t3pDDvyYg+QrGgdur8qjxR/uk0nU/UiMnhjUurgqEPc=", + "zh:0eb2273aec14d6a0b308fbf796295305eaef8bf4b8f294d9b60eba884e7b5da2", + "zh:1c74d524d2c3154922761508197d12b86f3730e466582c31a1f460a3b0a08c48", + "zh:358cda15fa1dcb22aedf467b4cf319ff44c3fbcd0ff42041476ff63b9968cfc1", + "zh:40317479e968133bb424f118089ce75ba2672b71dbf286b81b1ea47aeb96f657", + "zh:730b7fc2285d04132a70ed50c0b6066e6ed107068f6e335727f4b59cde5fb247", + "zh:76ecea220fdfbc7016ccb6178d1c6358929e99c11b49e668a9bf4bc76bf8a541", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a36904512ef6bc7bdc8b87867b6fe307f370524b48a73055b382757f8ef165a5", + "zh:c2329af283adee199597152ba85cb7fcf1581a326ed428565412cc68a7a81a24", + "zh:d597d9bf0fda82617c4219d0025ebb64226e00bc6767fc70780b2773067d2f19", + "zh:ec319be347acd1f29fd60106a70617d60fe8543fb01f9a17b3ca249f3bf415fd", + "zh:f8b19aa7c0e7a4ab6a3b3ad86c712492f7361b04000b78f1bfea4ff882feceb6", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.6.0, < 4.0.0" + hashes = [ + "h1:q/uaUTBdKgAmZESrwsoeDQff9uUA/cI/N5ZKNgVwa9c=", + "h1:zK+EG72uHuIWwy5Me6q2IxG/r859YA3AhqJRwUK2lOg=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/Iac/Iac.md b/Iac/Iac.md index 25629d0..74a818d 100644 --- a/Iac/Iac.md +++ b/Iac/Iac.md @@ -1,9 +1,51 @@ # Infrastructure as Code -Terraform modules for project infrastructure live under `Iac/modules`. +This repository now has a project-wide Terraform root under `Iac/main.tf`. -## Modules +The root composes the major Delivery Bot services into one deployable stack: -- `readable-bot-network-representation`: creates the Cosmos DB-backed bot read model infrastructure for the Readable Bot Network Representation epic. +- `shared-infra` +- `frontend` +- `admin-webapp` +- `order-service` +- `agent-service` +- `bot-api` +- `simulator` +- `readable-bot-network-representation` -This repository does not currently define a project-wide Terraform root. The module is intentionally written so another team member can wire it into that root when the shared IaC structure is ready. +## Final Project Architecture + +The final-project deployment is designed to show a connected Azure solution built on the class project: + +1. `App Service` + Hosts the customer and admin web apps. +2. `Container Apps` + Hosts the order service, bot API, simulator, and AI agent service. +3. `Azure OpenAI` + Powers the delivery assistant. +4. `Event Hubs` + Carries simulator robot-output and assignment-related events. +5. `Azure Functions` + Projects robot-output events into a read model. +6. `Cosmos DB` + Stores the current readable bot-network projection. +7. `Application Insights` + Captures telemetry for the Function App projection. +8. `Azure Key Vault` + Stores the Azure OpenAI API key and lets the Agent Service read it through managed identity. +9. `Azure Blob Storage` + Archives customer-facing Agent Service chat transcripts and support-escalation messages for later review and demos. +10. `Azure AI Search` + Provides a small seeded delivery knowledge base used to ground chatbot answers. +11. `Azure Service Bus` + Carries durable support-escalation work items from the Agent Service to the Function App. +12. `Azure API Management` + Exposes a single gateway in front of the order, agent, and bot network APIs. + +## Notes + +- The Terraform backend is intentionally left as a partial `azurerm` backend so each student can supply their own state storage settings. +- The previous shared-environment import file has been moved to an example file so the final-project environment can deploy cleanly without trying to import older shared resources. +- The readable bot network module is now wired into the root so the final project can demonstrate a full event-driven read model rather than only the request/response path. +- The Agent Service now expects a Key Vault secret name and vault URI in deployed environments so the Azure OpenAI API key can be fetched through managed identity instead of only plain configuration. +- The deployed customer frontend can point at API Management with `VITE_API_MANAGEMENT_BASE_URL`; explicit service URLs still override it for local or partial deployments. diff --git a/Iac/admin-webapp/main.tf b/Iac/admin-webapp/main.tf index 9eb13ed..3f138ee 100644 --- a/Iac/admin-webapp/main.tf +++ b/Iac/admin-webapp/main.tf @@ -1,18 +1,14 @@ -# Root configuration for the Admin & Maintenance App infrastructure. -# -# Composes the reusable ./modules/webapp module. Backend + provider config -# live in providers.tf; inputs and their defaults live in variables.tf. - module "admin_webapp" { source = "./modules/webapp" - resource_group_name = var.resource_group_name - app_service_plan_name = var.app_service_plan_name - app_service_name = var.app_service_name - node_version = var.node_version - botnet_api_url = var.botnet_api_url - simulator_api_url = var.simulator_api_url - tags = var.tags + resource_group_name = var.resource_group_name + location = var.location + app_service_plan_id = var.app_service_plan_id + app_service_name = var.app_service_name + node_version = var.node_version + botnet_api_url = var.botnet_api_url + simulator_api_url = var.simulator_api_url + tags = var.tags } # ── Observability (final feature: Azure Monitor) ──────────────────────────── diff --git a/Iac/admin-webapp/modules/webapp/main.tf b/Iac/admin-webapp/modules/webapp/main.tf index 87a524e..eaf7ca2 100644 --- a/Iac/admin-webapp/modules/webapp/main.tf +++ b/Iac/admin-webapp/modules/webapp/main.tf @@ -1,9 +1,3 @@ -# Reusable module: a Linux App Service that hosts a static SPA via pm2. -# -# Reuses an existing resource group and App Service Plan (passed by name) so -# the team isn't billed for a duplicate plan. The only managed resource is the -# App Service itself. - terraform { required_providers { azurerm = { @@ -17,16 +11,11 @@ data "azurerm_resource_group" "rg" { name = var.resource_group_name } -data "azurerm_service_plan" "plan" { - name = var.app_service_plan_name - resource_group_name = data.azurerm_resource_group.rg.name -} - resource "azurerm_linux_web_app" "admin" { name = var.app_service_name resource_group_name = data.azurerm_resource_group.rg.name - location = data.azurerm_service_plan.plan.location - service_plan_id = data.azurerm_service_plan.plan.id + location = var.location + service_plan_id = var.app_service_plan_id https_only = true identity { @@ -41,13 +30,9 @@ resource "azurerm_linux_web_app" "admin" { node_version = var.node_version } - # Allow the GitHub Actions workflow to push builds. scm_use_main_ip_restriction = true } - # Build-time URLs are baked into the SPA bundle, so these app settings - # exist mainly as a record of which upstreams this deployment talks to. - # If the SPA gains a runtime config layer, switch to reading these. app_settings = { "WEBSITE_NODE_DEFAULT_VERSION" = "~22" "BOTNET_API_URL" = var.botnet_api_url @@ -58,7 +43,6 @@ resource "azurerm_linux_web_app" "admin" { lifecycle { ignore_changes = [ - # Deployments overwrite the build artifact; don't fight the workflow. app_settings["WEBSITE_RUN_FROM_PACKAGE"], ] } diff --git a/Iac/admin-webapp/modules/webapp/variables.tf b/Iac/admin-webapp/modules/webapp/variables.tf index a4484d8..a9b942d 100644 --- a/Iac/admin-webapp/modules/webapp/variables.tf +++ b/Iac/admin-webapp/modules/webapp/variables.tf @@ -1,10 +1,15 @@ variable "resource_group_name" { - description = "Resource group that hosts the team's DeliveryBot resources." + description = "Resource group that hosts the DeliveryBot resources." type = string } -variable "app_service_plan_name" { - description = "Existing App Service Plan to reuse (shared with the Customer site to keep cost down)." +variable "location" { + description = "Region for the App Service Plan and admin app." + type = string +} + +variable "app_service_plan_id" { + description = "Resource ID of the shared App Service Plan." type = string } @@ -19,12 +24,12 @@ variable "node_version" { } variable "botnet_api_url" { - description = "Public URL of the BotNet API (Container App), baked into the SPA at build time." + description = "Public URL of the BotNet API baked into the SPA at build time." type = string } variable "simulator_api_url" { - description = "Public URL of the Robot Simulator (Container App), baked into the SPA at build time." + description = "Public URL of the Robot Simulator baked into the SPA at build time." type = string } diff --git a/Iac/admin-webapp/variables.tf b/Iac/admin-webapp/variables.tf index 240af39..46ffa64 100644 --- a/Iac/admin-webapp/variables.tf +++ b/Iac/admin-webapp/variables.tf @@ -1,25 +1,24 @@ variable "resource_group_name" { - description = "Resource group that hosts the team's DeliveryBot resources." + description = "Resource group that hosts the DeliveryBot resources." type = string default = "ewu-deliverybotsystem-rg" } -variable "app_service_plan_name" { - description = "Existing App Service Plan to reuse (shared with the Customer site to keep cost down)." +variable "location" { + description = "Region for the shared App Service Plan and admin app." type = string - default = "ASP-RGDeliveryBotdev-8b82" + default = "westus2" } -variable "app_service_name" { - description = "Globally-unique name for the Admin Web App App Service." +variable "app_service_plan_id" { + description = "Resource ID of the shared App Service Plan." type = string - default = "WA-DeliveryBot-Admin-dev" } -variable "location" { - description = "Region for the App Service. Must match the existing plan." +variable "app_service_name" { + description = "Globally-unique name for the Admin Web App App Service." type = string - default = "canadacentral" + default = "WA-DeliveryBot-Admin-dev" } variable "node_version" { @@ -29,15 +28,15 @@ variable "node_version" { } variable "botnet_api_url" { - description = "Public URL of the BotNet API (Container App), baked into the SPA at build time." + description = "Public URL of the BotNet API baked into the SPA at build time." type = string - default = "https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io" + default = "https://deliverybot-botapi-dev.example.com" } variable "simulator_api_url" { - description = "Public URL of the Robot Simulator (Container App), baked into the SPA at build time." + description = "Public URL of the Robot Simulator baked into the SPA at build time." type = string - default = "https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io" + default = "https://deliverybot-simulator-dev.example.com" } variable "tags" { @@ -46,7 +45,5 @@ variable "tags" { default = { project = "DeliveryBot" component = "admin-webapp" - owner = "CarsonL15" - issue = "#18" } } diff --git a/Iac/agent-service/README.md b/Iac/agent-service/README.md new file mode 100644 index 0000000..f924656 --- /dev/null +++ b/Iac/agent-service/README.md @@ -0,0 +1,32 @@ +# Agent Service — Infrastructure (Terraform) + +Provisions the Agent Service Azure Container App (`deliverybot-agent-service`). + +It reuses the shared resource group, Container App Environment, and ACR, and +only owns the Agent Service app itself. + +## Required inputs + +| Variable | Purpose | +|---|---| +| `azure_openai_endpoint` | Azure OpenAI resource endpoint | +| `azure_openai_deployment` | Azure OpenAI deployment name | +| `azure_openai_api_key_secret_name` | Key Vault secret name that stores the Azure OpenAI API key | +| `key_vault_uri` | Key Vault URI read by the Agent Service through managed identity | +| `transcript_archive_blob_service_uri` | Blob service URI used for transcript archive writes | +| `transcript_archive_container_name` | Blob container name used for transcript archive writes | +| `order_service_url` | Order Service URL used for live order enrichment | +| `simulator_api_url` | Robot Simulator URL used for live bot enrichment | +| `search_endpoint` | Azure AI Search endpoint used for chatbot grounding | +| `search_index_name` | Azure AI Search index name | +| `servicebus_fully_qualified_namespace` | Service Bus namespace used for support escalation publishing | +| `support_escalation_queue_name` | Service Bus queue name used for support escalation publishing | + +## Usage + +```bash +cd Iac/agent-service +terraform init +terraform plan +terraform apply +``` diff --git a/Iac/agent-service/main.tf b/Iac/agent-service/main.tf new file mode 100644 index 0000000..c3fc392 --- /dev/null +++ b/Iac/agent-service/main.tf @@ -0,0 +1,52 @@ +data "azurerm_resource_group" "rg" { + name = var.resource_group_name +} + +data "azurerm_container_app_environment" "env" { + name = var.container_app_environment_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +data "azurerm_container_registry" "acr" { + name = var.acr_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +module "agent_service_app" { + source = "./modules/container-app" + + name = var.container_app_name + resource_group_name = data.azurerm_resource_group.rg.name + container_app_environment_id = data.azurerm_container_app_environment.env.id + + acr_login_server = data.azurerm_container_registry.acr.login_server + acr_username = data.azurerm_container_registry.acr.admin_username + acr_password = data.azurerm_container_registry.acr.admin_password + + container_name = "agentservice" + image = "${data.azurerm_container_registry.acr.login_server}/${var.image_name}:latest" + target_port = 8080 + + env_vars = { + "ASPNETCORE_ENVIRONMENT" = "Production" + "AzureOpenAI__Endpoint" = var.azure_openai_endpoint + "AzureOpenAI__Deployment" = var.azure_openai_deployment + "AzureOpenAI__ApiVersion" = var.azure_openai_api_version + "AzureOpenAI__ApiKeySecretName" = var.azure_openai_api_key_secret_name + "KeyVault__VaultUri" = var.key_vault_uri + "Integrations__OrderServiceBaseUrl" = var.order_service_url + "Integrations__SimulatorBaseUrl" = var.simulator_api_url + "TranscriptArchive__BlobServiceUri" = var.transcript_archive_blob_service_uri + "TranscriptArchive__ContainerName" = var.transcript_archive_container_name + "TranscriptArchive__Enabled" = "true" + "Search__Enabled" = "true" + "Search__Endpoint" = var.search_endpoint + "Search__IndexName" = var.search_index_name + "ServiceBus__Enabled" = "true" + "ServiceBus__FullyQualifiedNamespace" = var.servicebus_fully_qualified_namespace + "ServiceBus__QueueName" = var.support_escalation_queue_name + "Cors__AllowedOrigins" = var.cors_allowed_origins + } + + tags = var.tags +} diff --git a/Iac/agent-service/modules/container-app/main.tf b/Iac/agent-service/modules/container-app/main.tf new file mode 100644 index 0000000..5495ace --- /dev/null +++ b/Iac/agent-service/modules/container-app/main.tf @@ -0,0 +1,74 @@ +resource "azurerm_container_app" "this" { + name = var.name + resource_group_name = var.resource_group_name + container_app_environment_id = var.container_app_environment_id + revision_mode = "Single" + tags = var.tags + + identity { + type = "SystemAssigned" + } + + secret { + name = "acr-password" + value = var.acr_password + } + + dynamic "secret" { + for_each = nonsensitive(toset(keys(var.secrets))) + content { + name = secret.value + value = var.secrets[secret.value] + } + } + + registry { + server = var.acr_login_server + username = var.acr_username + password_secret_name = "acr-password" + } + + ingress { + external_enabled = true + target_port = var.target_port + + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + template { + min_replicas = var.min_replicas + max_replicas = var.max_replicas + + container { + name = var.container_name + image = var.image + cpu = var.cpu + memory = var.memory + + dynamic "env" { + for_each = var.env_vars + content { + name = env.key + value = env.value + } + } + + dynamic "env" { + for_each = var.secret_env_vars + content { + name = env.key + secret_name = env.value + } + } + } + } + + lifecycle { + ignore_changes = [ + template[0].container[0].image, + ] + } +} diff --git a/Iac/agent-service/modules/container-app/outputs.tf b/Iac/agent-service/modules/container-app/outputs.tf new file mode 100644 index 0000000..535b96f --- /dev/null +++ b/Iac/agent-service/modules/container-app/outputs.tf @@ -0,0 +1,14 @@ +output "name" { + description = "Container App name." + value = azurerm_container_app.this.name +} + +output "url" { + description = "Public HTTPS URL of the Container App." + value = "https://${azurerm_container_app.this.latest_revision_fqdn}" +} + +output "identity_principal_id" { + description = "System-assigned managed identity principal ID." + value = azurerm_container_app.this.identity[0].principal_id +} diff --git a/Iac/agent-service/modules/container-app/variables.tf b/Iac/agent-service/modules/container-app/variables.tf new file mode 100644 index 0000000..b8efc80 --- /dev/null +++ b/Iac/agent-service/modules/container-app/variables.tf @@ -0,0 +1,94 @@ +variable "name" { + description = "Name of the Container App." + type = string +} + +variable "resource_group_name" { + description = "Resource group that hosts the Container App." + type = string +} + +variable "container_app_environment_id" { + description = "Managed environment resource ID." + type = string +} + +variable "acr_login_server" { + description = "Azure Container Registry login server." + type = string +} + +variable "acr_username" { + description = "Azure Container Registry admin username." + type = string +} + +variable "acr_password" { + description = "Azure Container Registry admin password." + type = string + sensitive = true +} + +variable "container_name" { + description = "Container name inside the app template." + type = string +} + +variable "image" { + description = "Container image reference." + type = string +} + +variable "target_port" { + description = "Public ingress target port." + type = number +} + +variable "cpu" { + description = "CPU allocated to the container." + type = number + default = 0.5 +} + +variable "memory" { + description = "Memory allocated to the container." + type = string + default = "1Gi" +} + +variable "min_replicas" { + description = "Minimum replica count." + type = number + default = 0 +} + +variable "max_replicas" { + description = "Maximum replica count." + type = number + default = 1 +} + +variable "env_vars" { + description = "Plain environment variables." + type = map(string) + default = {} +} + +variable "secret_env_vars" { + description = "Environment variables that reference secrets." + type = map(string) + default = {} +} + +variable "secrets" { + description = "Secret values keyed by secret name." + type = map(string) + sensitive = true + default = {} +} + +variable "tags" { + description = "Tags applied to the Container App." + type = map(string) + default = {} +} diff --git a/Iac/agent-service/outputs.tf b/Iac/agent-service/outputs.tf new file mode 100644 index 0000000..425860e --- /dev/null +++ b/Iac/agent-service/outputs.tf @@ -0,0 +1,14 @@ +output "container_app_name" { + description = "Name of the provisioned Agent Service Container App." + value = module.agent_service_app.name +} + +output "agent_service_url" { + description = "Public HTTPS URL of the Agent Service." + value = module.agent_service_app.url +} + +output "managed_identity_principal_id" { + description = "Principal ID of the app's system-assigned identity." + value = module.agent_service_app.identity_principal_id +} diff --git a/Iac/agent-service/variables.tf b/Iac/agent-service/variables.tf new file mode 100644 index 0000000..1ab3f45 --- /dev/null +++ b/Iac/agent-service/variables.tf @@ -0,0 +1,117 @@ +variable "resource_group_name" { + description = "Resource group that hosts the team's DeliveryBot resources." + type = string + default = "ewu-deliverybotsystem-rg" +} + +variable "container_app_environment_name" { + description = "Existing shared Container App Environment." + type = string + default = "deliverybot-dev-cae" +} + +variable "acr_name" { + description = "Existing shared Azure Container Registry the image is pulled from." + type = string + default = "deliverybotdevcr" +} + +variable "container_app_name" { + description = "Name of the Agent Service Container App." + type = string + default = "deliverybot-agent-service" +} + +variable "image_name" { + description = "Repository name of the Agent Service image in ACR." + type = string + default = "agentservice" +} + +variable "azure_openai_endpoint" { + description = "Azure OpenAI resource endpoint." + type = string +} + +variable "azure_openai_deployment" { + description = "Azure OpenAI deployment name used by the agent service." + type = string +} + +variable "azure_openai_api_version" { + description = "Azure OpenAI API version used for chat completions." + type = string + default = "2024-10-21" +} + +variable "azure_openai_api_key_secret_name" { + description = "Key Vault secret name that stores the Azure OpenAI API key." + type = string +} + +variable "key_vault_uri" { + description = "Vault URI used by the agent service to resolve secrets with managed identity." + type = string +} + +variable "transcript_archive_blob_service_uri" { + description = "Blob service URI used for transcript archive writes." + type = string +} + +variable "transcript_archive_container_name" { + description = "Blob container name used for transcript archive writes." + type = string +} + +variable "order_service_url" { + description = "Order Service base URL used for live order enrichment." + type = string + default = "" +} + +variable "simulator_api_url" { + description = "Robot Simulator base URL used for live bot enrichment." + type = string + default = "" +} + +variable "search_endpoint" { + description = "Azure AI Search endpoint used for agent grounding." + type = string + default = "" +} + +variable "search_index_name" { + description = "Azure AI Search index name used for agent grounding." + type = string + default = "delivery-agent-knowledge" +} + +variable "servicebus_fully_qualified_namespace" { + description = "Service Bus namespace host used for support escalation publishing." + type = string + default = "" +} + +variable "support_escalation_queue_name" { + description = "Service Bus queue name used for support escalation publishing." + type = string + default = "support-escalations" +} + +variable "cors_allowed_origins" { + description = "Comma-separated frontend origins allowed to call the Agent Service." + type = string + default = "" +} + +variable "tags" { + description = "Common tags applied to Agent Service resources." + type = map(string) + default = { + project = "DeliveryBot" + component = "agent-service" + } +} + diff --git a/Iac/agent-support.tf b/Iac/agent-support.tf new file mode 100644 index 0000000..bf3edfd --- /dev/null +++ b/Iac/agent-support.tf @@ -0,0 +1,226 @@ +data "azurerm_client_config" "current" {} + +resource "random_string" "agent_support_suffix" { + length = 6 + upper = false + special = false +} + +locals { + agent_key_vault_name = coalesce(var.agent_key_vault_name, "deliverybotagt-${random_string.agent_support_suffix.result}-kv") + agent_transcript_storage_account_name = coalesce(var.agent_transcript_storage_account_name, "deliverybotagt${random_string.agent_support_suffix.result}sa") + agent_search_service_name = coalesce(var.agent_search_service_name, "deliverybotagt-${random_string.agent_support_suffix.result}-search") + support_servicebus_namespace_name = coalesce(var.support_servicebus_namespace_name, "deliverybotagt-${random_string.agent_support_suffix.result}-sb") + api_management_name = coalesce(var.api_management_name, "deliverybotagt-${random_string.agent_support_suffix.result}-apim") +} + +resource "azurerm_key_vault" "agent" { + name = local.agent_key_vault_name + location = var.location + resource_group_name = var.resource_group_name + tenant_id = var.tenant_id + sku_name = "standard" + + soft_delete_retention_days = 7 + purge_protection_enabled = false +} + +resource "azurerm_key_vault_access_policy" "agent_provisioner" { + key_vault_id = azurerm_key_vault.agent.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + secret_permissions = [ + "Delete", + "Get", + "List", + "Purge", + "Recover", + "Set", + ] +} + +resource "azurerm_key_vault_secret" "agent_openai_api_key" { + name = var.agent_key_vault_openai_secret_name + value = var.azure_openai_api_key + key_vault_id = azurerm_key_vault.agent.id + + depends_on = [azurerm_key_vault_access_policy.agent_provisioner] +} + +resource "azurerm_storage_account" "agent_transcripts" { + name = local.agent_transcript_storage_account_name + resource_group_name = var.resource_group_name + location = var.location + account_tier = "Standard" + account_replication_type = "LRS" + min_tls_version = "TLS1_2" + + allow_nested_items_to_be_public = false + + blob_properties { + versioning_enabled = true + + delete_retention_policy { + days = 7 + } + + container_delete_retention_policy { + days = 7 + } + } +} + +resource "azurerm_storage_container" "agent_transcripts" { + name = var.agent_transcript_container_name + storage_account_id = azurerm_storage_account.agent_transcripts.id + container_access_type = "private" +} + +resource "azurerm_storage_container" "support_escalations" { + name = var.support_escalation_container_name + storage_account_id = azurerm_storage_account.agent_transcripts.id + container_access_type = "private" +} + +resource "azurerm_search_service" "agent" { + name = local.agent_search_service_name + resource_group_name = var.resource_group_name + location = var.location + sku = var.agent_search_sku + + identity { + type = "SystemAssigned" + } +} + +resource "azurerm_servicebus_namespace" "support" { + name = local.support_servicebus_namespace_name + location = var.location + resource_group_name = var.resource_group_name + sku = "Standard" +} + +resource "azurerm_servicebus_queue" "support_escalations" { + name = var.support_escalation_queue_name + namespace_id = azurerm_servicebus_namespace.support.id + + requires_duplicate_detection = true + duplicate_detection_history_time_window = "PT10M" +} + +resource "azurerm_api_management" "deliverybot" { + name = local.api_management_name + location = var.location + resource_group_name = var.resource_group_name + publisher_name = var.api_management_publisher_name + publisher_email = var.api_management_publisher_email + sku_name = "Consumption_0" + + identity { + type = "SystemAssigned" + } +} + +locals { + apim_apis = { + orders = { + display_name = "DeliveryBot Order Service" + path = "orders" + service_url = module.order_service.order_service_url + } + agent = { + display_name = "DeliveryBot Agent Service" + path = "agent" + service_url = module.agent_service.agent_service_url + } + botnet = { + display_name = "DeliveryBot BotNet API" + path = "botnet" + service_url = module.bot_api.bot_api_url + } + } + + apim_operation_methods = toset(["GET", "POST", "PUT", "PATCH", "DELETE"]) + apim_operations = { + for pair in setproduct(keys(local.apim_apis), local.apim_operation_methods) : + "${pair[0]}-${lower(pair[1])}" => { + api_name = pair[0] + method = pair[1] + } + } +} + +resource "azurerm_api_management_api" "deliverybot" { + for_each = local.apim_apis + + name = each.key + resource_group_name = var.resource_group_name + api_management_name = azurerm_api_management.deliverybot.name + revision = "1" + display_name = each.value.display_name + path = each.value.path + protocols = ["https"] + service_url = each.value.service_url + subscription_required = false +} + +resource "azurerm_api_management_api_operation" "deliverybot_proxy" { + for_each = local.apim_operations + + operation_id = "proxy-${lower(each.value.method)}" + api_name = azurerm_api_management_api.deliverybot[each.value.api_name].name + api_management_name = azurerm_api_management.deliverybot.name + resource_group_name = var.resource_group_name + display_name = "${each.value.method} proxy" + method = each.value.method + url_template = "/*" + + response { + status_code = 200 + } +} + +resource "azurerm_key_vault_access_policy" "agent_service" { + key_vault_id = azurerm_key_vault.agent.id + tenant_id = var.tenant_id + object_id = module.agent_service.managed_identity_principal_id + + secret_permissions = ["Get"] +} + +resource "azurerm_role_assignment" "agent_transcript_blob_contributor" { + scope = azurerm_storage_account.agent_transcripts.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = module.agent_service.managed_identity_principal_id +} + +resource "azurerm_role_assignment" "agent_search_index_contributor" { + scope = azurerm_search_service.agent.id + role_definition_name = "Search Index Data Contributor" + principal_id = module.agent_service.managed_identity_principal_id +} + +resource "azurerm_role_assignment" "agent_search_service_contributor" { + scope = azurerm_search_service.agent.id + role_definition_name = "Search Service Contributor" + principal_id = module.agent_service.managed_identity_principal_id +} + +resource "azurerm_role_assignment" "agent_servicebus_sender" { + scope = azurerm_servicebus_queue.support_escalations.id + role_definition_name = "Azure Service Bus Data Sender" + principal_id = module.agent_service.managed_identity_principal_id +} + +resource "azurerm_role_assignment" "function_servicebus_receiver" { + scope = azurerm_servicebus_queue.support_escalations.id + role_definition_name = "Azure Service Bus Data Receiver" + principal_id = module.readable_bot_network_representation.function_app_principal_id +} + +resource "azurerm_role_assignment" "function_support_blob_contributor" { + scope = azurerm_storage_account.agent_transcripts.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = module.readable_bot_network_representation.function_app_principal_id +} diff --git a/Iac/backend.tf b/Iac/backend.tf index a152909..6602f20 100644 --- a/Iac/backend.tf +++ b/Iac/backend.tf @@ -1,23 +1,3 @@ -# Terraform remote state backend. -# -# State is stored in the pre-existing Azure Blob Storage account: -# Storage account : dbstfstate01 -# Resource group : ewu-deliverybotsystem-rg -# Container : tfstate -# Blob key : deliverybot.tfstate -# -# Auth uses OIDC + Azure AD (no SAS tokens or storage keys). -# The storage account and container were verified via: -# az storage account show --name dbstfstate01 -# az storage container list --account-name dbstfstate01 --auth-mode login - terraform { - backend "azurerm" { - resource_group_name = "ewu-deliverybotsystem-rg" - storage_account_name = "dbstfstate01" - container_name = "tfstate" - key = "deliverybot.tfstate" - use_oidc = true - use_azuread_auth = true - } + backend "azurerm" {} } diff --git a/Iac/bot-api/variables.tf b/Iac/bot-api/variables.tf index c8e853c..9f0da1c 100644 --- a/Iac/bot-api/variables.tf +++ b/Iac/bot-api/variables.tf @@ -7,19 +7,19 @@ variable "resource_group_name" { variable "container_app_environment_name" { description = "Existing shared Container App Environment (managed by shared-infra)." type = string - default = "managedEnvironment-ewudeliverybots-aa2f" + default = "deliverybot-dev-cae" } variable "acr_name" { description = "Existing shared Azure Container Registry (managed by shared-infra)." type = string - default = "DeliverybotCR" + default = "deliverybotdevcr" } variable "sql_server_name" { description = "Existing shared SQL server (managed by shared-infra)." type = string - default = "deliverybotsystem-sql" + default = "deliverybot-dev-sql" } variable "container_app_name" { @@ -38,7 +38,7 @@ variable "sql_connection_string" { description = "SQL connection string for BotNetApiDb. Uses Managed Identity auth — passed in from the CD pipeline, never committed." type = string sensitive = true - default = "Server=tcp:deliverybotsystem-sql.database.windows.net,1433;Initial Catalog=BotNetApiDb;Authentication=Active Directory Managed Identity;" + default = "Server=tcp:deliverybot-dev-sql.database.windows.net,1433;Initial Catalog=BotNetApiDb;Authentication=Active Directory Managed Identity;" } variable "tags" { @@ -50,3 +50,4 @@ variable "tags" { owner = "wmiller17" } } + diff --git a/Iac/final-project.tfvars.example b/Iac/final-project.tfvars.example new file mode 100644 index 0000000..b558cbc --- /dev/null +++ b/Iac/final-project.tfvars.example @@ -0,0 +1,49 @@ +resource_group_name = "ewu-deliverybotsystem-rg" +location = "westus2" +eventhub_location = "centralus" +sql_location = "centralus" +app_service_plan_name = "ASP-RGDeliveryBotdev-8b82" +app_service_plan_location = "canadacentral" +app_service_plan_sku_name = "B1" +create_app_service_plan = false +existing_app_service_plan_resource_group_name = "ewu-deliverybotsystem-rg" +customer_frontend_app_service_name = "WA-DeliveryBot-dev" +admin_app_service_name = "WA-DeliveryBot-Admin-dev" +bot_api_container_app_name = "ewu-deliverybotsystem-api" +order_service_container_app_name = "deliverybot-order-service" +agent_service_container_app_name = "deliverybot-agent-dev" +simulator_container_app_name = "deliverybot-robot-simulator" +container_app_environment_name = "managedEnvironment-ewudeliverybots-aa2f" +create_container_app_environment = false +existing_container_app_environment_resource_group_name = "ewu-deliverybotsystem-rg" +eventhub_namespace_name = "DeliverybotSimulator-EVHNS" +acr_name = "DeliverybotCR" +bot_api_sql_server_name = "deliverybotsystem-sql" +botnet_api_url = "https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io" +simulator_api_url = "https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io" +azure_openai_endpoint = "https://your-openai-resource.openai.azure.com/" +azure_openai_deployment = "bot-assistant" +agent_key_vault_name = "dbagentfinalkv" +agent_key_vault_openai_secret_name = "azure-openai-api-key" +agent_transcript_storage_account_name = "deliverybotagentfinalsa" +agent_transcript_container_name = "agent-transcripts" +support_escalation_container_name = "support-escalations" +agent_search_service_name = "deliverybot-agent-final-search" +agent_search_index_name = "delivery-agent-knowledge" +support_servicebus_namespace_name = "deliverybot-agent-final-sb" +support_escalation_queue_name = "support-escalations" +api_management_name = "deliverybot-final-apim" +api_management_publisher_name = "DeliveryBot" +api_management_publisher_email = "deliverybot@example.com" +readable_bot_network_name_prefix = "deliverybot" +readable_bot_network_environment = "final" +readable_bot_network_consumer_group_name = "readable-bot-network-final" +readable_bot_network_cosmos_database_name = "bot-network" +readable_bot_network_cosmos_container_name = "bots" +readable_bot_network_diagnostics_container_name = "function-diagnostics" +readable_bot_network_cosmos_account_name = "deliverybot-final-rbnr-cosmos" +readable_bot_network_function_app_name = "deliverybot-final-rbnr-func" +readable_bot_network_service_plan_name = "deliverybot-final-rbnr-plan" +readable_bot_network_storage_account_name = "deliverybotfinalrbnrsa" +readable_bot_network_log_analytics_workspace_name = "deliverybot-final-rbnr-law" +readable_bot_network_application_insights_name = "deliverybot-final-rbnr-ai" diff --git a/Iac/frontend/main.tf b/Iac/frontend/main.tf index b052cb9..472739e 100644 --- a/Iac/frontend/main.tf +++ b/Iac/frontend/main.tf @@ -1,14 +1,10 @@ -# Customer Frontend infrastructure. -# -# Reuses the team's shared resource group and App Service Plan. -# This stack only owns the customer-facing Web App (WA-DeliveryBot-dev). - module "frontend_webapp" { source = "./modules/webapp" - resource_group_name = var.resource_group_name - app_service_plan_name = var.app_service_plan_name - app_service_name = var.app_service_name - node_version = var.node_version - tags = var.tags + resource_group_name = var.resource_group_name + location = var.location + app_service_plan_id = var.app_service_plan_id + app_service_name = var.app_service_name + node_version = var.node_version + tags = var.tags } diff --git a/Iac/frontend/modules/webapp/main.tf b/Iac/frontend/modules/webapp/main.tf index 69c4bdc..da38814 100644 --- a/Iac/frontend/modules/webapp/main.tf +++ b/Iac/frontend/modules/webapp/main.tf @@ -1,9 +1,3 @@ -# Reusable module: a Linux App Service that hosts a static SPA via pm2. -# -# Reuses an existing resource group and App Service Plan (passed by name) so -# the team isn't billed for a duplicate plan. The only managed resource is the -# App Service itself. - terraform { required_providers { azurerm = { @@ -17,16 +11,11 @@ data "azurerm_resource_group" "rg" { name = var.resource_group_name } -data "azurerm_service_plan" "plan" { - name = var.app_service_plan_name - resource_group_name = data.azurerm_resource_group.rg.name -} - resource "azurerm_linux_web_app" "frontend" { name = var.app_service_name resource_group_name = data.azurerm_resource_group.rg.name - location = data.azurerm_service_plan.plan.location - service_plan_id = data.azurerm_service_plan.plan.id + location = var.location + service_plan_id = var.app_service_plan_id https_only = true identity { @@ -41,7 +30,6 @@ resource "azurerm_linux_web_app" "frontend" { node_version = var.node_version } - # Allow the GitHub Actions workflow to push builds. scm_use_main_ip_restriction = true } @@ -53,7 +41,6 @@ resource "azurerm_linux_web_app" "frontend" { lifecycle { ignore_changes = [ - # Deployments overwrite the build artifact; don't fight the workflow. app_settings["WEBSITE_RUN_FROM_PACKAGE"], ] } diff --git a/Iac/frontend/modules/webapp/variables.tf b/Iac/frontend/modules/webapp/variables.tf index 44a25ec..9c71820 100644 --- a/Iac/frontend/modules/webapp/variables.tf +++ b/Iac/frontend/modules/webapp/variables.tf @@ -1,10 +1,15 @@ variable "resource_group_name" { - description = "Resource group that hosts the team's DeliveryBot resources." + description = "Resource group that hosts the DeliveryBot resources." type = string } -variable "app_service_plan_name" { - description = "Existing App Service Plan to reuse." +variable "location" { + description = "Region for the App Service Plan and frontend app." + type = string +} + +variable "app_service_plan_id" { + description = "Resource ID of the shared App Service Plan." type = string } diff --git a/Iac/frontend/variables.tf b/Iac/frontend/variables.tf index dfc03d4..c13080a 100644 --- a/Iac/frontend/variables.tf +++ b/Iac/frontend/variables.tf @@ -1,13 +1,18 @@ variable "resource_group_name" { - description = "Resource group that hosts the team's DeliveryBot resources." + description = "Resource group that hosts the DeliveryBot resources." type = string default = "ewu-deliverybotsystem-rg" } -variable "app_service_plan_name" { - description = "Existing App Service Plan to reuse (shared with the Admin site)." +variable "location" { + description = "Region for the shared App Service Plan and frontend app." + type = string + default = "westus2" +} + +variable "app_service_plan_id" { + description = "Resource ID of the shared App Service Plan." type = string - default = "ASP-RGDeliveryBotdev-8b82" } variable "app_service_name" { diff --git a/Iac/imports.tf b/Iac/imports-shared-dev-reference.tf.example similarity index 92% rename from Iac/imports.tf rename to Iac/imports-shared-dev-reference.tf.example index 30a8096..301045f 100644 --- a/Iac/imports.tf +++ b/Iac/imports-shared-dev-reference.tf.example @@ -1,11 +1,12 @@ # --------------------------------------------------------------------------- -# One-time import of all pre-existing Azure resources into Terraform state. +# Example import file for the older shared development environment. # -# Import blocks MUST live in the root module — Terraform does not allow them -# inside child modules. All addresses below are fully-qualified from root. +# This file is intentionally not loaded by Terraform because it ends with +# ".tf.example" instead of ".tf". Rename it only if you are importing the +# pre-existing shared Azure resources into Terraform state. # -# SAFE TO DELETE after the first successful apply that shows these resources -# as "already imported" (no changes planned for them). +# For the final-project resource group, leave this file as-is and +# start with a clean state. # --------------------------------------------------------------------------- locals { diff --git a/Iac/main.tf b/Iac/main.tf index 0e19083..26d07a6 100644 --- a/Iac/main.tf +++ b/Iac/main.tf @@ -1,105 +1,138 @@ -# Universal DeliveryBot infrastructure — root composition file. -# -# This is the single top-level configuration that wires together all -# per-service modules. Each subdirectory of Iac/ is a Terraform module that -# owns the resources for one service; this file injects the shared variables -# into each one and composes them into a single apply. -# -# Dependency ordering: -# shared-infra → no dependencies on other modules -# bot-api → depends on shared SQL server (data source inside module) -# order-service → depends on shared CAE + ACR (data sources inside module) -# simulator → depends on shared CAE + ACR + Event Hub NS -# admin-webapp → no Azure dependencies on other modules (App Service Plan -# is looked up by name via data source) -# frontend → same pattern as admin-webapp -# -# Terraform resolves the apply order automatically from data source / output -# references. No explicit depends_on is needed here. - -# ── Shared infrastructure ────────────────────────────────────────────────────── -# Owns: ACR, Log Analytics workspace, Container App Environment, -# Event Hub namespace + hubs, SQL server + firewall rule. - module "shared_infra" { source = "./shared-infra" - resource_group_name = var.resource_group_name - location = var.location - sql_location = var.sql_location - sql_ad_admin_login = var.sql_ad_admin_login - sql_ad_admin_object_id = var.sql_ad_admin_object_id - tenant_id = var.tenant_id + resource_group_name = var.resource_group_name + location = var.location + eventhub_location = var.eventhub_location + sql_location = var.sql_location + acr_name = var.acr_name + app_service_plan_name = var.app_service_plan_name + app_service_plan_sku_name = var.app_service_plan_sku_name + create_app_service_plan = var.create_app_service_plan + existing_app_service_plan_resource_group_name = var.existing_app_service_plan_resource_group_name + container_app_environment_name = var.container_app_environment_name + create_container_app_environment = var.create_container_app_environment + existing_container_app_environment_resource_group_name = var.existing_container_app_environment_resource_group_name + eventhub_namespace_name = var.eventhub_namespace_name + robot_input_partition_count = var.robot_input_partition_count + robot_output_partition_count = var.robot_output_partition_count + sql_server_name = var.bot_api_sql_server_name + sql_ad_admin_login = var.sql_ad_admin_login + sql_ad_admin_object_id = var.sql_ad_admin_object_id + tenant_id = var.tenant_id } -# ── Admin Web App ────────────────────────────────────────────────────────────── -# Owns: the WA-DeliveryBot-Admin-dev App Service. - module "admin_webapp" { source = "./admin-webapp" - resource_group_name = var.resource_group_name - app_service_plan_name = var.app_service_plan_name - app_service_name = var.admin_app_service_name - node_version = var.node_version - botnet_api_url = var.botnet_api_url - simulator_api_url = var.simulator_api_url + resource_group_name = var.resource_group_name + location = var.app_service_plan_location + app_service_plan_id = module.shared_infra.app_service_plan_id + app_service_name = var.admin_app_service_name + node_version = var.node_version + botnet_api_url = var.botnet_api_url + simulator_api_url = var.simulator_api_url } -# ── Order Service ────────────────────────────────────────────────────────────── -# Owns: the deliverybot-order-service Container App. - module "order_service" { source = "./order-service" resource_group_name = var.resource_group_name - container_app_environment_name = var.container_app_environment_name - acr_name = var.acr_name + container_app_environment_name = module.shared_infra.container_app_environment_name + acr_name = module.shared_infra.acr_name container_app_name = var.order_service_container_app_name sql_connection_string = var.order_service_sql_connection_string eventhub_connection_string = var.eventhub_connection_string + event_hub_namespace_name = module.shared_infra.eventhub_namespace_name botnet_api_url = var.botnet_api_url } -# ── Bot API ──────────────────────────────────────────────────────────────────── -# Owns: the ewu-deliverybotsystem-api Container App and its SQL database. +module "agent_service" { + source = "./agent-service" + + resource_group_name = var.resource_group_name + container_app_environment_name = module.shared_infra.container_app_environment_name + acr_name = module.shared_infra.acr_name + container_app_name = var.agent_service_container_app_name + azure_openai_endpoint = var.azure_openai_endpoint + azure_openai_deployment = var.azure_openai_deployment + azure_openai_api_version = var.azure_openai_api_version + azure_openai_api_key_secret_name = azurerm_key_vault_secret.agent_openai_api_key.name + key_vault_uri = azurerm_key_vault.agent.vault_uri + transcript_archive_blob_service_uri = azurerm_storage_account.agent_transcripts.primary_blob_endpoint + transcript_archive_container_name = azurerm_storage_container.agent_transcripts.name + order_service_url = module.order_service.order_service_url + simulator_api_url = module.simulator.container_app_url + search_endpoint = "https://${azurerm_search_service.agent.name}.search.windows.net" + search_index_name = var.agent_search_index_name + servicebus_fully_qualified_namespace = "${azurerm_servicebus_namespace.support.name}.servicebus.windows.net" + support_escalation_queue_name = azurerm_servicebus_queue.support_escalations.name + cors_allowed_origins = module.frontend.app_url +} + +module "readable_bot_network_representation" { + source = "./modules/readable-bot-network-representation" + + resource_group_name = var.resource_group_name + location = var.location + name_prefix = var.readable_bot_network_name_prefix + environment = var.readable_bot_network_environment + + eventhub_resource_group_name = var.readable_bot_network_eventhub_resource_group_name + eventhub_namespace_name = var.eventhub_namespace_name + robot_output_eventhub_name = var.readable_bot_network_robot_output_eventhub_name + eventhub_consumer_group_name = var.readable_bot_network_consumer_group_name + + cosmos_account_name = var.readable_bot_network_cosmos_account_name + cosmos_database_name = var.readable_bot_network_cosmos_database_name + cosmos_container_name = var.readable_bot_network_cosmos_container_name + cosmos_diagnostics_container_name = var.readable_bot_network_diagnostics_container_name + function_app_name = var.readable_bot_network_function_app_name + service_plan_name = var.readable_bot_network_service_plan_name + storage_account_name = var.readable_bot_network_storage_account_name + log_analytics_workspace_name = var.readable_bot_network_log_analytics_workspace_name + application_insights_name = var.readable_bot_network_application_insights_name + assign_eventhub_receiver_role = var.readable_bot_network_assign_eventhub_receiver_role + assign_cosmos_data_contributor_role = var.readable_bot_network_assign_cosmos_data_contributor_role + create_eventhub_consumer_group = var.readable_bot_network_create_eventhub_consumer_group + additional_app_settings = { + SupportEscalationQueueName = azurerm_servicebus_queue.support_escalations.name + SupportEscalationServiceBus__fullyQualifiedNamespace = "${azurerm_servicebus_namespace.support.name}.servicebus.windows.net" + SupportEscalationServiceBus__credential = "managedidentity" + EscalationArchive__BlobServiceUri = azurerm_storage_account.agent_transcripts.primary_blob_endpoint + EscalationArchive__ContainerName = azurerm_storage_container.support_escalations.name + } +} module "bot_api" { source = "./bot-api" resource_group_name = var.resource_group_name - container_app_environment_name = var.container_app_environment_name - acr_name = var.acr_name - sql_server_name = var.bot_api_sql_server_name + container_app_environment_name = module.shared_infra.container_app_environment_name + acr_name = module.shared_infra.acr_name + sql_server_name = module.shared_infra.sql_server_name container_app_name = var.bot_api_container_app_name sql_connection_string = var.bot_api_sql_connection_string } -# ── Customer Frontend ────────────────────────────────────────────────────────── -# Owns: the WA-DeliveryBot-dev App Service. - module "frontend" { source = "./frontend" - resource_group_name = var.resource_group_name - app_service_plan_name = var.app_service_plan_name - app_service_name = var.customer_frontend_app_service_name - node_version = var.node_version + resource_group_name = var.resource_group_name + location = var.app_service_plan_location + app_service_plan_id = module.shared_infra.app_service_plan_id + app_service_name = var.customer_frontend_app_service_name + node_version = var.node_version } -# ── Robot Simulator ──────────────────────────────────────────────────────────── -# Owns: the deliverybot-robot-simulator Container App. -# Note: simulator/variables.tf uses container_app_env_name (not -# container_app_environment_name) for historical reasons; mapped here. - module "simulator" { source = "./simulator" resource_group_name = var.resource_group_name location = var.location - container_app_env_name = var.container_app_environment_name - acr_name = var.acr_name - event_hub_namespace_name = var.eventhub_namespace_name + container_app_env_name = module.shared_infra.container_app_environment_name + acr_name = module.shared_infra.acr_name + event_hub_namespace_name = module.shared_infra.eventhub_namespace_name eventhub_connection_string = var.eventhub_connection_string container_app_name = var.simulator_container_app_name } diff --git a/Iac/order-service/variables.tf b/Iac/order-service/variables.tf index 7b0e048..defa028 100644 --- a/Iac/order-service/variables.tf +++ b/Iac/order-service/variables.tf @@ -7,13 +7,13 @@ variable "resource_group_name" { variable "container_app_environment_name" { description = "Existing shared Container App Environment (created by the root Iac)." type = string - default = "managedEnvironment-ewudeliverybots-aa2f" + default = "deliverybot-dev-cae" } variable "acr_name" { description = "Existing shared Azure Container Registry the image is pulled from." type = string - default = "DeliverybotCR" + default = "deliverybotdevcr" } variable "container_app_name" { @@ -31,7 +31,7 @@ variable "image_name" { variable "botnet_api_url" { description = "Base URL of the BotNet API the Order Service calls to select a bot." type = string - default = "https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io" + default = "https://deliverybot-botapi-dev.example.com" } variable "sql_connection_string" { @@ -49,7 +49,7 @@ variable "eventhub_connection_string" { variable "event_hub_namespace_name" { description = "Event Hub namespace hosting the simulator's robot-input/robot-output hubs." type = string - default = "DeliverybotSimulator-EVHNS" + default = "deliverybot-dev-evhns" } variable "status_event_hub_name" { @@ -74,3 +74,4 @@ variable "tags" { issue = "#43" } } + diff --git a/Iac/outputs.tf b/Iac/outputs.tf index 36dba01..cdb5f67 100644 --- a/Iac/outputs.tf +++ b/Iac/outputs.tf @@ -31,6 +31,83 @@ output "order_service_url" { value = module.order_service.order_service_url } +output "agent_service_url" { + description = "HTTPS URL of the Agent Service Container App." + value = module.agent_service.agent_service_url +} + +output "agent_key_vault_name" { + description = "Name of the Key Vault used by the Agent Service for secret retrieval." + value = azurerm_key_vault.agent.name +} + +output "agent_key_vault_uri" { + description = "Vault URI used by the Agent Service to read secrets with managed identity." + value = azurerm_key_vault.agent.vault_uri +} + +output "agent_transcript_storage_account_name" { + description = "Storage account name used for archived agent transcripts." + value = azurerm_storage_account.agent_transcripts.name +} + +output "agent_transcript_container_name" { + description = "Blob container used for archived agent transcripts." + value = azurerm_storage_container.agent_transcripts.name +} + +output "support_escalation_queue_name" { + description = "Service Bus queue used for support escalation workflow." + value = azurerm_servicebus_queue.support_escalations.name +} + +output "agent_search_service_name" { + description = "Azure AI Search service used for agent grounding." + value = azurerm_search_service.agent.name +} + +output "agent_search_index_name" { + description = "Azure AI Search index queried by the Agent Service." + value = var.agent_search_index_name +} + +output "api_management_gateway_url" { + description = "Gateway URL for the API Management facade." + value = azurerm_api_management.deliverybot.gateway_url +} + +# ── Readable Bot Network Representation ─────────────────────────────────────── + +output "readable_bot_network_function_app_name" { + description = "Name of the readable bot network Function App." + value = module.readable_bot_network_representation.function_app_name +} + +output "readable_bot_network_cosmos_account_name" { + description = "Name of the readable bot network Cosmos DB account." + value = module.readable_bot_network_representation.cosmos_account_name +} + +output "readable_bot_network_cosmos_database_name" { + description = "Cosmos DB database name for the readable bot network." + value = module.readable_bot_network_representation.cosmos_database_name +} + +output "readable_bot_network_cosmos_container_name" { + description = "Cosmos DB container name for current bot documents." + value = module.readable_bot_network_representation.cosmos_container_name +} + +output "readable_bot_network_diagnostics_container_name" { + description = "Cosmos DB diagnostics container name for the readable bot network." + value = module.readable_bot_network_representation.cosmos_diagnostics_container_name +} + +output "readable_bot_network_application_insights_name" { + description = "Application Insights resource name for the readable bot network Function App." + value = module.readable_bot_network_representation.application_insights_name +} + # ── Bot API ──────────────────────────────────────────────────────────────────── output "bot_api_url" { diff --git a/Iac/shared-infra/main.tf b/Iac/shared-infra/main.tf index 3d5c325..19de03b 100644 --- a/Iac/shared-infra/main.tf +++ b/Iac/shared-infra/main.tf @@ -2,41 +2,46 @@ data "azurerm_resource_group" "rg" { name = var.resource_group_name } -# ── Azure Container Registry ──────────────────────────────────────────────── +locals { + log_analytics_workspace_name = coalesce(var.log_analytics_workspace_name, "${var.app_service_plan_name}-logs") + existing_container_app_environment_rg_name = coalesce(var.existing_container_app_environment_resource_group_name, data.azurerm_resource_group.rg.name) + existing_app_service_plan_resource_group_name = coalesce(var.existing_app_service_plan_resource_group_name, data.azurerm_resource_group.rg.name) +} resource "azurerm_container_registry" "acr" { - name = "DeliverybotCR" + name = var.acr_name resource_group_name = data.azurerm_resource_group.rg.name location = var.location sku = "Standard" admin_enabled = true } -# ── Log Analytics Workspace ───────────────────────────────────────────────── - resource "azurerm_log_analytics_workspace" "logs" { - name = "workspaceewudeliverybotsystemrg8609" + name = local.log_analytics_workspace_name resource_group_name = data.azurerm_resource_group.rg.name location = var.location sku = "PerGB2018" retention_in_days = 30 } -# ── Container Apps Managed Environment ───────────────────────────────────── - resource "azurerm_container_app_environment" "env" { - name = "managedEnvironment-ewudeliverybots-aa2f" + count = var.create_container_app_environment ? 1 : 0 + name = var.container_app_environment_name resource_group_name = data.azurerm_resource_group.rg.name location = var.location log_analytics_workspace_id = azurerm_log_analytics_workspace.logs.id } -# ── Event Hub Namespace ───────────────────────────────────────────────────── +data "azurerm_container_app_environment" "existing_env" { + count = var.create_container_app_environment ? 0 : 1 + name = var.container_app_environment_name + resource_group_name = local.existing_container_app_environment_rg_name +} resource "azurerm_eventhub_namespace" "simulator" { - name = "DeliverybotSimulator-EVHNS" + name = var.eventhub_namespace_name resource_group_name = data.azurerm_resource_group.rg.name - location = var.location + location = var.eventhub_location sku = "Standard" capacity = 1 } @@ -44,24 +49,42 @@ resource "azurerm_eventhub_namespace" "simulator" { resource "azurerm_eventhub" "robot_input" { name = "robot-input" namespace_id = azurerm_eventhub_namespace.simulator.id - partition_count = 2 - message_retention = 1 + partition_count = var.robot_input_partition_count + message_retention = var.robot_input_message_retention + + lifecycle { + ignore_changes = [partition_count, message_retention] + } } resource "azurerm_eventhub" "robot_output" { name = "robot-output" namespace_id = azurerm_eventhub_namespace.simulator.id - partition_count = 2 - message_retention = 1 + partition_count = var.robot_output_partition_count + message_retention = var.robot_output_message_retention + + lifecycle { + ignore_changes = [partition_count, message_retention] + } } -# ── SQL Server ────────────────────────────────────────────────────────────── -# -# Azure AD-only authentication — no SQL login password. -# Each service stack owns its own database on this server. +resource "azurerm_service_plan" "shared" { + count = var.create_app_service_plan ? 1 : 0 + name = var.app_service_plan_name + resource_group_name = data.azurerm_resource_group.rg.name + location = var.location + os_type = "Linux" + sku_name = var.app_service_plan_sku_name +} + +data "azurerm_service_plan" "existing_shared" { + count = var.create_app_service_plan ? 0 : 1 + name = var.app_service_plan_name + resource_group_name = local.existing_app_service_plan_resource_group_name +} resource "azurerm_mssql_server" "sql" { - name = "deliverybotsystem-sql" + name = var.sql_server_name resource_group_name = data.azurerm_resource_group.rg.name location = var.sql_location version = "12.0" @@ -74,10 +97,9 @@ resource "azurerm_mssql_server" "sql" { } } -# Allow Azure services (Container Apps) to reach the SQL server. resource "azurerm_mssql_firewall_rule" "allow_azure_services" { name = "AllowAzureServices" server_id = azurerm_mssql_server.sql.id start_ip_address = "0.0.0.0" end_ip_address = "0.0.0.0" -} +} \ No newline at end of file diff --git a/Iac/shared-infra/outputs.tf b/Iac/shared-infra/outputs.tf index 202c290..450d8dc 100644 --- a/Iac/shared-infra/outputs.tf +++ b/Iac/shared-infra/outputs.tf @@ -1,3 +1,15 @@ +locals { + app_service_plan_name_value = var.create_app_service_plan ? azurerm_service_plan.shared[0].name : data.azurerm_service_plan.existing_shared[0].name + app_service_plan_id_value = var.create_app_service_plan ? azurerm_service_plan.shared[0].id : data.azurerm_service_plan.existing_shared[0].id + container_app_environment_name_value = var.create_container_app_environment ? azurerm_container_app_environment.env[0].name : data.azurerm_container_app_environment.existing_env[0].name + container_app_environment_id_value = var.create_container_app_environment ? azurerm_container_app_environment.env[0].id : data.azurerm_container_app_environment.existing_env[0].id +} + +output "acr_name" { + description = "Name of the shared Azure Container Registry." + value = azurerm_container_registry.acr.name +} + output "acr_login_server" { description = "ACR login server hostname (e.g. deliverybotcr.azurecr.io)." value = azurerm_container_registry.acr.login_server @@ -14,14 +26,24 @@ output "acr_admin_password" { sensitive = true } +output "app_service_plan_id" { + description = "Resource ID of the shared App Service Plan." + value = local.app_service_plan_id_value +} + +output "app_service_plan_name" { + description = "Name of the shared App Service Plan." + value = local.app_service_plan_name_value +} + output "container_app_environment_id" { description = "Resource ID of the Container Apps managed environment." - value = azurerm_container_app_environment.env.id + value = local.container_app_environment_id_value } output "container_app_environment_name" { description = "Name of the Container Apps managed environment." - value = azurerm_container_app_environment.env.name + value = local.container_app_environment_name_value } output "sql_server_id" { @@ -29,6 +51,11 @@ output "sql_server_id" { value = azurerm_mssql_server.sql.id } +output "sql_server_name" { + description = "Name of the shared SQL server." + value = azurerm_mssql_server.sql.name +} + output "sql_server_fqdn" { description = "Fully-qualified domain name of the SQL server." value = azurerm_mssql_server.sql.fully_qualified_domain_name diff --git a/Iac/shared-infra/variables.tf b/Iac/shared-infra/variables.tf index 8fac6fd..8ed937c 100644 --- a/Iac/shared-infra/variables.tf +++ b/Iac/shared-infra/variables.tf @@ -5,31 +5,119 @@ variable "resource_group_name" { } variable "location" { - description = "Primary Azure region for shared resources." + description = "Primary Azure region for shared application platform resources." type = string default = "westus2" } +variable "eventhub_location" { + description = "Azure region for the Event Hubs namespace." + type = string + default = "centralus" +} + variable "sql_location" { - description = "Azure region for the SQL server (kept in southeastasia for cost/availability)." + description = "Azure region for the SQL server." + type = string + default = "centralus" +} + +variable "acr_name" { + description = "Name of the shared Azure Container Registry." + type = string +} + +variable "app_service_plan_name" { + description = "Name of the shared App Service Plan used by the web apps." + type = string +} + +variable "app_service_plan_sku_name" { + description = "SKU for the shared App Service Plan." + type = string + default = "B1" +} + +variable "create_app_service_plan" { + description = "Whether to create the shared App Service Plan in this stack." + type = bool + default = true +} + +variable "existing_app_service_plan_resource_group_name" { + description = "Optional resource group for an existing shared App Service Plan. Defaults to resource_group_name." + type = string + default = null +} + +variable "container_app_environment_name" { + description = "Name of the shared Container Apps managed environment." + type = string +} + +variable "create_container_app_environment" { + description = "Whether to create the shared Container Apps managed environment in this stack." + type = bool + default = true +} + +variable "existing_container_app_environment_resource_group_name" { + description = "Optional resource group for an existing Container Apps managed environment. Defaults to resource_group_name." + type = string + default = null +} + +variable "eventhub_namespace_name" { + description = "Name of the shared Event Hub namespace." + type = string +} + +variable "robot_input_partition_count" { + description = "Partition count for the robot-input Event Hub." + type = number + default = 2 +} + +variable "robot_output_partition_count" { + description = "Partition count for the robot-output Event Hub." + type = number + default = 2 +} + +variable "robot_input_message_retention" { + description = "Message retention in days for the robot-input Event Hub." + type = number + default = 7 +} + +variable "robot_output_message_retention" { + description = "Message retention in days for the robot-output Event Hub." + type = number + default = 7 +} + +variable "log_analytics_workspace_name" { + description = "Optional explicit Log Analytics workspace name for shared resources." + type = string + default = null +} + +variable "sql_server_name" { + description = "Name of the shared SQL server." type = string - default = "southeastasia" } variable "sql_ad_admin_login" { description = "UPN of the Azure AD user set as SQL server administrator." type = string - default = "wmiller17@ewu.edu" } variable "sql_ad_admin_object_id" { description = "Object ID of the Azure AD SQL administrator." type = string - default = "0b83fd03-d44e-4731-8ee0-790b50b715db" } variable "tenant_id" { description = "Azure Active Directory tenant ID." type = string - default = "37321907-14a5-4390-987d-ec0c66c655cd" } diff --git a/Iac/variables.tf b/Iac/variables.tf index 69da6f9..23745d3 100644 --- a/Iac/variables.tf +++ b/Iac/variables.tf @@ -1,13 +1,3 @@ -# Root-level variable declarations. -# -# These are the "injection points" this file talks about: the root main.tf -# reads these values and passes them into each service module. Variables -# that are only used by one module use a descriptive prefix (e.g. -# admin_app_service_name) to avoid collisions; variables shared across -# multiple modules keep a simple name (e.g. resource_group_name). - -# ── Shared infrastructure ────────────────────────────────────────────────────── - variable "resource_group_name" { description = "Resource group shared by all DeliveryBot resources." type = string @@ -15,11 +5,17 @@ variable "resource_group_name" { } variable "location" { - description = "Primary Azure region (Container Apps, Event Hubs, etc.)." + description = "Primary Azure region for container-based and shared app resources." type = string default = "westus2" } +variable "eventhub_location" { + description = "Azure region for the Event Hubs namespace." + type = string + default = "centralus" +} + variable "acr_name" { description = "Name of the shared Azure Container Registry." type = string @@ -32,18 +28,40 @@ variable "container_app_environment_name" { default = "managedEnvironment-ewudeliverybots-aa2f" } +variable "create_container_app_environment" { + description = "Whether to create the shared Container Apps managed environment in this stack." + type = bool + default = false +} + +variable "existing_container_app_environment_resource_group_name" { + description = "Optional resource group for an existing Container Apps managed environment. Defaults to resource_group_name." + type = string + default = null +} + variable "eventhub_namespace_name" { description = "Name of the shared Event Hub namespace." type = string default = "DeliverybotSimulator-EVHNS" } -# ── Shared-infra specific ────────────────────────────────────────────────────── +variable "robot_input_partition_count" { + description = "Partition count for the robot-input Event Hub." + type = number + default = 2 +} + +variable "robot_output_partition_count" { + description = "Partition count for the robot-output Event Hub." + type = number + default = 2 +} variable "sql_location" { - description = "Azure region for the SQL server (kept in southeastasia for cost/availability)." + description = "Azure region for the SQL server." type = string - default = "southeastasia" + default = "centralus" } variable "sql_ad_admin_login" { @@ -64,22 +82,42 @@ variable "tenant_id" { default = "37321907-14a5-4390-987d-ec0c66c655cd" } -# ── Shared App Service settings (admin-webapp + customer frontend) ───────────── - variable "app_service_plan_name" { - description = "Existing App Service Plan shared by admin-webapp and customer frontend." + description = "Name of the shared App Service Plan used by both web apps." type = string default = "ASP-RGDeliveryBotdev-8b82" } +variable "app_service_plan_sku_name" { + description = "SKU for the shared App Service Plan." + type = string + default = "B1" +} + +variable "app_service_plan_location" { + description = "Region for web apps attached to the shared App Service Plan." + type = string + default = "canadacentral" +} + +variable "create_app_service_plan" { + description = "Whether to create the shared App Service Plan in this stack." + type = bool + default = false +} + +variable "existing_app_service_plan_resource_group_name" { + description = "Optional resource group for an existing shared App Service Plan. Defaults to resource_group_name." + type = string + default = null +} + variable "node_version" { description = "Node runtime version used by pm2 in both web apps." type = string default = "22-lts" } -# ── Shared API URLs (admin-webapp + order-service) ───────────────────────────── - variable "botnet_api_url" { description = "Public HTTPS URL of the BotNet API Container App." type = string @@ -92,35 +130,237 @@ variable "simulator_api_url" { default = "https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io" } -# ── Admin Web App ────────────────────────────────────────────────────────────── - variable "admin_app_service_name" { description = "Name of the Admin Web App App Service." type = string default = "WA-DeliveryBot-Admin-dev" } -# ── Order Service ────────────────────────────────────────────────────────────── - variable "order_service_container_app_name" { description = "Name of the Order Service Container App." type = string default = "deliverybot-order-service" } +variable "agent_service_container_app_name" { + description = "Name of the Agent Service Container App." + type = string + default = "deliverybot-agent-dev" +} + variable "order_service_sql_connection_string" { - description = "SQL connection string for OrderServiceDb. Supplied via TF_VAR_order_service_sql_connection_string in CI — never committed." + description = "SQL connection string for OrderServiceDb. Supplied via TF_VAR_order_service_sql_connection_string in CI." type = string sensitive = true } variable "eventhub_connection_string" { - description = "Event Hub namespace connection string. Used by Order Service and Robot Simulator. Supplied via TF_VAR_eventhub_connection_string in CI — never committed." + description = "Event Hub namespace connection string used by the Order Service and Robot Simulator." + type = string + sensitive = true +} + +variable "azure_openai_endpoint" { + description = "Azure OpenAI resource endpoint used by the Agent Service." + type = string +} + +variable "azure_openai_deployment" { + description = "Azure OpenAI deployment name used by the Agent Service." + type = string +} + +variable "azure_openai_api_key" { + description = "Azure OpenAI API key used by the Agent Service." type = string sensitive = true } -# ── Bot API ──────────────────────────────────────────────────────────────────── +variable "azure_openai_api_version" { + description = "Azure OpenAI API version used by the Agent Service." + type = string + default = "2024-10-21" +} + +variable "agent_key_vault_name" { + description = "Optional explicit name for the Agent Service Key Vault." + type = string + default = null +} + +variable "agent_key_vault_openai_secret_name" { + description = "Secret name used in Key Vault for the Agent Service Azure OpenAI API key." + type = string + default = "azure-openai-api-key" +} + +variable "agent_transcript_storage_account_name" { + description = "Optional explicit storage account name for archived agent transcripts." + type = string + default = null +} + +variable "agent_transcript_container_name" { + description = "Blob container name used for archived agent transcripts." + type = string + default = "agent-transcripts" +} + +variable "support_escalation_container_name" { + description = "Blob container name used by the Function App to archive support escalations consumed from Service Bus." + type = string + default = "support-escalations" +} + +variable "agent_search_service_name" { + description = "Optional explicit Azure AI Search service name for agent grounding." + type = string + default = null +} + +variable "agent_search_index_name" { + description = "Azure AI Search index name seeded and queried by the Agent Service." + type = string + default = "delivery-agent-knowledge" +} + +variable "agent_search_sku" { + description = "SKU for Azure AI Search. basic keeps the final project inexpensive while supporting managed identity." + type = string + default = "basic" +} + +variable "support_servicebus_namespace_name" { + description = "Optional explicit Service Bus namespace name for support escalation workflow." + type = string + default = null +} + +variable "support_escalation_queue_name" { + description = "Service Bus queue that carries support escalation work items." + type = string + default = "support-escalations" +} + +variable "api_management_name" { + description = "Optional explicit Azure API Management service name for the final-project API facade." + type = string + default = null +} + +variable "api_management_publisher_name" { + description = "Publisher name required by Azure API Management." + type = string + default = "DeliveryBot" +} + +variable "api_management_publisher_email" { + description = "Publisher email required by Azure API Management." + type = string + default = "deliverybot@example.com" +} + +variable "readable_bot_network_name_prefix" { + description = "Short prefix used in generated resource names for the readable bot network resources." + type = string + default = "deliverybot" +} + +variable "readable_bot_network_environment" { + description = "Environment label used in generated resource names and tags for the readable bot network resources." + type = string + default = "dev" +} + +variable "readable_bot_network_eventhub_resource_group_name" { + description = "Optional resource group containing the robot Event Hub namespace. Defaults to resource_group_name." + type = string + default = null +} + +variable "readable_bot_network_robot_output_eventhub_name" { + description = "Robot output Event Hub consumed by the readable bot network projection." + type = string + default = "robot-output" +} + +variable "readable_bot_network_consumer_group_name" { + description = "Consumer group name used by the readable bot network Function App." + type = string + default = "readable-bot-network" +} + +variable "readable_bot_network_create_eventhub_consumer_group" { + description = "Whether Terraform should create the readable bot network Event Hub consumer group." + type = bool + default = true +} + +variable "readable_bot_network_assign_eventhub_receiver_role" { + description = "Whether Terraform should assign Azure Event Hubs Data Receiver to the readable bot network Function App identity." + type = bool + default = true +} + +variable "readable_bot_network_assign_cosmos_data_contributor_role" { + description = "Whether Terraform should assign Cosmos DB Built-in Data Contributor to the readable bot network Function App identity." + type = bool + default = true +} + +variable "readable_bot_network_cosmos_account_name" { + description = "Optional explicit Cosmos DB account name for the readable bot network." + type = string + default = null +} + +variable "readable_bot_network_cosmos_database_name" { + description = "Cosmos DB SQL database name for the readable bot network." + type = string + default = "bot-network" +} + +variable "readable_bot_network_cosmos_container_name" { + description = "Cosmos DB SQL container name for the current bot read model." + type = string + default = "bots" +} + +variable "readable_bot_network_diagnostics_container_name" { + description = "Cosmos DB SQL container name for projection diagnostics." + type = string + default = "function-diagnostics" +} + +variable "readable_bot_network_function_app_name" { + description = "Optional explicit Function App name for the readable bot network." + type = string + default = null +} + +variable "readable_bot_network_service_plan_name" { + description = "Optional explicit App Service plan name for the readable bot network Function App." + type = string + default = null +} + +variable "readable_bot_network_storage_account_name" { + description = "Optional explicit storage account name for the readable bot network Function App." + type = string + default = null +} + +variable "readable_bot_network_log_analytics_workspace_name" { + description = "Optional explicit Log Analytics workspace name for the readable bot network resources." + type = string + default = null +} + +variable "readable_bot_network_application_insights_name" { + description = "Optional explicit Application Insights name for the readable bot network resources." + type = string + default = null +} variable "bot_api_container_app_name" { description = "Name of the BotNet API Container App." @@ -135,22 +375,18 @@ variable "bot_api_sql_server_name" { } variable "bot_api_sql_connection_string" { - description = "SQL connection string for BotNetApiDb. Uses Managed Identity auth — no password in the string." + description = "SQL connection string for BotNetApiDb. Uses Managed Identity auth." type = string sensitive = true default = "Server=tcp:deliverybotsystem-sql.database.windows.net,1433;Initial Catalog=BotNetApiDb;Authentication=Active Directory Managed Identity;" } -# ── Customer Frontend ────────────────────────────────────────────────────────── - variable "customer_frontend_app_service_name" { description = "Name of the Customer Frontend App Service." type = string default = "WA-DeliveryBot-dev" } -# ── Robot Simulator ──────────────────────────────────────────────────────────── - variable "simulator_container_app_name" { description = "Name of the Robot Simulator Container App." type = string diff --git a/OrderService/OrderService.Tests/OrderServiceTests.cs b/OrderService/OrderService.Tests/OrderServiceTests.cs index 6204435..ae58820 100644 --- a/OrderService/OrderService.Tests/OrderServiceTests.cs +++ b/OrderService/OrderService.Tests/OrderServiceTests.cs @@ -52,10 +52,13 @@ private static HttpResponseMessage BotListJson(bool hasAvailableBot) => ? """[{"id":1,"name":"bot-001","isOnline":true,"isServicingCustomer":false}]""" : "[]"); - private static Dictionary Config(string botUrl = "http://fake-bot-api") => + private static Dictionary Config( + string botUrl = "http://fake-bot-api", + string simulatorUrl = "") => new() { ["BotNetApi:BaseUrl"] = botUrl, + ["RobotSimulator:BaseUrl"] = simulatorUrl, ["EventHub:ConnectionString"] = "", ["EventHub:Name"] = "robot-input" }; @@ -113,6 +116,106 @@ public async Task PlaceOrder_AssignedStatus_WhenBotIsAvailable() Assert.Equal("bot-001", result.AssignedBotId); } + [Fact] + public async Task PlaceOrder_PrefersAvailableSimulatorBot_WhenAnotherBotIsAlreadyDelivering() + { + const string simulatorBotsJson = + "[" + + "{\"botId\":\"bot-001\",\"status\":\"OnDelivery\",\"activeOrderId\":\"existing-order\",\"queuedOrderCount\":0}," + + "{\"botId\":\"bot-002\",\"status\":\"Available\",\"activeOrderId\":null,\"queuedOrderCount\":0}," + + "{\"botId\":\"bot-003\",\"status\":\"Available\",\"activeOrderId\":null,\"queuedOrderCount\":1}" + + "]"; + + var (svc, _) = CreateService( + req => req.RequestUri!.AbsoluteUri.Contains("/bots") + ? Json(simulatorBotsJson) + : Json("[]"), + Config(botUrl: "", simulatorUrl: "http://fake-simulator")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal("Assigned", result.Status); + Assert.Equal("bot-002", result.AssignedBotId); + } + + [Fact] + public async Task PlaceOrder_FallsBackToLeastLoadedSimulatorBot_WhenNoBotIsCurrentlyAvailable() + { + const string simulatorBotsJson = + "[" + + "{\"botId\":\"bot-001\",\"status\":\"OnDelivery\",\"activeOrderId\":\"existing-order\",\"queuedOrderCount\":2}," + + "{\"botId\":\"bot-002\",\"status\":\"OnDelivery\",\"activeOrderId\":\"other-order\",\"queuedOrderCount\":0}" + + "]"; + + var (svc, _) = CreateService( + req => req.RequestUri!.AbsoluteUri.Contains("/bots") + ? Json(simulatorBotsJson) + : Json("[]"), + Config(botUrl: "", simulatorUrl: "http://fake-simulator")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal("Assigned", result.Status); + Assert.Equal("bot-002", result.AssignedBotId); + } + + [Fact] + public async Task PlaceOrder_FallsBackToBotNetApi_WhenSimulatorIsUnavailable() + { + var (svc, _) = CreateService( + req => + { + if (req.RequestUri!.Host.Contains("fake-simulator")) + { + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + } + + return DispatchByUrl(req, "[]", botAvailable: true); + }, + Config(botUrl: "http://fake-bot-api", simulatorUrl: "http://fake-simulator")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal("Assigned", result.Status); + Assert.Equal("bot-001", result.AssignedBotId); + } + + [Fact] + public async Task PlaceOrder_PostsAssignmentToSimulator_WhenSimulatorIsConfigured() + { + var requests = new List(); + const string simulatorBotsJson = + "[" + + "{\"botId\":\"bot-002\",\"status\":\"Available\",\"activeOrderId\":null,\"queuedOrderCount\":0}" + + "]"; + + var (svc, _) = CreateService( + req => + { + requests.Add(req); + + if (req.RequestUri!.AbsoluteUri.EndsWith("/bots")) + { + return Json(simulatorBotsJson); + } + + if (req.RequestUri.AbsoluteUri.EndsWith("/orders/assignments")) + { + return Json("""{"result":"Accepted"}"""); + } + + return Json("[]"); + }, + Config(botUrl: "", simulatorUrl: "http://fake-simulator")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal("bot-002", result.AssignedBotId); + Assert.Contains(requests, req => + req.Method == HttpMethod.Post && + req.RequestUri!.AbsoluteUri == "http://fake-simulator/orders/assignments"); + } + [Fact] public async Task PlaceOrder_PendingStatus_WhenNoBotsAvailable() { diff --git a/OrderService/OrderService/Services/OrderService.cs b/OrderService/OrderService/Services/OrderService.cs index 96ae843..6d71a52 100644 --- a/OrderService/OrderService/Services/OrderService.cs +++ b/OrderService/OrderService/Services/OrderService.cs @@ -35,7 +35,7 @@ public async Task PlaceOrderAsync(PlaceOrderDto dto) // 1. Geocode the delivery address to GPS coordinates var (latitude, longitude) = await GeocodeAddressAsync(dto.DeliveryAddress); - // 2. Pick an available bot from BotNetApi + // 2. Pick the best bot from the live simulator when available, then fall back to BotNetApi var botId = await SelectBotAsync(); // 3. Map the form's order type to item IDs the simulator understands @@ -58,10 +58,10 @@ public async Task PlaceOrderAsync(PlaceOrderDto dto) }).ToList() }; - _db.Orders.Add(order); - await _db.SaveChangesAsync(); + var orderPersisted = await TryPersistOrderAsync(order); - // 5. Publish RobotOrderAssignment event to Event Hub + // 5. Hand the assignment to the simulator directly when available, + // otherwise fall back to Event Hub. if (botId is not null) await PublishOrderAssignmentAsync(order, botId); @@ -69,6 +69,13 @@ public async Task PlaceOrderAsync(PlaceOrderDto dto) "Order placed. OrderId={OrderId} CustomerId={CustomerId} BotId={BotId} Address={Address}", order.Id, order.CustomerId, order.AssignedBotId, order.DeliveryAddress); + if (!orderPersisted) + { + _logger.LogWarning( + "Order was processed without database persistence because the development database is unavailable. OrderId={OrderId}", + order.Id); + } + return ToResponseDto(order); } @@ -141,8 +148,10 @@ public async Task ApplyStatusEventAsync(RobotEventEnvelope evt, CancellationToke { var client = _httpClientFactory.CreateClient("Nominatim"); var encoded = Uri.EscapeDataString(address); - var response = await client.GetAsync( - $"https://nominatim.openstreetmap.org/search?q={encoded}&format=json&limit=1"); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var response = await client.GetAsync( + $"https://nominatim.openstreetmap.org/search?q={encoded}&format=json&limit=1", + timeoutCts.Token); response.EnsureSuccessStatusCode(); @@ -180,8 +189,72 @@ public async Task ApplyStatusEventAsync(RobotEventEnvelope evt, CancellationToke _ => [("water", 1)] // "water" and any unrecognized value → water (always stocked) }; - // Calls BotNetApi and returns the Name of the first available bot + // Prefer the live simulator fleet so assignment reflects real active/queued work. + // Fall back to BotNetApi when simulator access is unavailable. private async Task SelectBotAsync() + { + var simulatorBotId = await SelectBotFromSimulatorAsync(); + if (!string.IsNullOrWhiteSpace(simulatorBotId)) + { + return simulatorBotId; + } + + return await SelectBotFromBotNetApiAsync(); + } + + private async Task SelectBotFromSimulatorAsync() + { + var simulatorUrl = _config["RobotSimulator:BaseUrl"]; + if (string.IsNullOrWhiteSpace(simulatorUrl)) + { + return null; + } + + try + { + var client = _httpClientFactory.CreateClient(); + using var response = await client.GetAsync($"{simulatorUrl.TrimEnd('/')}/bots"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var bots = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return bots? + .Where(b => !string.IsNullOrWhiteSpace(b.BotId)) + .Where(b => !string.Equals(b.Status, "Charging", StringComparison.OrdinalIgnoreCase)) + .OrderBy(b => GetBotLoadRank(b.Status)) + .ThenBy(b => b.QueuedOrderCount) + .ThenBy(b => b.ActiveOrderId is null ? 0 : 1) + .ThenBy(b => b.BotId, StringComparer.OrdinalIgnoreCase) + .Select(b => b.BotId) + .FirstOrDefault(); + } + catch (HttpRequestException ex) + { + return LogSimulatorSelectionFailure(ex); + } + catch (TaskCanceledException ex) + { + return LogSimulatorSelectionFailure(ex); + } + catch (JsonException ex) + { + return LogSimulatorSelectionFailure(ex); + } + catch (NotSupportedException ex) + { + return LogSimulatorSelectionFailure(ex); + } + catch (InvalidOperationException ex) + { + return LogSimulatorSelectionFailure(ex); + } + } + + private async Task SelectBotFromBotNetApiAsync() { var botApiUrl = _config["BotNetApi:BaseUrl"]; if (string.IsNullOrWhiteSpace(botApiUrl)) @@ -193,7 +266,8 @@ public async Task ApplyStatusEventAsync(RobotEventEnvelope evt, CancellationToke try { var client = _httpClientFactory.CreateClient(); - var response = await client.GetAsync($"{botApiUrl}/api/bots"); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var response = await client.GetAsync($"{botApiUrl}/api/bots", timeoutCts.Token); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); @@ -214,9 +288,23 @@ public async Task ApplyStatusEventAsync(RobotEventEnvelope evt, CancellationToke } } - // Publishes a RobotOrderAssignment event to Azure Event Hub + private static int GetBotLoadRank(string? status) => + status?.Trim() switch + { + "Available" => 0, + "OnDelivery" => 1, + _ => 2 + }; + + // Sends the assignment directly to the simulator in local/simple setups. + // Falls back to Azure Event Hub for shared/cloud-hosted setups. private async Task PublishOrderAssignmentAsync(Order order, string botId) { + if (await PublishDirectSimulatorAssignmentAsync(order, botId)) + { + return; + } + var connectionString = _config["EventHub:ConnectionString"]; var eventHubName = _config["EventHub:Name"]; @@ -270,6 +358,126 @@ private async Task PublishOrderAssignmentAsync(Order order, string botId) } } + private async Task PublishDirectSimulatorAssignmentAsync(Order order, string botId) + { + var simulatorUrl = _config["RobotSimulator:BaseUrl"]; + if (string.IsNullOrWhiteSpace(simulatorUrl)) + { + return false; + } + + try + { + var payload = new + { + orderId = order.Id.ToString(), + botId, + items = order.Items.Select(i => new + { + itemId = i.ItemId, + quantity = i.Quantity + }).ToList(), + destination = new + { + latitude = order.DestinationLatitude, + longitude = order.DestinationLongitude + } + }; + + var client = _httpClientFactory.CreateClient(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); + using var response = await client.PostAsync( + $"{simulatorUrl.TrimEnd('/')}/orders/assignments", + content, + timeoutCts.Token); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "RobotSimulator direct assignment returned HTTP {StatusCode}. Falling back to Event Hub. OrderId={OrderId} BotId={BotId}", + (int)response.StatusCode, + order.Id, + botId); + return false; + } + + _logger.LogInformation( + "Direct simulator assignment succeeded. OrderId={OrderId} BotId={BotId}", + order.Id, + botId); + + return true; + } + catch (HttpRequestException ex) + { + return LogDirectSimulatorAssignmentFailure(ex, order, botId); + } + catch (TaskCanceledException ex) + { + return LogDirectSimulatorAssignmentFailure(ex, order, botId); + } + catch (JsonException ex) + { + return LogDirectSimulatorAssignmentFailure(ex, order, botId); + } + catch (NotSupportedException ex) + { + return LogDirectSimulatorAssignmentFailure(ex, order, botId); + } + catch (UriFormatException ex) + { + return LogDirectSimulatorAssignmentFailure(ex, order, botId); + } + catch (InvalidOperationException ex) + { + return LogDirectSimulatorAssignmentFailure(ex, order, botId); + } + } + + private string? LogSimulatorSelectionFailure(Exception ex) + { + _logger.LogWarning(ex, "Failed to contact RobotSimulator for bot selection. Falling back to BotNetApi."); + return null; + } + + private bool LogDirectSimulatorAssignmentFailure(Exception ex, Order order, string botId) + { + _logger.LogWarning( + ex, + "Failed direct simulator assignment. Falling back to Event Hub. OrderId={OrderId} BotId={BotId}", + order.Id, + botId); + return false; + } + + private async Task TryPersistOrderAsync(Order order) + { + _db.Orders.Add(order); + + try + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await _db.SaveChangesAsync(timeoutCts.Token); + return true; + } + catch (Exception ex) when (IsDevelopmentEnvironment()) + { + _db.Entry(order).State = EntityState.Detached; + _logger.LogWarning( + ex, + "Skipping order persistence in development because the configured database is unavailable. OrderId={OrderId}", + order.Id); + return false; + } + } + + private bool IsDevelopmentEnvironment() => + string.Equals(_config["ASPNETCORE_ENVIRONMENT"], "Development", StringComparison.OrdinalIgnoreCase); + private static OrderResponseDto ToResponseDto(Order order) => new() { Id = order.Id, @@ -299,6 +507,14 @@ private sealed class BotDto public bool IsServicingCustomer { get; set; } } + private sealed class SimulatorBotDto + { + public string BotId { get; set; } = string.Empty; + public string? Status { get; set; } + public string? ActiveOrderId { get; set; } + public int QueuedOrderCount { get; set; } + } + // Nominatim geocoding response shape private sealed class NominatimResult { diff --git a/ReadBotsFunction/Functions/ProcessSupportEscalations.cs b/ReadBotsFunction/Functions/ProcessSupportEscalations.cs new file mode 100644 index 0000000..997e036 --- /dev/null +++ b/ReadBotsFunction/Functions/ProcessSupportEscalations.cs @@ -0,0 +1,99 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Azure.Core; +using Azure.Identity; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace ReadBotsFunction.Functions; + +public sealed class ProcessSupportEscalations +{ + private static readonly TokenRequestContext StorageTokenRequest = + new(["https://storage.azure.com/.default"]); + + private readonly ILogger _logger; + private readonly DefaultAzureCredential _credential = new(); + private readonly HttpClient _httpClient = new(); + + public ProcessSupportEscalations(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + [Function(nameof(ProcessSupportEscalations))] + public async Task RunAsync( + [ServiceBusTrigger("%SupportEscalationQueueName%", Connection = "SupportEscalationServiceBus")] + string message, + FunctionContext context, + CancellationToken cancellationToken) + { + var blobServiceUri = Environment.GetEnvironmentVariable("EscalationArchive__BlobServiceUri"); + var containerName = Environment.GetEnvironmentVariable("EscalationArchive__ContainerName") ?? "support-escalations"; + + if (string.IsNullOrWhiteSpace(blobServiceUri)) + { + _logger.LogWarning("Support escalation archive is not configured."); + return; + } + + await EnsureContainerExistsAsync(blobServiceUri, containerName, cancellationToken); + await WriteEscalationAsync(blobServiceUri, containerName, message, cancellationToken); + } + + private async Task EnsureContainerExistsAsync( + string blobServiceUri, + string containerName, + CancellationToken cancellationToken) + { + var containerUri = new Uri($"{blobServiceUri.TrimEnd('/')}/{containerName}?restype=container"); + using var request = await CreateAuthorizedRequestAsync(HttpMethod.Put, containerUri, cancellationToken); + request.Content = new ByteArrayContent([]); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode != HttpStatusCode.Created && + response.StatusCode != HttpStatusCode.Conflict) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException( + $"Blob Storage returned HTTP {(int)response.StatusCode} while creating the support escalation container: {body}"); + } + } + + private async Task WriteEscalationAsync( + string blobServiceUri, + string containerName, + string message, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var blobName = FormattableString.Invariant($"{now:yyyy/MM/dd}/{now:HHmmssfff}-{Guid.NewGuid():N}.json"); + var blobUri = new Uri($"{blobServiceUri.TrimEnd('/')}/{containerName}/{blobName}"); + using var request = await CreateAuthorizedRequestAsync(HttpMethod.Put, blobUri, cancellationToken); + request.Headers.Add("x-ms-blob-type", "BlockBlob"); + request.Content = new StringContent(message, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode != HttpStatusCode.Created) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException( + $"Blob Storage returned HTTP {(int)response.StatusCode} while writing a support escalation: {body}"); + } + } + + private async Task CreateAuthorizedRequestAsync( + HttpMethod method, + Uri uri, + CancellationToken cancellationToken) + { + var accessToken = await _credential.GetTokenAsync(StorageTokenRequest, cancellationToken); + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); + request.Headers.Add("x-ms-date", DateTimeOffset.UtcNow.ToString("R", CultureInfo.InvariantCulture)); + request.Headers.Add("x-ms-version", "2023-11-03"); + return request; + } +} diff --git a/ReadBotsFunction/ReadBotsFunction.csproj b/ReadBotsFunction/ReadBotsFunction.csproj index e4d2177..a7bb3cb 100644 --- a/ReadBotsFunction/ReadBotsFunction.csproj +++ b/ReadBotsFunction/ReadBotsFunction.csproj @@ -28,6 +28,7 @@ + diff --git a/ReadBotsFunction/local.settings.sample.json b/ReadBotsFunction/local.settings.sample.json index d5801a7..a589dfb 100644 --- a/ReadBotsFunction/local.settings.sample.json +++ b/ReadBotsFunction/local.settings.sample.json @@ -7,6 +7,11 @@ "RobotOutputEventHubConsumerGroup": "readable-bot-network-dev", "RobotOutputEventHubIdentity__fullyQualifiedNamespace": "DeliverybotSimulator-EVHNS.servicebus.windows.net", "RobotOutputEventHubIdentity__credential": "managedidentity", + "SupportEscalationQueueName": "support-escalations", + "SupportEscalationServiceBus__fullyQualifiedNamespace": "deliverybot-support-dev.servicebus.windows.net", + "SupportEscalationServiceBus__credential": "managedidentity", + "EscalationArchive__BlobServiceUri": "https://deliverybotagtdevsa.blob.core.windows.net/", + "EscalationArchive__ContainerName": "support-escalations", "ReadableBotNetwork__CosmosAccountEndpoint": "https://deliverybot-rbnr-dev-rbnr-mtgpw6.documents.azure.com:443/", "ReadableBotNetwork__CosmosDatabaseName": "bot-network", "ReadableBotNetwork__BotsContainerName": "bots", diff --git a/admin-webapp/src/auth/authConfig.js b/admin-webapp/src/auth/authConfig.js index e107582..901c532 100644 --- a/admin-webapp/src/auth/authConfig.js +++ b/admin-webapp/src/auth/authConfig.js @@ -31,8 +31,9 @@ export const msalConfig = { }, } -// openid + profile yield the ID token (name) and group claims. Add API scopes -// here once the backends validate bearer tokens. +// Keep the initial sign-in request to pure OIDC scopes. Requesting Graph scopes +// like User.Read here can force extra consent/admin policy checks even though +// the app only needs an ID token and group claims to gate staff access. export const loginRequest = { - scopes: ['openid', 'profile', 'User.Read'], + scopes: ['openid', 'profile'], } diff --git a/admin-webapp/src/auth/authConfig.test.js b/admin-webapp/src/auth/authConfig.test.js index df38d32..62d5545 100644 --- a/admin-webapp/src/auth/authConfig.test.js +++ b/admin-webapp/src/auth/authConfig.test.js @@ -23,4 +23,12 @@ describe('authConfig (issue #54)', () => { const mod = await import('./authConfig.js?nocache=' + Date.now()) expect(mod.authEnabled).toBe(true) }) + + it('requests only baseline OIDC scopes for sign-in', async () => { + vi.resetModules() + vi.stubEnv('VITE_ENTRA_CLIENT_ID', 'test-client-id') + vi.stubEnv('VITE_ENTRA_TENANT_ID', 'test-tenant-id') + const mod = await import('./authConfig.js?nocache=' + Date.now()) + expect(mod.loginRequest.scopes).toEqual(['openid', 'profile']) + }) }) diff --git a/docs/final-project-deployment.md b/docs/final-project-deployment.md new file mode 100644 index 0000000..9377b08 --- /dev/null +++ b/docs/final-project-deployment.md @@ -0,0 +1,125 @@ +# Final Project Deployment Plan + +This final project extends the Delivery Bot System in a student-owned Azure environment. The goal is to demonstrate a connected Azure solution built on the class project without relying on the shared class resource group. + +## Final Feature Story + +The final focuses on an AI-assisted customer delivery flow plus an event-driven readable bot network: + +1. The customer places an order in the customer web app. +2. The order service processes the order and reacts to simulator events. +3. The robot simulator publishes robot-output events to Event Hubs. +4. The readable bot network Function App consumes robot-output events. +5. The Function App projects the current bot state into Cosmos DB. +6. The customer-facing assistant uses Azure OpenAI to answer questions about the latest order, route, ETA, and assigned robot. + +## Azure Services Covered + +This plan touches at least five Azure services: + +1. App Service + Hosts the customer web app and admin web app. +2. Container Apps + Hosts the order service, bot API, simulator, and agent service. +3. Azure OpenAI + Powers the delivery assistant. +4. Event Hubs + Carries robot-output and assignment-related events. +5. Azure Functions + Projects robot events into a read model. +6. Cosmos DB + Stores the readable bot network state. +7. Application Insights + Captures Function App telemetry. + +## Infrastructure Ownership + +Terraform root: `Iac/` + +Important notes: + +- The Terraform backend is now intentionally partial. +- Supply your own Azure Storage backend values during `terraform init`. +- The old shared-environment import file is now only an example: `Iac/imports-shared-dev-reference.tf.example`. +- The readable bot network module is now wired into the root Terraform stack. + +## GitHub Repository Variables + +Set these repository variables before running the deployment workflows: + +- `RESOURCE_GROUP_NAME` +- `ACR_NAME` +- `ACR_LOGIN_SERVER` +- `CUSTOMER_FRONTEND_APP_SERVICE_NAME` +- `ADMIN_APP_SERVICE_NAME` +- `BOT_API_CONTAINER_APP_NAME` +- `BOT_API_SQL_SERVER_NAME` +- `BOT_API_SQL_DATABASE_NAME` +- `ORDER_SERVICE_CONTAINER_APP_NAME` +- `AGENT_SERVICE_CONTAINER_APP_NAME` +- `SIMULATOR_CONTAINER_APP_NAME` +- `VITE_AGENT_API_URL` +- `VITE_MAP_TILE_URL` +- `VITE_ORDER_SERVICE_URL` +- `VITE_OSRM_API_URL` +- `VITE_SIMULATOR_API_BASE` +- `VITE_BOTNET_API_URL` +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_DEPLOYMENT` +- `TFSTATE_RESOURCE_GROUP` +- `TFSTATE_STORAGE_ACCOUNT` +- `TFSTATE_CONTAINER` +- `TFSTATE_KEY` +- `READABLE_BOT_NETWORK_FUNCTION_APP_NAME` +- `READABLE_BOT_NETWORK_COSMOS_ACCOUNT_NAME` +- `READABLE_BOT_NETWORK_COSMOS_DATABASE_NAME` +- `READABLE_BOT_NETWORK_COSMOS_CONTAINER_NAME` +- `READABLE_BOT_NETWORK_DIAGNOSTICS_CONTAINER_NAME` + +Optional admin auth variables: + +- `ENTRA_CLIENT_ID` +- `ENTRA_TENANT_ID` +- `ENTRA_ADMIN_GROUP_ID` + +## GitHub Repository Secrets + +Set these repository secrets before deployment: + +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` +- `AZURE_EVENTHUB_CONNECTION_STRING` +- `AZURE_OPENAI_API_KEY` + +## Terraform Inputs + +Use `Iac/final-project.tfvars.example` as the starting point for your own environment values. + +At a minimum, your Terraform deployment needs: + +- resource group name +- container app environment name +- ACR name +- Event Hub namespace name +- Azure OpenAI endpoint +- Azure OpenAI deployment name +- SQL/Event Hub connection string secrets injected through GitHub Actions + +## Presentation Flow + +Recommended demo sequence: + +1. Show the architecture diagram. +2. Show the Terraform root and explain that one deployment composes the services. +3. Show the GitHub Actions workflows using OIDC. +4. Show the deployed resources in your own Azure resource group. +5. Place an order from the customer frontend. +6. Show the assistant answering order questions. +7. Show the readable bot network Function App and Cosmos DB projection. + +## Honest Project Framing + +Use this explanation if needed: + +> This final is built on the class project codebase, but deployed in my own Azure environment because I did not have dependable access to the shared class resource group. I kept the deployment model consistent with the project by using Terraform, GitHub Actions, and Azure identity-based authentication. diff --git a/federated-credential.json b/federated-credential.json new file mode 100644 index 0000000..1685509 --- /dev/null +++ b/federated-credential.json @@ -0,0 +1,9 @@ +{ + "name": "deliverybot-final-main", + "issuer": "https://token.actions.githubusercontent.com", + "subject": "repo:wiilke/DeliveryBotSystem-FinalProject:ref:refs/heads/main", + "description": "GitHub Actions OIDC for DeliveryBotSystem final project main branch", + "audiences": [ + "api://AzureADTokenExchange" + ] +} \ No newline at end of file diff --git a/frontend/customer-webapp/.env.final-project.example b/frontend/customer-webapp/.env.final-project.example new file mode 100644 index 0000000..bfa6312 --- /dev/null +++ b/frontend/customer-webapp/.env.final-project.example @@ -0,0 +1,6 @@ +VITE_SIMULATOR_API_BASE=/api/simulator +VITE_API_MANAGEMENT_BASE_URL=https://deliverybotagt-dev-apim.azure-api.net +VITE_ORDER_SERVICE_URL=/api/orders +VITE_OSRM_API_URL=https://router.project-osrm.org +VITE_AGENT_API_URL=/api/agent +VITE_MAP_TILE_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png diff --git a/frontend/customer-webapp/package.json b/frontend/customer-webapp/package.json index 940e9be..eea14d0 100644 --- a/frontend/customer-webapp/package.json +++ b/frontend/customer-webapp/package.json @@ -4,10 +4,13 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "powershell -ExecutionPolicy Bypass -File ./scripts/dev.ps1", + "dev:web": "vite", + "dev:agent": "dotnet run --project ../../AgentService/AgentService/AgentService.csproj", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test" }, "dependencies": { "leaflet": "^1.9.4", diff --git a/frontend/customer-webapp/scripts/dev.ps1 b/frontend/customer-webapp/scripts/dev.ps1 new file mode 100644 index 0000000..761d1ac --- /dev/null +++ b/frontend/customer-webapp/scripts/dev.ps1 @@ -0,0 +1,16 @@ +$agentPort = 7071 +$agentProject = Resolve-Path (Join-Path $PSScriptRoot "..\..\..\AgentService\AgentService\AgentService.csproj") +$agentWorkingDir = Split-Path $agentProject -Parent + +$agentRunning = Get-NetTCPConnection -LocalPort $agentPort -State Listen -ErrorAction SilentlyContinue + +if (-not $agentRunning) { + Start-Process powershell -ArgumentList @( + "-NoProfile", + "-Command", + "Set-Location '$agentWorkingDir'; dotnet run" + ) -WindowStyle Hidden + Start-Sleep -Seconds 3 +} + +& npm run dev:web diff --git a/frontend/customer-webapp/src/App.css b/frontend/customer-webapp/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/frontend/customer-webapp/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/frontend/customer-webapp/src/App.jsx b/frontend/customer-webapp/src/App.jsx index 544a7b5..eeff644 100644 --- a/frontend/customer-webapp/src/App.jsx +++ b/frontend/customer-webapp/src/App.jsx @@ -1,14 +1,26 @@ +import { useEffect, useState } from "react" import { BrowserRouter, - Routes, + Link, Route, - Link + Routes } from "react-router-dom" - -import Home from "./pages/Home" -import CreateOrder from "./pages/CreateOrder" +import AgentAssistant from "./components/AgentAssistant.jsx" +import { readLatestOrder, subscribeToLatestOrder, writeLatestOrder } from "./lib/orderSession.js" +import Home from "./pages/Home.jsx" +import CreateOrder from "./pages/CreateOrder.jsx" function App() { + const [latestOrder, setLatestOrder] = useState(() => readLatestOrder()) + const [latestRoute, setLatestRoute] = useState(null) + + useEffect(() => subscribeToLatestOrder(setLatestOrder), []) + + function handleOrderCreated(order) { + writeLatestOrder(order) + setLatestOrder(order) + } + return (
@@ -27,13 +39,17 @@ function App() { - } /> - + } + /> } + element={} /> + +
) @@ -44,25 +60,24 @@ const styles = { backgroundColor: "#111827", minHeight: "100vh" }, - nav: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1.5rem 2rem", backgroundColor: "#0f172a", - color: "white" + color: "white", + gap: "1rem", + flexWrap: "wrap" }, - links: { display: "flex", gap: "1rem" }, - link: { color: "white", textDecoration: "none" } } -export default App \ No newline at end of file +export default App diff --git a/frontend/customer-webapp/src/assets/hero.png b/frontend/customer-webapp/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/frontend/customer-webapp/src/assets/hero.png and /dev/null differ diff --git a/frontend/customer-webapp/src/assets/react.svg b/frontend/customer-webapp/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/customer-webapp/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/customer-webapp/src/assets/vite.svg b/frontend/customer-webapp/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/customer-webapp/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/frontend/customer-webapp/src/components/AgentAssistant.jsx b/frontend/customer-webapp/src/components/AgentAssistant.jsx new file mode 100644 index 0000000..e126d5e --- /dev/null +++ b/frontend/customer-webapp/src/components/AgentAssistant.jsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { + buildAgentContext, + getAssistantPromptSuggestions, + sendAgentMessage +} from "../lib/agent.js" +import { assistantStyles } from "../lib/assistantStyles.js" +import { formatOrderStatus } from "../lib/orders.js" + +const starterMessages = [ + { + id: "welcome", + role: "assistant", + text: "Ask about your route, ETA, latest order, or assigned robot.", + source: "local" + } +] + +export default function AgentAssistant({ latestOrder, route }) { + const [isOpen, setIsOpen] = useState(false) + const [messages, setMessages] = useState(starterMessages) + const [draft, setDraft] = useState("") + const [isSending, setIsSending] = useState(false) + const [error, setError] = useState("") + const [lastWarning, setLastWarning] = useState("") + const [connectionInfo, setConnectionInfo] = useState({ + source: "local", + model: null + }) + const context = useMemo( + () => buildAgentContext(latestOrder, route), + [latestOrder, route] + ) + const promptSuggestions = useMemo( + () => getAssistantPromptSuggestions(context), + [context] + ) + const nextMessageIdRef = useRef(1) + const transcriptRef = useRef(null) + + useEffect(() => { + if (!transcriptRef.current) { + return + } + + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight + }, [messages, isSending]) + + function createMessageId(prefix) { + const nextValue = nextMessageIdRef.current + nextMessageIdRef.current += 1 + return `${prefix}-${nextValue}` + } + + async function sendMessageText(message) { + const trimmed = message.trim() + if (!trimmed || isSending) { + return + } + + const userMessage = { + id: createMessageId("user"), + role: "user", + text: trimmed + } + const conversationHistory = [...messages, userMessage] + + setMessages(conversationHistory) + setDraft("") + setIsSending(true) + setError("") + setLastWarning("") + + try { + const response = await sendAgentMessage(trimmed, context, { + messages: conversationHistory + }) + setConnectionInfo({ + source: response.source, + model: response.model || null + }) + + setMessages((current) => [ + ...current, + { + id: createMessageId("assistant"), + role: "assistant", + text: response.reply, + source: response.source, + model: response.model || null + } + ]) + setLastWarning(response.warning || "") + } catch (sendError) { + setError(sendError.message) + } finally { + setIsSending(false) + } + } + + async function handleSend(event) { + event.preventDefault() + await sendMessageText(draft) + } + + function resetConversation() { + setMessages(starterMessages) + setDraft("") + setError("") + setLastWarning("") + setConnectionInfo({ + source: "local", + model: null + }) + } + + return ( + <> + + + {isOpen && ( +