From 9dfbc1253eae3e18c00dd66e6bbdf31d384b5072 Mon Sep 17 00:00:00 2001 From: wiilke Date: Fri, 12 Jun 2026 15:06:53 -0700 Subject: [PATCH 1/8] Publish current final project snapshot --- .github/workflows/AdminWebpage-Deploy-WF.yml | 16 +- .../workflows/CustomerWebpage-Deploy-WF.yml | 5 +- .github/workflows/agentservice-deploy.yml | 70 +++ .github/workflows/botNetApi-deploy.yml | 12 +- .github/workflows/iac.yml | 18 +- .github/workflows/orderservice-deploy.yml | 8 +- .../workflows/readable-bot-network-update.yml | 12 +- .github/workflows/simulator-deploy.yml | 8 +- .gitignore | 5 + .../AgentService.Tests.csproj | 14 + AgentService/AgentService.Tests/Program.cs | 288 ++++++++++++ AgentService/AgentService/.gitignore | 2 + AgentService/AgentService/AgentService.csproj | 9 + .../Controllers/AgentController.cs | 47 ++ .../AgentService/DTOs/AgentChatContextDto.cs | 7 + .../AgentService/DTOs/AgentChatMessageDto.cs | 7 + .../AgentService/DTOs/AgentChatRequestDto.cs | 8 + .../AgentService/DTOs/AgentChatResponseDto.cs | 8 + .../AgentService/DTOs/AgentLatestOrderDto.cs | 10 + .../AgentService/DTOs/AgentRouteDto.cs | 8 + AgentService/AgentService/Dockerfile | 21 + .../Options/AzureOpenAiOptions.cs | 17 + AgentService/AgentService/Program.cs | 31 ++ .../Properties/launchSettings.json | 14 + .../Services/AzureOpenAiAgentService.cs | 70 +++ .../Services/AzureOpenAiChatMapper.cs | 129 ++++++ .../AgentService/Services/IAgentService.cs | 8 + AgentService/AgentService/appsettings.json | 16 + Iac/.terraform.lock.hcl | 43 ++ Iac/Iac.md | 38 +- Iac/admin-webapp/main.tf | 20 +- Iac/admin-webapp/modules/webapp/main.tf | 20 +- Iac/admin-webapp/modules/webapp/variables.tf | 15 +- Iac/admin-webapp/variables.tf | 31 +- Iac/agent-service/README.md | 23 + Iac/agent-service/main.tf | 46 ++ .../modules/container-app/main.tf | 74 +++ .../modules/container-app/outputs.tf | 14 + .../modules/container-app/variables.tf | 94 ++++ Iac/agent-service/outputs.tf | 14 + Iac/agent-service/variables.tf | 61 +++ Iac/backend.tf | 22 +- Iac/bot-api/variables.tf | 9 +- Iac/final-project.tfvars.example | 31 ++ Iac/frontend/main.tf | 16 +- Iac/frontend/modules/webapp/main.tf | 17 +- Iac/frontend/modules/webapp/variables.tf | 11 +- Iac/frontend/variables.tf | 17 +- Iac/imports-final-project.tf | 19 + ...> imports-shared-dev-reference.tf.example} | 11 +- Iac/main.tf | 142 +++--- Iac/order-service/variables.tf | 9 +- Iac/outputs.tf | 37 ++ Iac/shared-infra/main.tf | 66 ++- Iac/shared-infra/outputs.tf | 31 +- Iac/shared-infra/variables.tf | 104 ++++- Iac/variables.tf | 248 ++++++++-- docs/final-project-deployment.md | 125 +++++ federated-credential.json | 9 + .../.env.final-project.example | 5 + frontend/customer-webapp/package.json | 7 +- frontend/customer-webapp/scripts/dev.ps1 | 16 + frontend/customer-webapp/src/App.css | 184 -------- frontend/customer-webapp/src/App.jsx | 41 +- frontend/customer-webapp/src/assets/hero.png | Bin 13057 -> 0 bytes frontend/customer-webapp/src/assets/react.svg | 1 - frontend/customer-webapp/src/assets/vite.svg | 1 - .../src/components/AgentAssistant.jsx | 264 +++++++++++ .../src/components/AgentAssistant.test.js | 64 +++ frontend/customer-webapp/src/lib/agent.js | 252 ++++++++++ .../customer-webapp/src/lib/agent.test.js | 363 +++++++++++++++ .../src/lib/assistantStyles.js | 198 ++++++++ frontend/customer-webapp/src/lib/config.js | 18 + .../customer-webapp/src/lib/orderSession.js | 51 +++ .../src/lib/orderSession.test.js | 133 ++++++ frontend/customer-webapp/src/lib/orders.js | 211 +++++++++ .../customer-webapp/src/lib/orders.test.js | 251 ++++++++++ frontend/customer-webapp/src/lib/osrm.js | 132 ++++++ frontend/customer-webapp/src/lib/osrm.test.js | 179 ++++++++ .../customer-webapp/src/pages/CreateOrder.jsx | 357 ++++++++++++--- frontend/customer-webapp/src/pages/Home.jsx | 431 ++++++++++++------ .../customer-webapp/src/ui.contracts.test.js | 40 ++ frontend/customer-webapp/vite.config.js | 9 + 83 files changed, 4792 insertions(+), 701 deletions(-) create mode 100644 .github/workflows/agentservice-deploy.yml create mode 100644 AgentService/AgentService.Tests/AgentService.Tests.csproj create mode 100644 AgentService/AgentService.Tests/Program.cs create mode 100644 AgentService/AgentService/.gitignore create mode 100644 AgentService/AgentService/AgentService.csproj create mode 100644 AgentService/AgentService/Controllers/AgentController.cs create mode 100644 AgentService/AgentService/DTOs/AgentChatContextDto.cs create mode 100644 AgentService/AgentService/DTOs/AgentChatMessageDto.cs create mode 100644 AgentService/AgentService/DTOs/AgentChatRequestDto.cs create mode 100644 AgentService/AgentService/DTOs/AgentChatResponseDto.cs create mode 100644 AgentService/AgentService/DTOs/AgentLatestOrderDto.cs create mode 100644 AgentService/AgentService/DTOs/AgentRouteDto.cs create mode 100644 AgentService/AgentService/Dockerfile create mode 100644 AgentService/AgentService/Options/AzureOpenAiOptions.cs create mode 100644 AgentService/AgentService/Program.cs create mode 100644 AgentService/AgentService/Properties/launchSettings.json create mode 100644 AgentService/AgentService/Services/AzureOpenAiAgentService.cs create mode 100644 AgentService/AgentService/Services/AzureOpenAiChatMapper.cs create mode 100644 AgentService/AgentService/Services/IAgentService.cs create mode 100644 AgentService/AgentService/appsettings.json create mode 100644 Iac/.terraform.lock.hcl create mode 100644 Iac/agent-service/README.md create mode 100644 Iac/agent-service/main.tf create mode 100644 Iac/agent-service/modules/container-app/main.tf create mode 100644 Iac/agent-service/modules/container-app/outputs.tf create mode 100644 Iac/agent-service/modules/container-app/variables.tf create mode 100644 Iac/agent-service/outputs.tf create mode 100644 Iac/agent-service/variables.tf create mode 100644 Iac/final-project.tfvars.example create mode 100644 Iac/imports-final-project.tf rename Iac/{imports.tf => imports-shared-dev-reference.tf.example} (92%) create mode 100644 docs/final-project-deployment.md create mode 100644 federated-credential.json create mode 100644 frontend/customer-webapp/.env.final-project.example create mode 100644 frontend/customer-webapp/scripts/dev.ps1 delete mode 100644 frontend/customer-webapp/src/App.css delete mode 100644 frontend/customer-webapp/src/assets/hero.png delete mode 100644 frontend/customer-webapp/src/assets/react.svg delete mode 100644 frontend/customer-webapp/src/assets/vite.svg create mode 100644 frontend/customer-webapp/src/components/AgentAssistant.jsx create mode 100644 frontend/customer-webapp/src/components/AgentAssistant.test.js create mode 100644 frontend/customer-webapp/src/lib/agent.js create mode 100644 frontend/customer-webapp/src/lib/agent.test.js create mode 100644 frontend/customer-webapp/src/lib/assistantStyles.js create mode 100644 frontend/customer-webapp/src/lib/config.js create mode 100644 frontend/customer-webapp/src/lib/orderSession.js create mode 100644 frontend/customer-webapp/src/lib/orderSession.test.js create mode 100644 frontend/customer-webapp/src/lib/orders.js create mode 100644 frontend/customer-webapp/src/lib/orders.test.js create mode 100644 frontend/customer-webapp/src/lib/osrm.js create mode 100644 frontend/customer-webapp/src/lib/osrm.test.js create mode 100644 frontend/customer-webapp/src/ui.contracts.test.js diff --git a/.github/workflows/AdminWebpage-Deploy-WF.yml b/.github/workflows/AdminWebpage-Deploy-WF.yml index 1e59f66..773ae90 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 }} + APP_SERVICE_NAME: ${{ vars.ADMIN_APP_SERVICE_NAME }} + BOTNET_API_URL: ${{ vars.VITE_BOTNET_API_URL }} + SIMULATOR_API_URL: ${{ vars.VITE_SIMULATOR_API_URL }} + ORDER_SERVICE_URL: ${{ vars.VITE_ORDER_SERVICE_URL }} # 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..8bb2d6e 100644 --- a/.github/workflows/CustomerWebpage-Deploy-WF.yml +++ b/.github/workflows/CustomerWebpage-Deploy-WF.yml @@ -47,7 +47,10 @@ jobs: - name: Build application run: npm run build env: + 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 +65,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 }} 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..90e0196 --- /dev/null +++ b/.github/workflows/agentservice-deploy.yml @@ -0,0 +1,70 @@ +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: + +permissions: + id-token: write + contents: read + +env: + RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP_NAME }} + ACR_NAME: ${{ vars.ACR_NAME }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }} + CONTAINER_APP_NAME: ${{ vars.AGENT_SERVICE_CONTAINER_APP_NAME }} + IMAGE_NAME: agentservice + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run backend checks + run: | + dotnet build AgentService/AgentService/AgentService.csproj --configuration Release + dotnet run --project AgentService/AgentService.Tests/AgentService.Tests.csproj --configuration Release + + - name: Azure Login (OIDC) + 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 + run: az acr login --name "$ACR_NAME" + + - name: Build and push Docker image + 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 + run: | + az containerapp update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --image "$IMAGE_TAG" + + - name: Print deployment URL + 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..7bcfcb6 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 }} # 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 }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }} # Container App — pre-created with system-assigned managed identity - CONTAINER_APP_NAME: ewu-deliverybotsystem-api + CONTAINER_APP_NAME: ${{ vars.BOT_API_CONTAINER_APP_NAME }} # 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 }} + SQL_DB_NAME: ${{ vars.BOT_API_SQL_DATABASE_NAME }} IMAGE_NAME: botnetapi diff --git a/.github/workflows/iac.yml b/.github/workflows/iac.yml index d4ea71c..c650462 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 }} + TFSTATE_STORAGE_ACCOUNT: ${{ vars.TFSTATE_STORAGE_ACCOUNT }} + TFSTATE_CONTAINER: ${{ vars.TFSTATE_CONTAINER }} + TFSTATE_KEY: ${{ vars.TFSTATE_KEY || 'deliverybot-final.tfstate' }} jobs: terraform: @@ -59,6 +61,9 @@ 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 }} steps: - name: Checkout repository @@ -112,7 +117,14 @@ 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 diff --git a/.github/workflows/orderservice-deploy.yml b/.github/workflows/orderservice-deploy.yml index c87bc50..f4f4275 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 }} + ACR_NAME: ${{ vars.ACR_NAME }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }} + CONTAINER_APP_NAME: ${{ vars.ORDER_SERVICE_CONTAINER_APP_NAME }} IMAGE_NAME: orderservice jobs: diff --git a/.github/workflows/readable-bot-network-update.yml b/.github/workflows/readable-bot-network-update.yml index 52ebbb8..922af83 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 }} + FUNCTION_APP_NAME: ${{ vars.READABLE_BOT_NETWORK_FUNCTION_APP_NAME }} + COSMOS_ACCOUNT_NAME: ${{ vars.READABLE_BOT_NETWORK_COSMOS_ACCOUNT_NAME }} + COSMOS_DATABASE_NAME: ${{ vars.READABLE_BOT_NETWORK_COSMOS_DATABASE_NAME }} + BOTS_CONTAINER_NAME: ${{ vars.READABLE_BOT_NETWORK_COSMOS_CONTAINER_NAME }} + DIAGNOSTICS_CONTAINER_NAME: ${{ vars.READABLE_BOT_NETWORK_DIAGNOSTICS_CONTAINER_NAME }} jobs: update-cosmos: diff --git a/.github/workflows/simulator-deploy.yml b/.github/workflows/simulator-deploy.yml index 7a67d5e..a17e5b7 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 }} # 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 }} + ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }} # Container App — pre-created with system-assigned managed identity - CONTAINER_APP_NAME: deliverybot-robot-simulator + CONTAINER_APP_NAME: ${{ vars.SIMULATOR_CONTAINER_APP_NAME }} 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..ddb9938 --- /dev/null +++ b/AgentService/AgentService.Tests/AgentService.Tests.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + Exe + enable + enable + + + + + + + diff --git a/AgentService/AgentService.Tests/Program.cs b/AgentService/AgentService.Tests/Program.cs new file mode 100644 index 0000000..f30afbe --- /dev/null +++ b/AgentService/AgentService.Tests/Program.cs @@ -0,0 +1,288 @@ +using System.Net; +using System.Text.Json; +using AgentService.DTOs; +using AgentService.Options; +using AgentService.Services; +using Microsoft.Extensions.Options; + +var tests = new AgentServiceTestRunner(); +await tests.RunAsync(); + +internal sealed class AgentServiceTestRunner +{ + 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" + } + } + }; + + public async Task RunAsync() + { + var tests = new List<(string Name, Func Run)> + { + ("BuildUserPrompt includes question and context", () => RunSync(BuildUserPrompt_IncludesQuestionAndContext)), + ("BuildUserPrompt includes recent conversation history", () => RunSync(BuildUserPrompt_IncludesRecentHistory)), + ("BuildUserPrompt handles missing history", () => RunSync(BuildUserPrompt_HandlesMissingHistory)), + ("BuildRequestBody includes recent history and settings", () => RunSync(BuildRequestBody_IncludesHistoryAndSettings)), + ("ExtractReply reads first choice content", () => RunSync(ExtractReply_ReadsFirstChoiceContent)), + ("ChatAsync throws when Azure OpenAI is not configured", ChatAsync_ThrowsWhenAzureOpenAiIsNotConfigured), + ("ChatAsync returns reply and model when Azure OpenAI succeeds", ChatAsync_ReturnsReplyAndModel_WhenAzureOpenAiSucceeds), + ("ChatAsync throws when Azure OpenAI returns error", ChatAsync_ThrowsWhenAzureOpenAiReturnsError), + ("ChatAsync posts to Azure OpenAI chat completions endpoint", ChatAsync_PostsToAzureOpenAiChatCompletionsEndpoint) + }; + + foreach (var test in tests) + { + await test.Run(); + Console.WriteLine($"PASS {test.Name}"); + } + } + + private static Task RunSync(Action test) + { + test(); + return Task.CompletedTask; + } + + private static void BuildUserPrompt_IncludesQuestionAndContext() + { + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(Request); + + AssertContains(prompt, "What is the ETA?"); + AssertContains(prompt, "bot-002"); + AssertContains(prompt, "9 min"); + AssertContains(prompt, "Spokane Convention Center"); + AssertContains(prompt, "water x1, chips x2"); + } + + private static void BuildUserPrompt_IncludesRecentHistory() + { + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(Request); + + AssertContains(prompt, "assistant: I can help with your latest order."); + AssertContains(prompt, "user: Where is the delivery going?"); + } + + private static void BuildUserPrompt_HandlesMissingHistory() + { + var prompt = AzureOpenAiChatMapper.BuildUserPrompt(RequestWithoutHistory); + + AssertContains(prompt, "No earlier conversation is available."); + } + + private static void BuildRequestBody_IncludesHistoryAndSettings() + { + var body = JsonSerializer.Serialize( + AzureOpenAiChatMapper.BuildRequestBody(Request, MakeOptions())); + + AssertContains(body, "\"temperature\":0.2"); + AssertContains(body, "Where is the delivery going?"); + AssertContains(body, "water x1, chips x2"); + } + + private static void ExtractReply_ReadsFirstChoiceContent() + { + using var document = JsonDocument.Parse(""" + { + "model": "gpt-4.1-mini", + "choices": [ + { + "message": { + "content": "The current ETA is about 9 min." + } + } + ] + } + """); + + AssertEqual("The current ETA is about 9 min.", AzureOpenAiChatMapper.ExtractReply(document)); + AssertEqual("gpt-4.1-mini", AzureOpenAiChatMapper.ExtractModel(document)); + } + + private static async Task ChatAsync_ThrowsWhenAzureOpenAiIsNotConfigured() + { + var service = CreateService(_ => new HttpResponseMessage(HttpStatusCode.OK), new AzureOpenAiOptions()); + + try + { + await service.ChatAsync(Request); + throw new InvalidOperationException("Expected InvalidOperationException."); + } + catch (InvalidOperationException error) + { + AssertContains(error.Message, "Azure OpenAI is not configured"); + } + } + + private static 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); + + AssertEqual("The current ETA is about 9 min.", result.Reply); + AssertEqual("azure-openai", result.Source); + AssertEqual("gpt-4.1-mini", result.Model); + } + + private static async Task ChatAsync_ThrowsWhenAzureOpenAiReturnsError() + { + var service = CreateService(_ => + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"error\":\"bad request\"}") + }, + MakeOptions()); + + try + { + await service.ChatAsync(Request); + throw new InvalidOperationException("Expected InvalidOperationException."); + } + catch (InvalidOperationException error) + { + AssertContains(error.Message, "HTTP 400"); + } + } + + private static 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); + + if (requestedUri is null) + { + throw new InvalidOperationException("Expected request URI to be captured."); + } + + AssertContains(requestedUri.ToString(), "/openai/deployments/delivery-agent/chat/completions"); + AssertContains(requestedUri.ToString(), "api-version=2024-10-21"); + } + + private static AzureOpenAiAgentService CreateService( + Func respond, + AzureOpenAiOptions options) + { + var httpClient = new HttpClient(new FakeHandler(respond)); + return new AzureOpenAiAgentService(httpClient, Options.Create(options)); + } + + private static AzureOpenAiOptions MakeOptions() => new() + { + Endpoint = "https://deliverybot-openai.openai.azure.com", + Deployment = "delivery-agent", + ApiKey = "test-key", + ApiVersion = "2024-10-21" + }; + + private static void AssertContains(string actual, string expected) + { + if (!actual.Contains(expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Expected '{actual}' to contain '{expected}'."); + } + } + + private static void AssertEqual(string? expected, string? actual) + { + if (!string.Equals(expected, actual, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Expected '{expected}' but got '{actual}'."); + } + } + + private sealed class FakeHandler(Func respond) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(respond(request)); + } +} 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..a3a34b6 --- /dev/null +++ b/AgentService/AgentService/AgentService.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + 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..70dcee1 --- /dev/null +++ b/AgentService/AgentService/DTOs/AgentChatContextDto.cs @@ -0,0 +1,7 @@ +namespace AgentService.DTOs; + +public sealed class AgentChatContextDto +{ + public AgentLatestOrderDto? LatestOrder { get; set; } + public AgentRouteDto? Route { 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/Options/AzureOpenAiOptions.cs b/AgentService/AgentService/Options/AzureOpenAiOptions.cs new file mode 100644 index 0000000..942b010 --- /dev/null +++ b/AgentService/AgentService/Options/AzureOpenAiOptions.cs @@ -0,0 +1,17 @@ +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 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/Program.cs b/AgentService/AgentService/Program.cs new file mode 100644 index 0000000..0dd344e --- /dev/null +++ b/AgentService/AgentService/Program.cs @@ -0,0 +1,31 @@ +using AgentService.Options; +using AgentService.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure( + builder.Configuration.GetSection(AzureOpenAiOptions.SectionName)); + +builder.Services.AddHttpClient(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("CustomerFrontend", policy => + policy.WithOrigins( + "https://wa-deliverybot-dev.azurewebsites.net", + "https://wa-deliverybot-final.azurewebsites.net", + "http://localhost:5173") + .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/AzureOpenAiAgentService.cs b/AgentService/AgentService/Services/AzureOpenAiAgentService.cs new file mode 100644 index 0000000..9902b81 --- /dev/null +++ b/AgentService/AgentService/Services/AzureOpenAiAgentService.cs @@ -0,0 +1,70 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using AgentService.DTOs; +using AgentService.Options; +using Microsoft.Extensions.Options; + +namespace AgentService.Services; + +public sealed class AzureOpenAiAgentService : IAgentService +{ + private readonly HttpClient _httpClient; + private readonly AzureOpenAiOptions _options; + + public AzureOpenAiAgentService(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options.Value; + } + + public async Task ChatAsync( + AgentChatRequestDto request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.Message)) + { + throw new ArgumentException("Message is required.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(_options.Endpoint) || + string.IsNullOrWhiteSpace(_options.Deployment) || + string.IsNullOrWhiteSpace(_options.ApiKey)) + { + throw new InvalidOperationException( + "Azure OpenAI is not configured. Set AzureOpenAI:Endpoint, AzureOpenAI:Deployment, and AzureOpenAI:ApiKey."); + } + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildRequestUri()); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpRequest.Headers.Add("api-key", _options.ApiKey); + 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); + + return new AgentChatResponseDto + { + Reply = AzureOpenAiChatMapper.ExtractReply(document), + Source = "azure-openai", + Model = AzureOpenAiChatMapper.ExtractModel(document) + }; + } + + private string BuildRequestUri() + { + var endpoint = _options.Endpoint.TrimEnd('/'); + return $"{endpoint}/openai/deployments/{_options.Deployment}/chat/completions?api-version={_options.ApiVersion}"; + } +} diff --git a/AgentService/AgentService/Services/AzureOpenAiChatMapper.cs b/AgentService/AgentService/Services/AzureOpenAiChatMapper.cs new file mode 100644 index 0000000..0a9cb51 --- /dev/null +++ b/AgentService/AgentService/Services/AzureOpenAiChatMapper.cs @@ -0,0 +1,129 @@ +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("Answer the customer directly in plain language."); + lines.Add("If a detail is missing, say that directly instead of guessing."); + + 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/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/appsettings.json b/AgentService/AgentService/appsettings.json new file mode 100644 index 0000000..821190d --- /dev/null +++ b/AgentService/AgentService/appsettings.json @@ -0,0 +1,16 @@ +{ + "AzureOpenAI": { + "Endpoint": "", + "Deployment": "", + "ApiKey": "", + "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." + }, + "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..5d9c939 --- /dev/null +++ b/Iac/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# 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: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: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..7483cea 100644 --- a/Iac/Iac.md +++ b/Iac/Iac.md @@ -1,9 +1,39 @@ # 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](C:/Users/kernw/Desktop/DeliveryBotSystem%20-%20FinalProject/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. + +## 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. diff --git a/Iac/admin-webapp/main.tf b/Iac/admin-webapp/main.tf index a10bad4..d1de749 100644 --- a/Iac/admin-webapp/main.tf +++ b/Iac/admin-webapp/main.tf @@ -1,16 +1,12 @@ -# 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 } 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..d061f6d 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" + default = "deliverybot-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..3805bea --- /dev/null +++ b/Iac/agent-service/README.md @@ -0,0 +1,23 @@ +# 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` | Azure OpenAI API key | + +## 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..e1be83b --- /dev/null +++ b/Iac/agent-service/main.tf @@ -0,0 +1,46 @@ +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 + + secrets = { + "azure-openai-api-key" = var.azure_openai_api_key + } + + env_vars = { + "ASPNETCORE_ENVIRONMENT" = "Production" + "AzureOpenAI__Endpoint" = var.azure_openai_endpoint + "AzureOpenAI__Deployment" = var.azure_openai_deployment + "AzureOpenAI__ApiVersion" = var.azure_openai_api_version + } + + secret_env_vars = { + "AzureOpenAI__ApiKey" = "azure-openai-api-key" + } + + 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..c8e6d05 --- /dev/null +++ b/Iac/agent-service/variables.tf @@ -0,0 +1,61 @@ +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_key" { + description = "Azure OpenAI API key." + type = string + sensitive = true +} + +variable "azure_openai_api_version" { + description = "Azure OpenAI API version used for chat completions." + type = string + default = "2024-10-21" +} + +variable "tags" { + description = "Common tags applied to Agent Service resources." + type = map(string) + default = { + project = "DeliveryBot" + component = "agent-service" + } +} + 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..e1e185c --- /dev/null +++ b/Iac/final-project.tfvars.example @@ -0,0 +1,31 @@ +resource_group_name = "deliverybot-final-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-final" +admin_app_service_name = "wa-deliverybot-admin-final" +bot_api_container_app_name = "deliverybot-botapi-final" +order_service_container_app_name = "deliverybot-orders-final" +agent_service_container_app_name = "deliverybot-agent-final" +simulator_container_app_name = "deliverybot-simulator-final" +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 = "deliverybotfinalevhns" +acr_name = "deliverybotfinalcr" +bot_api_sql_server_name = "deliverybot-final-sql" +botnet_api_url = "https://deliverybot-botapi-final.example.com" +simulator_api_url = "https://deliverybot-simulator-final.example.com" +azure_openai_endpoint = "https://your-openai-resource.openai.azure.com/" +azure_openai_deployment = "bot-assistant" +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" 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..0601725 100644 --- a/Iac/frontend/variables.tf +++ b/Iac/frontend/variables.tf @@ -1,19 +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" + default = "deliverybot-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" { description = "Globally-unique name for the Customer Frontend App Service." type = string - default = "WA-DeliveryBot-dev" + default = "wa-deliverybot-dev" } variable "node_version" { diff --git a/Iac/imports-final-project.tf b/Iac/imports-final-project.tf new file mode 100644 index 0000000..74073e2 --- /dev/null +++ b/Iac/imports-final-project.tf @@ -0,0 +1,19 @@ +locals { + import_sub = "207d6c46-9d83-44fc-b7d5-6e2cfcf4d001" + import_rg = "deliverybot-final-rg" +} + +import { + to = module.shared_infra.azurerm_eventhub_namespace.simulator + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.EventHub/namespaces/deliverybotfinalevhns" +} + +import { + to = module.shared_infra.azurerm_eventhub.robot_input + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.EventHub/namespaces/deliverybotfinalevhns/eventhubs/robot-input" +} + +import { + to = module.shared_infra.azurerm_eventhub.robot_output + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.EventHub/namespaces/deliverybotfinalevhns/eventhubs/robot-output" +} 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..788f841 100644 --- a/Iac/main.tf +++ b/Iac/main.tf @@ -1,105 +1,121 @@ -# 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_key = var.azure_openai_api_key + azure_openai_api_version = var.azure_openai_api_version +} + +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 +} 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..6f49cd1 100644 --- a/Iac/outputs.tf +++ b/Iac/outputs.tf @@ -31,6 +31,43 @@ 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 +} + +# ── 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..a5abfc6 100644 --- a/Iac/shared-infra/variables.tf +++ b/Iac/shared-infra/variables.tf @@ -1,35 +1,123 @@ variable "resource_group_name" { description = "Name of the shared resource group." type = string - default = "ewu-deliverybotsystem-rg" + default = "deliverybot-rg" } 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" -} +} \ No newline at end of file diff --git a/Iac/variables.tf b/Iac/variables.tf index 69da6f9..eed6a83 100644 --- a/Iac/variables.tf +++ b/Iac/variables.tf @@ -1,49 +1,67 @@ -# 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 - default = "ewu-deliverybotsystem-rg" + default = "deliverybot-rg" } 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 - default = "DeliverybotCR" + default = "deliverybotdevcr" } variable "container_app_environment_name" { description = "Name of the shared Container Apps managed environment." type = string - default = "managedEnvironment-ewudeliverybots-aa2f" + default = "deliverybot-dev-cae" +} + +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 - default = "DeliverybotSimulator-EVHNS" + default = "deliverybot-dev-evhns" +} + +variable "robot_input_partition_count" { + description = "Partition count for the robot-input Event Hub." + type = number + default = 2 } -# ── Shared-infra specific ────────────────────────────────────────────────────── +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,12 +82,34 @@ 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-deliverybot-dev" +} + +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 = "ASP-RGDeliveryBotdev-8b82" + default = "canadacentral" +} + +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 "node_version" { @@ -78,81 +118,199 @@ variable "node_version" { 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 - default = "https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io" + default = "https://deliverybot-botapi-dev.example.com" } variable "simulator_api_url" { description = "Public HTTPS URL of the Robot Simulator Container App." type = string - default = "https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io" + default = "https://deliverybot-simulator-dev.example.com" } -# ── Admin Web App ────────────────────────────────────────────────────────────── - variable "admin_app_service_name" { description = "Name of the Admin Web App App Service." type = string - default = "WA-DeliveryBot-Admin-dev" + 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" + default = "deliverybot-orders-dev" +} + +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 } -# ── Bot API ──────────────────────────────────────────────────────────────────── +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 +} + +variable "azure_openai_api_version" { + description = "Azure OpenAI API version used by the Agent Service." + type = string + default = "2024-10-21" +} + +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." type = string - default = "ewu-deliverybotsystem-api" + default = "deliverybot-botapi-dev" } variable "bot_api_sql_server_name" { description = "Name of the shared SQL server used by the Bot API." type = string - default = "deliverybotsystem-sql" + default = "deliverybot-dev-sql" } 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;" + default = "Server=tcp:deliverybot-dev-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" + default = "wa-deliverybot-dev" } -# ── Robot Simulator ──────────────────────────────────────────────────────────── - variable "simulator_container_app_name" { description = "Name of the Robot Simulator Container App." type = string - default = "deliverybot-robot-simulator" + default = "deliverybot-simulator-dev" } 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..fb2b561 --- /dev/null +++ b/frontend/customer-webapp/.env.final-project.example @@ -0,0 +1,5 @@ +VITE_SIMULATOR_API_BASE=/api/simulator +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 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf 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 && ( +