├── .azdo
└── pipelines
│ └── azure-dev.yml
├── .devcontainer
└── devcontainer.json
├── .github
├── CODE_OF_CONDUCT.md
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
├── dependabot.yml
└── workflows
│ ├── azure-dev.yml
│ ├── integration-test.yml
│ └── template-validation.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── SUPPORT.md
├── azure.yaml
├── data
├── ContosoBenefits.pdf
└── fork.jpg
├── infra
├── abbreviations.json
├── main.bicep
├── main.parameters.json
└── modules
│ ├── aistudio
│ ├── aihub.bicep
│ ├── aiservices.bicep
│ ├── dns.bicep
│ ├── keyVault.bicep
│ ├── network.bicep
│ ├── searchService.bicep
│ ├── sharedPrivateLinks.bicep
│ └── storage.bicep
│ ├── appinsights.bicep
│ ├── appservice.bicep
│ ├── bing.bicep
│ ├── botservice.bicep
│ ├── cosmos.bicep
│ ├── gptDeployment.bicep
│ └── msi.bicep
├── media
└── architecture.png
├── scripts
├── setupSso.ps1
└── setupSso.sh
└── src
├── .env.example
├── app.py
├── azure_ai_projects-1.0.0b1-py3-none-any.whl
├── bots
├── __init__.py
├── assistant_bot.py
└── state_management_bot.py
├── config.py
├── conftest.py
├── data_models
├── __init__.py
├── conversation_data.py
└── mime_type.py
├── dialogs
├── __init__.py
└── login_dialog.py
├── gunicorn.conf.py
├── public
├── images
│ ├── BotServices-Translucent.svg
│ └── BotServices.png
├── index.html
└── webchat.js.gz
├── pytest.ini
├── requirements.txt
├── routes
├── api
│ ├── directline.py
│ ├── files.py
│ └── messages.py
└── static
│ └── static.py
├── services
├── bing.py
├── cosmos.py
└── graph.py
├── tests
├── __init__.py
├── test_bot.py
├── test_directline.py
├── test_files.py
├── test_messages.py
├── test_models.py
└── test_sample.py
├── tools
├── BingQuery.txt
├── ImageQuery.json
└── ScheduleEvent.txt
└── utils.py
/.azdo/pipelines/azure-dev.yml:
--------------------------------------------------------------------------------
1 | # Run when commits are pushed to mainline branch (main or master)
2 | # Set this to the mainline branch you are using
3 | trigger:
4 | - main
5 | - master
6 |
7 | # Azure Pipelines workflow to deploy to Azure using azd
8 | # To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo`
9 | # Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd
10 | # See below for alternative task to install azd if you can't install above task in your organization
11 |
12 | pool:
13 | vmImage: ubuntu-latest
14 |
15 | steps:
16 | # - task: setup-azd@0
17 | # displayName: Install azd
18 |
19 | # If you can't install above task in your organization, you can comment it and uncomment below task to install azd
20 | - task: Bash@3
21 | displayName: Install azd
22 | inputs:
23 | targetType: 'inline'
24 | script: |
25 | curl -fsSL https://aka.ms/install-azd.sh | bash
26 |
27 | # azd delegate auth to az to use service connection with AzureCLI@2
28 | - pwsh: |
29 | azd config set auth.useAzCliAuth "true"
30 | displayName: Configure AZD to Use AZ CLI Authentication.
31 |
32 | - task: AzureCLI@2
33 | displayName: Install Az Auth v2
34 | inputs:
35 | azureSubscription: azconnection
36 | scriptType: bash
37 | scriptLocation: inlineScript
38 | inlineScript: |
39 | az extension add --name authV2 -y
40 | env:
41 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID)
42 | AZURE_ENV_NAME: $(AZURE_ENV_NAME)
43 | AZURE_LOCATION: $(AZURE_LOCATION)
44 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(AZD_INITIAL_ENVIRONMENT_CONFIG)
45 |
46 | - task: AzureCLI@2
47 | displayName: Provision Infrastructure
48 | inputs:
49 | azureSubscription: azconnection
50 | addSpnToEnvironment: true
51 | scriptType: bash
52 | scriptLocation: inlineScript
53 | inlineScript: |
54 | azd provision --no-prompt
55 | env:
56 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID)
57 | AZURE_ENV_NAME: $(AZURE_ENV_NAME)
58 | AZURE_LOCATION: $(AZURE_LOCATION)
59 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(AZD_INITIAL_ENVIRONMENT_CONFIG)
60 |
61 | - task: AzureCLI@2
62 | displayName: Deploy Application
63 | inputs:
64 | azureSubscription: azconnection
65 | scriptType: bash
66 | scriptLocation: inlineScript
67 | inlineScript: |
68 | azd deploy --no-prompt
69 | env:
70 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID)
71 | AZURE_ENV_NAME: $(AZURE_ENV_NAME)
72 | AZURE_LOCATION: $(AZURE_LOCATION)
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Azure Developer CLI",
3 | "image": "mcr.microsoft.com/devcontainers/python:3.10-bullseye",
4 | "features": {
5 | // See https://containers.dev/features for list of features
6 | "azure-cli": "latest",
7 | "ghcr.io/devcontainers/features/docker-in-docker:2": {},
8 | "ghcr.io/azure/azure-dev/azd:latest": {}
9 | },
10 | "customizations": {
11 | "vscode": {
12 | "extensions": [
13 | "GitHub.vscode-github-actions",
14 | "ms-azuretools.azure-dev",
15 | "ms-azuretools.vscode-azurefunctions",
16 | "ms-azuretools.vscode-bicep",
17 | "ms-azuretools.vscode-docker"
18 | // Include other VSCode language extensions if needed
19 | // Right click on an extension inside VSCode to add directly to devcontainer.json, or copy the extension ID
20 | ]
21 | }
22 | },
23 | "forwardPorts": [
24 | // Forward ports if needed for local development
25 | ],
26 | "postCreateCommand": "",
27 | "remoteUser": "vscode",
28 | "hostRequirements": {
29 | "memory": "8gb"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Microsoft Open Source Code of Conduct
2 |
3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
4 |
5 | Resources:
6 |
7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
4 | > Please provide us with the following information:
5 | > ---------------------------------------------------------------
6 |
7 | ### This issue is for a: (mark with an `x`)
8 | ```
9 | - [ ] bug report -> please search issues before submitting
10 | - [ ] feature request
11 | - [ ] documentation issue or request
12 | - [ ] regression (a behavior that used to work and stopped in a new release)
13 | ```
14 |
15 | ### Minimal steps to reproduce
16 | >
17 |
18 | ### Any log messages given by the failure
19 | >
20 |
21 | ### Expected/desired behavior
22 | >
23 |
24 | ### OS and Version?
25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?)
26 |
27 | ### Versions
28 | >
29 |
30 | ### Mention any other details that might be useful
31 |
32 | > ---------------------------------------------------------------
33 | > Thanks! We'll be in touch soon.
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Purpose
2 |
3 | * ...
4 |
5 | ## Does this introduce a breaking change?
6 |
7 | ```
8 | [ ] Yes
9 | [ ] No
10 | ```
11 |
12 | ## Pull Request Type
13 | What kind of change does this Pull Request introduce?
14 |
15 |
16 | ```
17 | [ ] Bugfix
18 | [ ] Feature
19 | [ ] Code style update (formatting, local variables)
20 | [ ] Refactoring (no functional changes, no api changes)
21 | [ ] Documentation content changes
22 | [ ] Other... Please describe:
23 | ```
24 |
25 | ## How to Test
26 | * Get the code
27 |
28 | ```
29 | git clone [repo-address]
30 | cd [repo-name]
31 | git checkout [branch-name]
32 | npm install
33 | ```
34 |
35 | * Test the code
36 |
37 | ```
38 | ```
39 |
40 | ## What to Check
41 | Verify that the following are valid
42 | * ...
43 |
44 | ## Other Information
45 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for more information:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 | # https://containers.dev/guide/dependabot
6 |
7 | version: 2
8 | updates:
9 | - package-ecosystem: "devcontainers"
10 | directory: "/"
11 | schedule:
12 | interval: weekly
--------------------------------------------------------------------------------
/.github/workflows/azure-dev.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Dev
2 | on:
3 | workflow_dispatch:
4 |
5 | # GitHub Actions workflow to deploy to Azure using azd
6 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config`
7 |
8 | # Set up permissions for deploying with secretless Azure federated credentials
9 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication
10 | permissions:
11 | id-token: write
12 | contents: read
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 | env:
18 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
19 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
20 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
21 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
22 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
23 | steps:
24 | - name: Checkout
25 | uses: actions/checkout@v4
26 |
27 | - name: Install azd
28 | uses: Azure/setup-azd@v1.0.0
29 |
30 | - name: Log in with Azure Developer (Federated Credentials)
31 | if: ${{ env.AZURE_CLIENT_ID != '' }}
32 | run: |
33 | azd auth login `
34 | --client-id "$Env:AZURE_CLIENT_ID" `
35 | --federated-credential-provider "github" `
36 | --tenant-id "$Env:AZURE_TENANT_ID"
37 | shell: pwsh
38 |
39 | - name: Azure login
40 | uses: azure/login@v2
41 | with:
42 | client-id: ${{ env.AZURE_CLIENT_ID }}
43 | tenant-id: ${{ env.AZURE_TENANT_ID }}
44 | subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
45 |
46 | - name: Log in with Azure (Client Credentials)
47 | if: ${{ env.AZURE_CREDENTIALS != '' }}
48 | run: |
49 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable;
50 | Write-Host "::add-mask::$($info.clientSecret)"
51 |
52 | azd auth login `
53 | --client-id "$($info.clientId)" `
54 | --client-secret "$($info.clientSecret)" `
55 | --tenant-id "$($info.tenantId)"
56 | shell: pwsh
57 | env:
58 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
59 |
60 | - name: Provision Infrastructure
61 | run: azd provision --no-prompt
62 | env:
63 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }}
64 |
65 | - name: Deploy Application
66 | run: azd deploy --no-prompt
--------------------------------------------------------------------------------
/.github/workflows/integration-test.yml:
--------------------------------------------------------------------------------
1 | name: Run Integration Tests
2 | on:
3 | workflow_dispatch:
4 |
5 | # GitHub Actions workflow to deploy to Azure using azd
6 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config`
7 |
8 | # Set up permissions for deploying with secretless Azure federated credentials
9 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication
10 | permissions:
11 | id-token: write
12 | contents: read
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 | env:
18 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
19 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
20 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
21 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
22 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
23 | steps:
24 | - name: Checkout
25 | uses: actions/checkout@v4
26 |
27 | - name: Install azd
28 | uses: Azure/setup-azd@v1.0.0
29 |
30 | - name: Log in with Azure Developer (Federated Credentials)
31 | if: ${{ env.AZURE_CLIENT_ID != '' }}
32 | run: |
33 | azd auth login `
34 | --client-id "$Env:AZURE_CLIENT_ID" `
35 | --federated-credential-provider "github" `
36 | --tenant-id "$Env:AZURE_TENANT_ID"
37 | shell: pwsh
38 |
39 | - name: Azure login
40 | uses: azure/login@v2
41 | with:
42 | client-id: ${{ env.AZURE_CLIENT_ID }}
43 | tenant-id: ${{ env.AZURE_TENANT_ID }}
44 | subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
45 |
46 | - name: Log in with Azure (Client Credentials)
47 | if: ${{ env.AZURE_CREDENTIALS != '' }}
48 | run: |
49 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable;
50 | Write-Host "::add-mask::$($info.clientSecret)"
51 |
52 | azd auth login `
53 | --client-id "$($info.clientId)" `
54 | --client-secret "$($info.clientSecret)" `
55 | --tenant-id "$($info.tenantId)"
56 | shell: pwsh
57 | env:
58 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
59 |
60 | - name: Retrieve environment
61 | run: azd env refresh --no-prompt
62 | env:
63 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }}
64 |
65 | - name: Run tests
66 | run: cd src && pytest --cov=. --cov-report xml --nf tests/*
--------------------------------------------------------------------------------
/.github/workflows/template-validation.yml:
--------------------------------------------------------------------------------
1 | name: Template Validation
2 | on:
3 | workflow_dispatch:
4 |
5 | permissions:
6 | contents: read
7 | id-token: write
8 | pull-requests: write
9 |
10 | jobs:
11 | template_validation_job:
12 | runs-on: ubuntu-latest
13 | name: template validation
14 | steps:
15 | - uses: actions/checkout@v4
16 | - uses: microsoft/template-validation-action@v0.3.2
17 | id: validation
18 | env:
19 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
20 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
21 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
22 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
23 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
24 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }}
25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26 |
27 | - name: print result
28 | run: cat ${{ steps.validation.outputs.resultFile }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .azure
2 |
3 | .venv
4 | .conda
5 | .env
6 | __pycache__
7 |
8 | node_modules
9 |
10 | bin
11 | obj
12 | appsettings.json
13 |
14 | .DS_Store
15 |
16 | .pytest_cache
17 | .coverage
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to this project
2 |
3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a
4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
6 |
7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide
8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
9 | provided by the bot. You will only need to do this once across all repos using our CLA.
10 |
11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
14 |
15 | - [Code of Conduct](#coc)
16 | - [Issues and Bugs](#issue)
17 | - [Feature Requests](#feature)
18 | - [Submission Guidelines](#submit)
19 |
20 | ## Code of Conduct
21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
22 |
23 | ## Found an Issue?
24 | If you find a bug in the source code or a mistake in the documentation, you can help us by
25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can
26 | [submit a Pull Request](#submit-pr) with a fix.
27 |
28 | ## Want a Feature?
29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub
30 | Repository. If you would like to *implement* a new feature, please submit an issue with
31 | a proposal for your work first, to be sure that we can use it.
32 |
33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).
34 |
35 | ## Submission Guidelines
36 |
37 | ### Submitting an Issue
38 | Before you submit an issue, search the archive, maybe your question was already answered.
39 |
40 | If your issue appears to be a bug, and hasn't been reported, open a new issue.
41 | Help us to maximize the effort we can spend fixing issues and adding new
42 | features, by not reporting duplicate issues. Providing the following information will increase the
43 | chances of your issue being dealt with quickly:
44 |
45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps
46 | * **Version** - what version is affected (e.g. 0.1.2)
47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you
48 | * **Browsers and Operating System** - is this a problem with all browsers?
49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps
50 | * **Related Issues** - has a similar issue been reported before?
51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be
52 | causing the problem (line of code or commit)
53 |
54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new].
55 |
56 | ### Submitting a Pull Request (PR)
57 | Before you submit your Pull Request (PR) consider the following guidelines:
58 |
59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR
60 | that relates to your submission. You don't want to duplicate effort.
61 |
62 | * Make your changes in a new git fork:
63 |
64 | * Commit your changes using a descriptive commit message
65 | * Push your fork to GitHub:
66 | * In GitHub, create a pull request
67 | * If we suggest changes then:
68 | * Make the required updates.
69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request):
70 |
71 | ```shell
72 | git rebase master -i
73 | git push -f
74 | ```
75 |
76 | That's it! Thank you for your contribution!
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Marco Cardoso
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Azure Agents Travel Assistant
2 |
3 | End-to-end sample of a travel agent implemented with Azure AI Agent Service and Bot Framework. The travel assistant leverages the Azure AI platform to understand images and documents, providing assistance during travel planning and common travel situations. Key features include:
4 |
5 | - **Image Recognition**: Identify landmarks, extract text from travel documents, and more.
6 | - **Document Understanding**: Parse itineraries, booking confirmations, and other travel-related documents.
7 | - **Travel Assistance**: Offer recommendations, reminders, and support for various travel scenarios.
8 |
9 | > [!IMPORTANT]
10 | > The Azure AI Agent Service is currently in Private Preview. This early stage of development means the product is actively evolving, with significant updates and improvements expected. Users should anticipate changes as we work towards refining features, enhancing functionality, and expanding capabilities. We welcome feedback and contributions during this phase to help shape the future of the product. [Azure AI Agent Service: Private Preview Waitlist Application](https://customervoice.microsoft.com/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR4zfDSs3yfVOpxzjhs7PkSlUNlhGSlZTMkFBSDNaSVRMSDZBUjhPT1VLMCQlQCN0PWcu)
11 |
12 | ## Solution Architecture
13 |
14 | Below are the main components deployed as part of this solution:
15 |
16 | 
17 |
18 | - User sends messages through one of the [supported Bot Framework Channels](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-channels-reference?view=azure-bot-service-4.0);
19 | - The Azure Bot Service connects to the Python web application through the /api/messages endpoint;
20 | - The Application communicates with the Azure AI Agent Runtime to process incoming messages using assistants;
21 | - Results are streamed back in realtime, and posted back to the user's channel;
22 | - The conversation history is stored in Cosmos DB, as well as ephemeral conversation state.
23 | > Note: Communications between App Services, Azure AI and Cosmos DB can be configured to use Private Endpoints. Authentication to these services is done using a User-Assigned Managed Identity.
24 |
25 | ## Getting Started
26 |
27 | To get started with the Azure Agents Travel Assistant, follow the instructions below to set up your environment and run the sample application.
28 |
29 | ### Prerequisites
30 |
31 | - An Azure subscription
32 | - Azure OpenAI: 50k Tokens per Minute of Pay-as-you-go quota for GPT-4o or GPT-4o-mini
33 | - Azure App Services: Available VM quota - P0v3 recommended
34 | - Azure CLI
35 | - Azure Developer CLI
36 | - Python 3.10 or later (for running locally)
37 |
38 | ### Installation
39 |
40 | 1. Before you begin, make sure you are logged in to both AZ and AZD CLIs
41 | ```sh
42 | az login # or az login -t
43 | azd auth login # or azd auth login --tenant
44 | ```
45 |
46 | 2. Clone the repository:
47 | ```sh
48 | git clone https://github.com/Azure-Samples/azureai-travel-agent-python
49 | cd azureai-travel-agent-python
50 | ```
51 |
52 | 3. Deploy the infrastructure and sample app
53 | ```sh
54 | azd up
55 | ```
56 | You will be prompted to select and Azure subscription, a region, and an environment name. The environment name should be a short string, and it will be used to name the deployed resources.
57 |
58 | 4. (Optional) Run locally:
59 | ```sh
60 | cd src
61 | pip install -r requirements.txt
62 | python app.py
63 | ```
64 |
65 | 5. (Optional) Open http://localhost:3978/api/messages in the [Bot Framework Emulator](https://github.com/microsoft/BotFramework-Emulator)
66 |
67 | ## Features
68 |
69 | - **Publicly available travel knowledge**: Ask about well-known destinations and tourist attractions
70 | - **Document Upload**: Upload PDF, Word and other document formats and add to File Search to use the information contained as part of the conversation
71 | - **Image Upload**: Upload images and ask questions about the location, landmark or directions
72 | - **Web search**: The Agent may use Bing Search to obtain updated information about certain locations, accomodations, weather and more.
73 |
74 | ## Guidance
75 |
76 | ### Regional Availablility
77 |
78 | You may deploy this solution on any regions that support Azure AI Agents. Some components, such as Bot Service and Bing Search, are deployed in a global model, and as such are not tied to a single region. Make sure to review Data Residency requirements.
79 |
80 | **At this time, only the East US region supports Azure AI Agents.**
81 |
82 | - [Regionalization in Azure AI Bot Service](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-concept-regionalization?view=azure-bot-service-4.0)
83 |
84 | If you need to deploy services across more than one region, use the commands below to set regions for specific services. Services with unspecified locations will be created in the "main" region chosen.
85 |
86 | ```sh
87 | # azd env set AZURE__LOCATION
88 | # For example:
89 | azd env set AZURE_APPSERVICE_LOCATION eastus2
90 | azd env set AZURE_COSMOSDB_LOCATION westus
91 | ```
92 |
93 | Review ./infra/main.parameters.json for a full list of available environment configurations.
94 |
95 | ### Model Support
96 |
97 | This quickstart supports both GPT-4o and GPT-4o-mini. Ohter models may also perform well depending on question complexity. Standard deployments are used by default, but you may update them to Global Standard or Provisioned SKUs after successfully deploying the solution.
98 |
99 | ### Troubleshooting
100 |
101 | - Provisioning errors:
102 | - `Azure Open AI: InsufficientQuota`: Your Azure Subscription does not have enough pay-as-you-go quota for the selected region/model. Select a different subscription, region, model, or [request quota](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/quota?tabs=rest).
103 | - `Bing Search: Insufficient Quota`: You cannot add a new Bing resource in the selected Subscription and SKU. Select a different SKU or region, or delete other Bing Search resources
104 | - `Cosmos DB: Service Unavailable`: This region may be under high demand and does not have available resources for your subcription type. Select a different region or try again later
105 | - `Any resource: Invalid Resource Location, the resource already exists`: A resource with the same name already exists in a different region or resource group. Delete existing resources or retry with a different environment name
106 | - Runtime errors:
107 | - `Message: Invalid subscription ID`: Your subscription may not be enabled for Azure AI Agents. During the preview of this functionality, it may be required to complete additional steps to onboard your subscription to Azure AI Agents. Alternatively, update the .env file (locally) or app service environment variables (on Azure) to point the application to another AI Project.
108 | - `azure.core.exceptions.ClientAuthenticationError: (PermissionDenied) Principal does not have access to API/Operation.`: Your current user does not have Azure ML Data Scientist role, or your IP is not allowed to access the Azure AI Hub. Review the RBAC and networking configurations of your AI Hub/Project. Similar exceptions may happen when not logged in with the Azure CLI.
109 |
110 | ## Security
111 |
112 | Most of the resources deployed in this template leverage Private Endpoints and Entra ID authentication for enhanced security. Make sure to use the corresponding parameters to use these features.
113 |
114 | There is currently one exception to this pattern in this repository:
115 |
116 | - Bot Service does not support Entra ID authentication for the Direct Line / Web channel.
117 |
118 | For this reason, this service will use a secret, properly stored in the Key Vault deployed with the quickstart.
119 |
120 | ## Resources
121 |
122 | - [Getting started with Azure OpenAI Assistants (Preview)](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/assistant)
123 | - [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/overview)
124 | - [Generative AI For Beginners](https://github.com/microsoft/generative-ai-for-beginners)
125 |
126 | ## How to Contribute
127 |
128 | This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit
129 |
130 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA.
131 |
132 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq) or contact with any additional questions or comments.
133 |
134 | ## Key Contacts & Contributors
135 |
136 | | Contact | GitHub ID | Email |
137 | |---------|-----------|-------|
138 | | Marco Cardoso | @MarcoABCardoso | macardoso@microsoft.com |
139 |
140 | ## License
141 |
142 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies.
143 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Security
4 |
5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/).
6 |
7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below.
8 |
9 | ## Reporting Security Issues
10 |
11 | **Please do not report security vulnerabilities through public GitHub issues.**
12 |
13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report).
14 |
15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey).
16 |
17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc).
18 |
19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
20 |
21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
22 | * Full paths of source file(s) related to the manifestation of the issue
23 | * The location of the affected source code (tag/branch/commit or direct URL)
24 | * Any special configuration required to reproduce the issue
25 | * Step-by-step instructions to reproduce the issue
26 | * Proof-of-concept or exploit code (if possible)
27 | * Impact of the issue, including how an attacker might exploit the issue
28 |
29 | This information will help us triage your report more quickly.
30 |
31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs.
32 |
33 | ## Preferred Languages
34 |
35 | We prefer all communications to be in English.
36 |
37 | ## Policy
38 |
39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd).
40 |
41 |
42 |
--------------------------------------------------------------------------------
/SUPPORT.md:
--------------------------------------------------------------------------------
1 | # Support
2 |
3 | ## How to file issues and get help
4 |
5 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing
6 | issues before filing new issues to avoid duplicates. For new issues, file your bug or
7 | feature request as a new Issue.
8 |
9 | ## Microsoft Support Policy
10 |
11 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
12 |
--------------------------------------------------------------------------------
/azure.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
2 |
3 | name: azure-agents-python
4 | services:
5 | azure-agents-app:
6 | project: src
7 | host: appservice
8 | language: python
9 | hooks:
10 | postprovision:
11 | windows:
12 | shell: pwsh
13 | run: Copy-Item ".azure/$(azd env get-value AZURE_ENV_NAME)/.env" -Destination "src/.env"
14 | interactive: false
15 | continueOnError: false
16 | posix:
17 | shell: sh
18 | run: cp .azure/$(azd env get-value AZURE_ENV_NAME)/.env src/.env
19 | interactive: false
20 | continueOnError: false
--------------------------------------------------------------------------------
/data/ContosoBenefits.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/data/ContosoBenefits.pdf
--------------------------------------------------------------------------------
/data/fork.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/data/fork.jpg
--------------------------------------------------------------------------------
/infra/abbreviations.json:
--------------------------------------------------------------------------------
1 | {
2 | "analysisServicesServers": "as",
3 | "apiManagementService": "apim-",
4 | "appConfigurationConfigurationStores": "appcs-",
5 | "appManagedEnvironments": "cae-",
6 | "appContainerApps": "ca-",
7 | "authorizationPolicyDefinitions": "policy-",
8 | "automationAutomationAccounts": "aa-",
9 | "blueprintBlueprints": "bp-",
10 | "blueprintBlueprintsArtifacts": "bpa-",
11 | "cacheRedis": "redis-",
12 | "cdnProfiles": "cdnp-",
13 | "cdnProfilesEndpoints": "cdne-",
14 | "cognitiveServicesAccounts": "cog-",
15 | "cognitiveServicesAzureAI": "cog-ai-",
16 | "cognitiveServicesBing": "cog-bg-",
17 | "cognitiveServicesOpenAI": "cog-oa-",
18 | "cognitiveServicesSpeech": "cog-spc-",
19 | "cognitiveServicesFormRecognizer": "cog-fr-",
20 | "cognitiveServicesTextAnalytics": "cog-ta-",
21 | "cognitiveServicesBot": "cog-bot-",
22 | "computeAvailabilitySets": "avail-",
23 | "computeCloudServices": "cld-",
24 | "computeDiskEncryptionSets": "des",
25 | "computeDisks": "disk",
26 | "computeDisksOs": "osdisk",
27 | "computeGalleries": "gal",
28 | "computeSnapshots": "snap-",
29 | "computeVirtualMachines": "vm",
30 | "computeVirtualMachineScaleSets": "vmss-",
31 | "containerInstanceContainerGroups": "ci",
32 | "containerRegistryRegistries": "cr",
33 | "containerServiceManagedClusters": "aks-",
34 | "databricksWorkspaces": "dbw-",
35 | "dataFactoryFactories": "adf-",
36 | "dataLakeAnalyticsAccounts": "dla",
37 | "dataLakeStoreAccounts": "dls",
38 | "dataMigrationServices": "dms-",
39 | "dBforMySQLServers": "mysql-",
40 | "dBforPostgreSQLServers": "psql-",
41 | "devicesIotHubs": "iot-",
42 | "devicesProvisioningServices": "provs-",
43 | "devicesProvisioningServicesCertificates": "pcert-",
44 | "documentDBDatabaseAccounts": "cosmos-",
45 | "eventGridDomains": "evgd-",
46 | "eventGridDomainsTopics": "evgt-",
47 | "eventGridEventSubscriptions": "evgs-",
48 | "eventHubNamespaces": "evhns-",
49 | "eventHubNamespacesEventHubs": "evh-",
50 | "hdInsightClustersHadoop": "hadoop-",
51 | "hdInsightClustersHbase": "hbase-",
52 | "hdInsightClustersKafka": "kafka-",
53 | "hdInsightClustersMl": "mls-",
54 | "hdInsightClustersSpark": "spark-",
55 | "hdInsightClustersStorm": "storm-",
56 | "hybridComputeMachines": "arcs-",
57 | "insightsActionGroups": "ag-",
58 | "insightsComponents": "appi-",
59 | "keyVaultVaults": "kv-",
60 | "kubernetesConnectedClusters": "arck",
61 | "kustoClusters": "dec",
62 | "kustoClustersDatabases": "dedb",
63 | "logicIntegrationAccounts": "ia-",
64 | "logicWorkflows": "logic-",
65 | "machineLearningServicesWorkspaces": "mlw-",
66 | "managedIdentityUserAssignedIdentities": "id-",
67 | "managementManagementGroups": "mg-",
68 | "migrateAssessmentProjects": "migr-",
69 | "networkApplicationGateways": "agw-",
70 | "networkApplicationSecurityGroups": "asg-",
71 | "networkAzureFirewalls": "afw-",
72 | "networkBastionHosts": "bas-",
73 | "networkConnections": "con-",
74 | "networkDnsZones": "dnsz-",
75 | "networkExpressRouteCircuits": "erc-",
76 | "networkFirewallPolicies": "afwp-",
77 | "networkFirewallPoliciesWebApplication": "waf",
78 | "networkFirewallPoliciesRuleGroups": "wafrg",
79 | "networkFrontDoors": "fd-",
80 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-",
81 | "networkLoadBalancersExternal": "lbe-",
82 | "networkLoadBalancersInternal": "lbi-",
83 | "networkLoadBalancersInboundNatRules": "rule-",
84 | "networkLocalNetworkGateways": "lgw-",
85 | "networkNatGateways": "ng-",
86 | "networkNetworkInterfaces": "nic-",
87 | "networkNetworkSecurityGroups": "nsg-",
88 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-",
89 | "networkNetworkWatchers": "nw-",
90 | "networkPrivateDnsZones": "pdnsz-",
91 | "networkPrivateLinkServices": "pl-",
92 | "networkPublicIPAddresses": "pip-",
93 | "networkPublicIPPrefixes": "ippre-",
94 | "networkRouteFilters": "rf-",
95 | "networkRouteTables": "rt-",
96 | "networkRouteTablesRoutes": "udr-",
97 | "networkTrafficManagerProfiles": "traf-",
98 | "networkVirtualNetworkGateways": "vgw-",
99 | "networkVirtualNetworks": "vnet-",
100 | "networkVirtualNetworksSubnets": "snet-",
101 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-",
102 | "networkVirtualWans": "vwan-",
103 | "networkVpnGateways": "vpng-",
104 | "networkVpnGatewaysVpnConnections": "vcn-",
105 | "networkVpnGatewaysVpnSites": "vst-",
106 | "notificationHubsNamespaces": "ntfns-",
107 | "notificationHubsNamespacesNotificationHubs": "ntf-",
108 | "operationalInsightsWorkspaces": "log-",
109 | "portalDashboards": "dash-",
110 | "powerBIDedicatedCapacities": "pbi-",
111 | "purviewAccounts": "pview-",
112 | "recoveryServicesVaults": "rsv-",
113 | "resourcesResourceGroups": "rg-",
114 | "searchSearchServices": "srch-",
115 | "serviceBusNamespaces": "sb-",
116 | "serviceBusNamespacesQueues": "sbq-",
117 | "serviceBusNamespacesTopics": "sbt-",
118 | "serviceEndPointPolicies": "se-",
119 | "serviceFabricClusters": "sf-",
120 | "signalRServiceSignalR": "sigr",
121 | "sqlManagedInstances": "sqlmi-",
122 | "sqlServers": "sql-",
123 | "sqlServersDataWarehouse": "sqldw-",
124 | "sqlServersDatabases": "sqldb-",
125 | "sqlServersDatabasesStretch": "sqlstrdb-",
126 | "storageStorageAccounts": "st",
127 | "storageStorageAccountsVm": "stvm",
128 | "storSimpleManagers": "ssimp",
129 | "streamAnalyticsCluster": "asa-",
130 | "synapseWorkspaces": "syn",
131 | "synapseWorkspacesAnalyticsWorkspaces": "synw",
132 | "synapseWorkspacesSqlPoolsDedicated": "syndp",
133 | "synapseWorkspacesSqlPoolsSpark": "synsp",
134 | "timeSeriesInsightsEnvironments": "tsi-",
135 | "webServerFarms": "plan-",
136 | "webSitesAppService": "app-",
137 | "webSitesAppServiceEnvironment": "ase-",
138 | "webSitesFunctions": "func-",
139 | "webStaticSites": "stapp-"
140 | }
--------------------------------------------------------------------------------
/infra/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | // Common configurations
4 | @description('Name of the environment')
5 | param environmentName string
6 | @description('Principal ID to grant access to the AI services. Leave empty to skip')
7 | param myPrincipalId string = ''
8 | @description('Current principal type being used')
9 | @allowed(['User', 'ServicePrincipal'])
10 | param myPrincipalType string
11 | @description('IP addresses to grant access to the AI services. Leave empty to skip')
12 | param allowedIpAddresses string = ''
13 | var allowedIpAddressesArray = !empty(allowedIpAddresses) ? split(allowedIpAddresses, ',') : []
14 | @description('Resource group name for the AI services. Defauts to rg-')
15 | param resourceGroupName string = ''
16 | @description('Resource group name for the DNS configurations. Defaults to rg-dns')
17 | param dnsResourceGroupName string = ''
18 | @description('Tags for all AI resources created. JSON object')
19 | param tags object = {}
20 |
21 | // Network configurations
22 | @description('Allow or deny public network access to the AI services (recommended: Disabled)')
23 | @allowed(['Enabled', 'Disabled'])
24 | param publicNetworkAccess string
25 | @description('Authentication type to use (recommended: identity)')
26 | @allowed(['identity', 'accessKey'])
27 | param authMode string = 'identity'
28 | @description('Address prefixes for the spoke vNet')
29 | param vnetAddressPrefixes array = ['10.0.0.0/16']
30 | @description('Address prefix for the private endpoint subnet')
31 | param privateEndpointSubnetAddressPrefix string = '10.0.0.0/24'
32 | @description('Address prefix for the application subnet')
33 | param appSubnetAddressPrefix string = '10.0.1.0/24'
34 |
35 | // AI Services configurations
36 | @description('Name of the AI Services account. Automatically generated if left blank')
37 | param aiServicesName string = ''
38 | @description('Name of the AI Hub resource. Automatically generated if left blank')
39 | param aiHubName string = ''
40 | @description('Name of the Storage Account. Automatically generated if left blank')
41 | param storageName string = ''
42 | @description('Name of the Key Vault. Automatically generated if left blank')
43 | param keyVaultName string = ''
44 | @description('Name of the Bing account. Automatically generated if left blank')
45 | param bingName string = ''
46 | @description('Name of the Bot Service. Automatically generated if left blank')
47 | param botName string = ''
48 |
49 | // Other configurations
50 | @description('Name of the Bot Service. Automatically generated if left blank')
51 | param msiName string = ''
52 | @description('Name of the Cosmos DB Account. Automatically generated if left blank')
53 | param cosmosName string = ''
54 | @description('Name of the App Service Plan. Automatically generated if left blank')
55 | param appPlanName string = ''
56 | @description('Name of the App Services Instance. Automatically generated if left blank')
57 | param appName string = ''
58 | @description('Whether to enable authentication (requires Entra App Developer role)')
59 | param enableAuthentication bool = false
60 | @description('Whether to deploy an AI Hub')
61 | param deployAIHub bool = true
62 | @description('Whether to deploy a sample AI Project')
63 | param deployAIProject bool = true
64 |
65 | @description('Gen AI model name and version to deploy')
66 | @allowed(['gpt-4;1106-Preview', 'gpt-4;0125-Preview', 'gpt-4o;2024-05-13', 'gpt-4o-mini;2024-07-18'])
67 | param model string
68 | @description('Tokens per minute capacity for the model. Units of 1000 (capacity = 10 means 10,000 tokens per minute)')
69 | param modelCapacity int
70 |
71 |
72 | // Location and overrides
73 | @description('Location to deploy AI Services')
74 | param aiServicesLocation string = deployment().location
75 | @description('Location to deploy Cosmos DB')
76 | param cosmosLocation string = deployment().location
77 | @description('Location to deploy App Service')
78 | param appServiceLocation string = deployment().location
79 | @description('Location to deploy Bot Service')
80 | param botServiceLocation string = 'global'
81 | @description('Location to deploy Storage account')
82 | param storageLocation string = deployment().location
83 | @description('Location to deploy Key Vault')
84 | param keyVaultLocation string = deployment().location
85 | @description('Location to deploy Managed Identity')
86 | param msiLocation string = deployment().location
87 | @description('Location to deploy virtual network resources')
88 | param vnetLocation string = deployment().location
89 | @description('Location to deploy private DNS zones')
90 | param dnsLocation string = deployment().location
91 |
92 | var modelName = split(model, ';')[0]
93 | var modelVersion = split(model, ';')[1]
94 |
95 | var abbrs = loadJsonContent('abbreviations.json')
96 | var uniqueSuffix = substring(uniqueString(subscription().id, environmentName), 1, 3)
97 | var location = deployment().location
98 |
99 | var names = {
100 | resourceGroup: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}'
101 | dnsResourceGroup: !empty(dnsResourceGroupName) ? dnsResourceGroupName : '${abbrs.resourcesResourceGroups}dns'
102 | msi: !empty(msiName) ? msiName : '${abbrs.managedIdentityUserAssignedIdentities}${environmentName}-${uniqueSuffix}'
103 | cosmos: !empty(cosmosName) ? cosmosName : '${abbrs.documentDBDatabaseAccounts}${environmentName}-${uniqueSuffix}'
104 | appPlan: !empty(appPlanName) ? appPlanName : '${abbrs.webSitesAppServiceEnvironment}${environmentName}-${uniqueSuffix}'
105 | app: !empty(appName) ? appName : '${abbrs.webSitesAppService}${environmentName}-${uniqueSuffix}'
106 | bot: !empty(botName) ? botName : '${abbrs.cognitiveServicesBot}${environmentName}-${uniqueSuffix}'
107 | vnet: '${abbrs.networkVirtualNetworks}${environmentName}-${uniqueSuffix}'
108 | privateLinkSubnet: '${abbrs.networkVirtualNetworksSubnets}${environmentName}-pl-${uniqueSuffix}'
109 | appSubnet: '${abbrs.networkVirtualNetworksSubnets}${environmentName}-app-${uniqueSuffix}'
110 | aiServices: !empty(aiServicesName) ? aiServicesName : '${abbrs.cognitiveServicesAccounts}${environmentName}-${uniqueSuffix}'
111 | aiHub: !empty(aiHubName) ? aiHubName : '${abbrs.cognitiveServicesAccounts}hub-${environmentName}-${uniqueSuffix}'
112 | storage: !empty(storageName) ? storageName : replace(replace('${abbrs.storageStorageAccounts}${environmentName}${uniqueSuffix}', '-', ''), '_', '')
113 | keyVault: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${environmentName}-${uniqueSuffix}'
114 | bing: !empty(bingName) ? bingName : '${abbrs.cognitiveServicesBing}${environmentName}-${uniqueSuffix}'
115 | }
116 |
117 | // Private Network Resources
118 | var dnsZones = [
119 | 'privatelink.openai.azure.com'
120 | 'privatelink.cognitiveservices.azure.com'
121 | 'privatelink.blob.${environment().suffixes.storage}'
122 | 'privatelink.vaultcore.azure.net'
123 | 'privatelink.search.azure.com'
124 | 'privatelink.documents.azure.com'
125 | 'privatelink.api.azureml.ms'
126 | 'privatelink.notebooks.azure.net'
127 | 'privatelink.azurewebsites.net'
128 | ]
129 |
130 | var dnsZoneIds = publicNetworkAccess == 'Disabled' ? m_dns.outputs.dnsZoneIds : dnsZones
131 | var privateEndpointSubnetId = publicNetworkAccess == 'Disabled' ? m_network.outputs.privateEndpointSubnetId : ''
132 |
133 | // Deploy two resource groups
134 | resource resourceGroup 'Microsoft.Resources/resourceGroups@2023-07-01' = {
135 | name: names.resourceGroup
136 | location: location
137 | tags: union(tags, { 'azd-env-name': environmentName })
138 | }
139 |
140 | resource dnsResourceGroup 'Microsoft.Resources/resourceGroups@2023-07-01' = if (publicNetworkAccess == 'Disabled') {
141 | name: names.dnsResourceGroup
142 | location: empty(dnsLocation) ? location : dnsLocation
143 | tags: tags
144 | }
145 |
146 | // Network module - deploys Vnet
147 | module m_network 'modules/aistudio/network.bicep' = if (publicNetworkAccess == 'Disabled') {
148 | name: 'deploy_vnet'
149 | scope: resourceGroup
150 | params: {
151 | location: empty(vnetLocation) ? location : vnetLocation
152 | vnetName: names.vnet
153 | vnetAddressPrefixes: vnetAddressPrefixes
154 | privateEndpointSubnetName: names.privateLinkSubnet
155 | privateEndpointSubnetAddressPrefix: privateEndpointSubnetAddressPrefix
156 | appSubnetName: names.appSubnet
157 | appSubnetAddressPrefix: appSubnetAddressPrefix
158 | }
159 | }
160 |
161 | // DNS module - deploys private DNS zones and links them to the Vnet
162 | module m_dns 'modules/aistudio/dns.bicep' = if (publicNetworkAccess == 'Disabled') {
163 | name: 'deploy_dns'
164 | scope: dnsResourceGroup
165 | params: {
166 | vnetId: publicNetworkAccess == 'Disabled' ? m_network.outputs.vnetId : ''
167 | vnetName: publicNetworkAccess == 'Disabled' ? m_network.outputs.vnetName : ''
168 | dnsZones: dnsZones
169 | }
170 | }
171 |
172 | module m_msi 'modules/msi.bicep' = {
173 | name: 'deploy_msi'
174 | scope: resourceGroup
175 | params: {
176 | location: empty(msiLocation) ? location : msiLocation
177 | msiName: names.msi
178 | tags: tags
179 | }
180 | }
181 |
182 | // AI Services module
183 | module m_aiservices 'modules/aistudio/aiservices.bicep' = {
184 | name: 'deploy_aiservices'
185 | scope: resourceGroup
186 | params: {
187 | location: empty(aiServicesLocation) ? location : aiServicesLocation
188 | vnetLocation: empty(vnetLocation) ? location : vnetLocation
189 | aiServicesName: names.aiServices
190 | publicNetworkAccess: publicNetworkAccess
191 | privateEndpointSubnetId: privateEndpointSubnetId
192 | openAIPrivateDnsZoneId: dnsZoneIds[0]
193 | cognitiveServicesPrivateDnsZoneId: dnsZoneIds[1]
194 | authMode: authMode
195 | grantAccessTo: authMode == 'identity'
196 | ? [
197 | {
198 | id: myPrincipalId
199 | type: myPrincipalType
200 | }
201 | {
202 | id: m_msi.outputs.msiPrincipalID
203 | type: 'ServicePrincipal'
204 | }
205 | ]
206 | : []
207 | allowedIpAddresses: allowedIpAddressesArray
208 | tags: tags
209 | }
210 | }
211 |
212 | // Storage and Key Vault
213 | module m_storage 'modules/aistudio/storage.bicep' = {
214 | name: 'deploy_storage'
215 | scope: resourceGroup
216 | params: {
217 | location: empty(storageLocation) ? location : storageLocation
218 | vnetLocation: empty(vnetLocation) ? location : vnetLocation
219 | storageName: names.storage
220 | publicNetworkAccess: publicNetworkAccess
221 | authMode: authMode
222 | privateEndpointSubnetId: privateEndpointSubnetId
223 | privateDnsZoneId: dnsZoneIds[2]
224 | grantAccessTo: authMode == 'identity'
225 | ? [
226 | {
227 | id: myPrincipalId
228 | type: myPrincipalType
229 | }
230 | {
231 | id: m_msi.outputs.msiPrincipalID
232 | type: 'ServicePrincipal'
233 | }
234 | ]
235 | : []
236 | tags: tags
237 | }
238 | }
239 |
240 | module m_keyVault 'modules/aistudio/keyVault.bicep' = {
241 | name: 'deploy_keyVault'
242 | scope: resourceGroup
243 | params: {
244 | location: empty(keyVaultLocation) ? location : keyVaultLocation
245 | vnetLocation: empty(vnetLocation) ? location : vnetLocation
246 | keyVaultName: names.keyVault
247 | publicNetworkAccess: publicNetworkAccess
248 | privateEndpointSubnetId: privateEndpointSubnetId
249 | privateDnsZoneId: dnsZoneIds[3]
250 | allowedIpAddresses: allowedIpAddressesArray
251 | grantAccessTo: [
252 | {
253 | id: myPrincipalId
254 | type: myPrincipalType
255 | }
256 | {
257 | id: m_msi.outputs.msiPrincipalID
258 | type: 'ServicePrincipal'
259 | }
260 | ]
261 | tags: tags
262 | }
263 | }
264 |
265 | // AI Hub module - deploys AI Hub and Project
266 | module m_aihub 'modules/aistudio/aihub.bicep' = if (deployAIHub) {
267 | name: 'deploy_ai'
268 | scope: resourceGroup
269 | params: {
270 | location: empty(aiServicesLocation) ? location : aiServicesLocation
271 | vnetLocation: empty(vnetLocation) ? location : vnetLocation
272 | aiHubName: names.aiHub
273 | aiProjectName: 'cog-ai-prj-${environmentName}-${uniqueSuffix}'
274 | aiServicesName: m_aiservices.outputs.aiServicesName
275 | keyVaultName: m_keyVault.outputs.keyVaultName
276 | storageName: names.storage
277 | publicNetworkAccess: publicNetworkAccess
278 | systemDatastoresAuthMode: authMode
279 | privateEndpointSubnetId: privateEndpointSubnetId
280 | apiPrivateDnsZoneId: dnsZoneIds[6]
281 | notebookPrivateDnsZoneId: dnsZoneIds[7]
282 | deployAIProject: deployAIProject
283 | allowedIpAddresses: allowedIpAddressesArray
284 | grantAccessTo: authMode == 'identity'
285 | ? [
286 | {
287 | id: myPrincipalId
288 | type: myPrincipalType
289 | }
290 | {
291 | id: m_msi.outputs.msiPrincipalID
292 | type: 'ServicePrincipal'
293 | }
294 | ]
295 | : []
296 | tags: tags
297 | }
298 | }
299 |
300 | // Bing module
301 | module m_bing 'modules/bing.bicep' = {
302 | name: 'deploy_bing'
303 | scope: resourceGroup
304 | params: {
305 | bingName: names.bing
306 | keyVaultName: m_keyVault.outputs.keyVaultName
307 | tags: tags
308 | }
309 | }
310 |
311 | module m_cosmos 'modules/cosmos.bicep' = {
312 | name: 'deploy_cosmos'
313 | scope: resourceGroup
314 | params: {
315 | location: empty(cosmosLocation) ? location : cosmosLocation
316 | vnetLocation: empty(vnetLocation) ? location : vnetLocation
317 | cosmosName: names.cosmos
318 | keyVaultName: m_keyVault.outputs.keyVaultName
319 | publicNetworkAccess: publicNetworkAccess
320 | privateEndpointSubnetId: privateEndpointSubnetId
321 | privateDnsZoneId: dnsZoneIds[5]
322 | allowedIpAddresses: allowedIpAddressesArray
323 | authMode: authMode
324 | grantAccessTo: authMode == 'identity'
325 | ? [
326 | {
327 | id: myPrincipalId
328 | type: myPrincipalType
329 | }
330 | {
331 | id: m_msi.outputs.msiPrincipalID
332 | type: 'ServicePrincipal'
333 | }
334 | ]
335 | : []
336 | tags: tags
337 | }
338 | }
339 |
340 | module m_gpt 'modules/gptDeployment.bicep' = {
341 | name: 'deploygpt'
342 | scope: resourceGroup
343 | params: {
344 | aiServicesName: m_aiservices.outputs.aiServicesName
345 | modelName: modelName
346 | modelVersion: modelVersion
347 | modelCapacity: modelCapacity
348 | }
349 | }
350 |
351 | module m_app 'modules/appservice.bicep' = {
352 | name: 'deploy_app'
353 | scope: resourceGroup
354 | params: {
355 | location: empty(appServiceLocation) ? location : appServiceLocation
356 | vnetLocation: empty(vnetLocation) ? location : vnetLocation
357 | appServicePlanName: names.appPlan
358 | appServiceName: names.app
359 | tags: tags
360 | publicNetworkAccess: publicNetworkAccess
361 | privateEndpointSubnetId: privateEndpointSubnetId
362 | privateDnsZoneId: dnsZoneIds[8]
363 | authMode: authMode
364 | appSubnetId: publicNetworkAccess == 'Disabled' ? m_network.outputs.appSubnetId : ''
365 | allowedIpAddresses: allowedIpAddressesArray
366 | msiID: m_msi.outputs.msiID
367 | msiClientID: m_msi.outputs.msiClientID
368 | cosmosName: m_cosmos.outputs.cosmosName
369 | deploymentName: m_gpt.outputs.modelName
370 | aiServicesName: m_aiservices.outputs.aiServicesName
371 | bingName: m_bing.outputs.bingName
372 | aiHubName: m_aihub.outputs.aiHubName
373 | aiProjectName: m_aihub.outputs.aiProjectName
374 | keyVaultName: m_keyVault.outputs.keyVaultName
375 | }
376 | }
377 |
378 | module m_bot 'modules/botservice.bicep' = {
379 | name: 'deploy_bot'
380 | scope: resourceGroup
381 | params: {
382 | location: empty(botServiceLocation) ? location : botServiceLocation
383 | botServiceName: names.bot
384 | keyVaultName: m_keyVault.outputs.keyVaultName
385 | tags: tags
386 | endpoint: 'https://${m_app.outputs.backendHostName}/api/messages'
387 | msiClientID: m_msi.outputs.msiClientID
388 | msiID: m_msi.outputs.msiID
389 | publicNetworkAccess: publicNetworkAccess
390 | }
391 | }
392 |
393 | output AZURE_TENANT_ID string = tenant().tenantId
394 | output AZURE_RESOURCE_GROUP_ID string = resourceGroup.id
395 | output AZURE_RESOURCE_GROUP_NAME string = resourceGroup.name
396 | output AI_SERVICES_ENDPOINT string = m_aiservices.outputs.aiServicesEndpoint
397 | output BACKEND_APP_NAME string = m_app.outputs.backendAppName
398 | output BACKEND_APP_HOSTNAME string = m_app.outputs.backendHostName
399 | output MSI_PRINCIPAL_ID string = m_msi.outputs.msiPrincipalID
400 | output ENABLE_AUTH bool = enableAuthentication
401 | output AUTH_MODE string = authMode
402 |
403 | output AZURE_COSMOSDB_ENDPOINT string = m_cosmos.outputs.cosmosEndpoint
404 | output AZURE_KEY_VAULT_ENDPOINT string = m_keyVault.outputs.keyVaultEndpoint
405 | output AZURE_OPENAI_API_ENDPOINT string = m_aiservices.outputs.aiServicesEndpoint
406 | output AZURE_OPENAI_API_VERSION string = '2024-07-01-preview'
407 | output AZURE_OPENAI_ASSISTANT_NAME string = 'azure-agents-python'
408 | output AZURE_OPENAI_DEPLOYMENT_NAME string = m_gpt.outputs.modelName
409 | output AZURE_OPENAI_STREAMING bool = false
410 | output AZURE_AI_PROJECT_CONNECTION_STRING string = m_aihub.outputs.aiProjectConnectionString
411 | output AZURE_BING_CONNECTION_ID string = m_bing.outputs.bingName
412 | output LLM_INSTRUCTIONS string = 'Welcome to the Travel Agent Sample! You can use this chat to:
- Get recommendations of places to visit and things to do;
- Upload your travel bookings and generate an itinerary;
- Upload pictures of signs, menus and more to get information about them;
- Ask for help with budgeting a trip;
- And more!
To upload files, use the attachment button on the left, attach a file and hit enter to upload. You will get confirmation when the file is ready to use. You will also be prompted whether you\'d like to add it to Code Interpreter or File Search. Use Code Interpreter for mathematical operations, and File Search to use the file contents as context for your question. You may skip this step for images.'
413 | output LLM_WELCOME_MESSAGE string = 'You are a helpful travel agent who can assist with many travel-related inquiries, including:\n- Reading travel documents and putting together itineraries\n- Viewing and interpreting pictures that may be in different languages\n- Locating landmarks and suggesting places to visit\n- Budgeting and graphing cost information\n- Looking up information on the web for up to date information\nYou should do your best to respond to travel-related questions, but politely decline to help with unrelated questions.\nAny time the information you want to provide requires up-to-date sources - for example, hotels, restaurants and more - you should use the Bing Search tool and provide sources.'
414 | output MAX_TURNS int = 20
415 |
--------------------------------------------------------------------------------
/infra/main.parameters.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
3 | "contentVersion": "1.0.0.0",
4 | "parameters": {
5 | "environmentName": {
6 | "value": "${AZURE_ENV_NAME}"
7 | },
8 | "location": {
9 | "value": "${AZURE_LOCATION}"
10 | },
11 | "resourceGroupName": {
12 | "value": "rg-${AZURE_ENV_NAME}"
13 | },
14 | "dnsResourceGroupName": {
15 | "value": "rg-dns-${AZURE_ENV_NAME}"
16 | },
17 | "aiServicesName": {
18 | "value": "${AZURE_AISERVICES_NAME}"
19 | },
20 | "aiHubName": {
21 | "value": "${AZURE_AIHUB_NAME}"
22 | },
23 | "storageName": {
24 | "value": "${AZURE_STORAGE_NAME}"
25 | },
26 | "keyVaultName": {
27 | "value": "${AZURE_KEYVAULT_NAME}"
28 | },
29 | "bingName": {
30 | "value": "${AZURE_BING_NAME}"
31 | },
32 | "botName": {
33 | "value": "${AZURE_BOT_NAME}"
34 | },
35 | "msiName": {
36 | "value": "${AZURE_MSI_NAME}"
37 | },
38 | "cosmosName": {
39 | "value": "${AZURE_COSMOS_NAME}"
40 | },
41 | "appPlanName": {
42 | "value": "${AZURE_APPPLAN_NAME}"
43 | },
44 | "appName": {
45 | "value": "${AZURE_APP_NAME}"
46 | },
47 | "allowedIpAddresses": {
48 | "value": "${ALLOWED_IP_ADDRESSES}"
49 | },
50 | "myPrincipalType": {
51 | "value": "${AZURE_PRINCIPAL_TYPE}"
52 | },
53 | "myPrincipalId": {
54 | "value": "${AZURE_PRINCIPAL_ID}"
55 | },
56 | "aiServicesLocation": {
57 | "value": "${AZURE_AISERVICES_LOCATION}"
58 | },
59 | "cosmosLocation": {
60 | "value": "${AZURE_COSMOSDB_LOCATION}"
61 | },
62 | "appServiceLocation": {
63 | "value": "${AZURE_APPSERVICE_LOCATION}"
64 | },
65 | "storageLocation": {
66 | "value": "${AZURE_STORAGE_LOCATION}"
67 | },
68 | "keyVaultLocation": {
69 | "value": "${AZURE_KEYVAULT_LOCATION}"
70 | },
71 | "msiLocation": {
72 | "value": "${AZURE_MSI_LOCATION}"
73 | },
74 | "vnetLocation": {
75 | "value": "${AZURE_VNET_LOCATION}"
76 | },
77 | "dnsLocation": {
78 | "value": "${AZURE_APPSERVICE_LOCATION}"
79 | },
80 | "tags": {
81 | "value": {
82 | "Owner": "AI Team",
83 | "Project": "GPTBot",
84 | "Environment": "Dev",
85 | "Toolkit": "Bicep"
86 | }
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/aihub.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetLocation string = location
3 |
4 | // Dependencies
5 | param aiServicesName string
6 | param storageName string
7 | param keyVaultName string
8 | param searchName string = ''
9 |
10 | // Azure AI configuration
11 | param aiHubName string
12 | param aiProjectName string
13 |
14 | // Other
15 | param tags object = {}
16 | param publicNetworkAccess string
17 | param systemDatastoresAuthMode string
18 | param privateEndpointSubnetId string
19 | param apiPrivateDnsZoneId string
20 | param notebookPrivateDnsZoneId string
21 | param grantAccessTo array
22 | param allowedIpAddresses array = []
23 | param defaultComputeName string = ''
24 | param deployAIProject bool
25 |
26 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = {
27 | name: aiServicesName
28 | }
29 | resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
30 | name: storageName
31 | }
32 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
33 | name: keyVaultName
34 | }
35 | resource search 'Microsoft.Search/searchServices@2023-11-01' existing = {
36 | name: searchName
37 | }
38 |
39 | resource aiHub 'Microsoft.MachineLearningServices/workspaces@2024-04-01-preview' = {
40 | name: aiHubName
41 | location: location
42 | tags: tags
43 | identity: {
44 | type: 'SystemAssigned'
45 | }
46 | properties: {
47 | publicNetworkAccess: !empty(allowedIpAddresses) ? 'Enabled' : publicNetworkAccess
48 | ipAllowlist: allowedIpAddresses
49 | managedNetwork: {
50 | isolationMode: publicNetworkAccess == 'Disabled' ? 'AllowOnlyApprovedOutbound' : 'Disabled'
51 | outboundRules: publicNetworkAccess == 'Disabled' && !empty(search.name)
52 | ? {
53 | 'rule-${search.name}': {
54 | type: 'PrivateEndpoint'
55 | destination: {
56 | serviceResourceId: search.id
57 | subresourceTarget: 'searchService'
58 | }
59 | }
60 | }
61 | : {}
62 | }
63 | friendlyName: aiHubName
64 | keyVault: keyVault.id
65 | storageAccount: storage.id
66 | systemDatastoresAuthMode: systemDatastoresAuthMode
67 | }
68 | kind: 'hub'
69 |
70 | resource aiServicesConnection 'connections@2024-04-01' = {
71 | name: '${aiHubName}-connection-AIServices'
72 | properties: {
73 | category: 'AIServices'
74 | target: aiServices.properties.endpoint
75 | authType: 'AAD'
76 | isSharedToAll: true
77 | metadata: {
78 | ApiType: 'Azure'
79 | ResourceId: aiServices.id
80 | ApiVersion: '2023-07-01-preview'
81 | DeploymentApiVersion: '2023-10-01-preview'
82 | Location: location
83 | }
84 | }
85 | }
86 |
87 | resource searchConnection 'connections@2024-04-01' = if (!empty(searchName)) {
88 | name: '${aiHubName}-connection-Search'
89 | properties: {
90 | category: 'CognitiveSearch'
91 | target: 'https://${search.name}.search.windows.net'
92 | authType: 'AAD'
93 | isSharedToAll: true
94 | }
95 | }
96 |
97 | resource defaultCompute 'computes@2024-04-01-preview' = if (!empty(defaultComputeName)) {
98 | name: defaultComputeName
99 | location: location
100 | tags: tags
101 | properties: {
102 | computeType: 'ComputeInstance'
103 | properties: {
104 | vmSize: 'Standard_DS11_v2'
105 | }
106 | }
107 | }
108 | }
109 |
110 | resource aiProject 'Microsoft.MachineLearningServices/workspaces@2024-04-01-preview' = if (deployAIProject) {
111 | name: aiProjectName
112 | location: location
113 | tags: tags
114 | identity: {
115 | type: 'SystemAssigned'
116 | }
117 | properties: {
118 | publicNetworkAccess: publicNetworkAccess
119 | hubResourceId: aiHub.id
120 | }
121 | kind: 'Project'
122 | }
123 |
124 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
125 | name: 'pl-${aiHubName}'
126 | location: vnetLocation
127 | tags: tags
128 | properties: {
129 | subnet: {
130 | id: privateEndpointSubnetId
131 | }
132 | privateLinkServiceConnections: [
133 | {
134 | name: 'private-endpoint-connection'
135 | properties: {
136 | privateLinkServiceId: aiHub.id
137 | groupIds: ['amlworkspace']
138 | }
139 | }
140 | ]
141 | }
142 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
143 | name: 'zg-${aiHubName}'
144 | properties: {
145 | privateDnsZoneConfigs: [
146 | {
147 | name: 'api'
148 | properties: {
149 | privateDnsZoneId: apiPrivateDnsZoneId
150 | }
151 | }
152 | {
153 | name: 'notebook'
154 | properties: {
155 | privateDnsZoneId: notebookPrivateDnsZoneId
156 | }
157 | }
158 | ]
159 | }
160 | }
161 | }
162 |
163 | resource aiDeveloper 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
164 | name: '64702f94-c441-49e6-a78b-ef80e0188fee'
165 | }
166 |
167 | resource aiDeveloperAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
168 | for principal in grantAccessTo: if (!empty(principal.id)) {
169 | name: guid(principal.id, aiHub.id, aiDeveloper.id)
170 | scope: aiHub
171 | properties: {
172 | roleDefinitionId: aiDeveloper.id
173 | principalId: principal.id
174 | principalType: principal.type
175 | }
176 | }
177 | ]
178 |
179 | resource aiDeveloperAccessProj 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
180 | for principal in grantAccessTo: if (!empty(principal.id)) {
181 | name: guid(principal.id, aiProject.id, aiDeveloper.id)
182 | scope: aiProject
183 | properties: {
184 | roleDefinitionId: aiDeveloper.id
185 | principalId: principal.id
186 | principalType: principal.type
187 | }
188 | }
189 | ]
190 |
191 | resource cognitiveServicesOpenAIContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
192 | name: 'a001fd3d-188f-4b5d-821b-7da978bf7442'
193 | }
194 |
195 | resource openaiAccessFromProject 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
196 | name: guid(aiProject.id, aiServices.id, cognitiveServicesOpenAIContributor.id)
197 | scope: aiServices
198 | properties: {
199 | roleDefinitionId: cognitiveServicesOpenAIContributor.id
200 | principalId: aiProject.identity.principalId
201 | principalType: 'ServicePrincipal'
202 | }
203 | }
204 |
205 |
206 | output aiHubID string = aiHub.id
207 | output aiHubName string = aiHub.name
208 | output aiProjectID string = aiProject.id
209 | output aiProjectName string = aiProject.name
210 | output aiProjectDiscoveryUrl string = aiProject.properties.discoveryUrl
211 | output aiProjectConnectionString string = '${split(aiProject.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${aiProject.name}'
212 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/aiservices.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetLocation string = location
3 | param aiServicesName string
4 | param tags object = {}
5 | param privateEndpointSubnetId string
6 | param publicNetworkAccess string
7 | param openAIPrivateDnsZoneId string
8 | param cognitiveServicesPrivateDnsZoneId string
9 | param grantAccessTo array
10 | param allowedIpAddresses array = []
11 | param authMode string
12 |
13 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' = {
14 | name: aiServicesName
15 | location: location
16 | sku: {
17 | name: 'S0'
18 | }
19 | identity: {
20 | type: 'SystemAssigned'
21 | }
22 | kind: 'AIServices'
23 | properties: {
24 | disableLocalAuth: authMode == 'accessKey' ? false : true
25 | customSubDomainName: aiServicesName
26 | publicNetworkAccess: !empty(allowedIpAddresses) ? 'Enabled' : publicNetworkAccess
27 | networkAcls: {
28 | defaultAction: publicNetworkAccess == 'Enabled' ? 'Allow' : 'Deny'
29 | ipRules: [
30 | for ipAddress in allowedIpAddresses: {
31 | value: ipAddress
32 | }
33 | ]
34 | }
35 | }
36 | tags: tags
37 | }
38 |
39 | resource aiPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
40 | name: 'pl-oai-${aiServicesName}'
41 | location: vnetLocation
42 | tags: tags
43 | properties: {
44 | subnet: {
45 | id: privateEndpointSubnetId
46 | }
47 | privateLinkServiceConnections: [
48 | {
49 | name: 'private-endpoint-connection'
50 | properties: {
51 | privateLinkServiceId: aiServices.id
52 | groupIds: ['account']
53 | }
54 | }
55 | ]
56 | }
57 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
58 | name: 'zg-${aiServicesName}'
59 | properties: {
60 | privateDnsZoneConfigs: [
61 | {
62 | name: 'default'
63 | properties: {
64 | privateDnsZoneId: openAIPrivateDnsZoneId
65 | }
66 | }
67 | ]
68 | }
69 | }
70 | }
71 |
72 | resource cognitiveServicesPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
73 | name: 'pl-${aiServicesName}'
74 | location: location
75 | tags: tags
76 | properties: {
77 | subnet: {
78 | id: privateEndpointSubnetId
79 | }
80 | privateLinkServiceConnections: [
81 | {
82 | name: 'private-endpoint-connection'
83 | properties: {
84 | privateLinkServiceId: aiServices.id
85 | groupIds: ['account']
86 | }
87 | }
88 | ]
89 | }
90 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
91 | name: 'zg-${aiServicesName}'
92 | properties: {
93 | privateDnsZoneConfigs: [
94 | {
95 | name: 'default'
96 | properties: {
97 | privateDnsZoneId: cognitiveServicesPrivateDnsZoneId
98 | }
99 | }
100 | ]
101 | }
102 | }
103 | }
104 |
105 | resource cognitiveServicesOpenAIContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
106 | name: 'a001fd3d-188f-4b5d-821b-7da978bf7442'
107 | }
108 |
109 | resource cognitiveServicesUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
110 | name: 'a97b65f3-24c7-4388-baec-2e87135dc908'
111 | }
112 |
113 | resource openaiAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
114 | for principal in grantAccessTo: if (!empty(principal.id)) {
115 | name: guid(principal.id, aiServices.id, cognitiveServicesOpenAIContributor.id)
116 | scope: aiServices
117 | properties: {
118 | roleDefinitionId: cognitiveServicesOpenAIContributor.id
119 | principalId: principal.id
120 | principalType: principal.type
121 | }
122 | }
123 | ]
124 |
125 | resource userAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
126 | for principal in grantAccessTo: if (!empty(principal.id)) {
127 | name: guid(principal.id, aiServices.id, cognitiveServicesUser.id)
128 | scope: aiServices
129 | properties: {
130 | roleDefinitionId: cognitiveServicesUser.id
131 | principalId: principal.id
132 | principalType: principal.type
133 | }
134 | }
135 | ]
136 |
137 | output aiServicesID string = aiServices.id
138 | output aiServicesName string = aiServices.name
139 | output aiServicesEndpoint string = aiServices.properties.endpoint
140 | output aiServicesPrincipalId string = aiServices.identity.principalId
141 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/dns.bicep:
--------------------------------------------------------------------------------
1 | param vnetName string
2 | param vnetId string
3 | param tags object = {}
4 |
5 | param dnsZones array
6 |
7 | resource privateDnsZones 'Microsoft.Network/privateDnsZones@2020-06-01' = [
8 | for zone in dnsZones: {
9 | name: zone
10 | location: 'global'
11 | tags: tags
12 | properties: {}
13 | }
14 | ]
15 |
16 | resource virtualNetworkLinks 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = [
17 | for zone in dnsZones: {
18 | name: '${zone}/${vnetName}-link'
19 | location: 'global'
20 | tags: tags
21 | properties: {
22 | registrationEnabled: false
23 | virtualNetwork: {
24 | id: vnetId
25 | }
26 | }
27 | dependsOn: [
28 | privateDnsZones
29 | ]
30 | }
31 | ]
32 |
33 | output dnsZoneNames string[] = [for zone in dnsZones: zone]
34 | output dnsZoneIds string[] = [for (zone, index) in dnsZones: privateDnsZones[index].id]
35 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/keyVault.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetLocation string = location
3 | param keyVaultName string
4 | param tags object = {}
5 | param publicNetworkAccess string
6 | param privateEndpointSubnetId string
7 | param privateDnsZoneId string
8 | param grantAccessTo array
9 | param allowedIpAddresses array = []
10 |
11 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
12 | name: keyVaultName
13 | location: location
14 | tags: tags
15 | properties: {
16 | createMode: 'default'
17 | enabledForDeployment: false
18 | enabledForDiskEncryption: false
19 | enabledForTemplateDeployment: false
20 | enableSoftDelete: true
21 | enableRbacAuthorization: true
22 | publicNetworkAccess: empty(allowedIpAddresses) ? publicNetworkAccess : 'Enabled'
23 | networkAcls: {
24 | bypass: 'AzureServices'
25 | defaultAction: publicNetworkAccess == 'Enabled' ? 'Allow' : 'Deny'
26 | virtualNetworkRules: []
27 | ipRules: [
28 | for ipAddress in allowedIpAddresses: {
29 | value: ipAddress
30 | }
31 | ]
32 | }
33 | sku: {
34 | family: 'A'
35 | name: 'standard'
36 | }
37 | tenantId: subscription().tenantId
38 | }
39 | }
40 |
41 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
42 | name: 'pl-${keyVaultName}'
43 | location: vnetLocation
44 | tags: tags
45 | properties: {
46 | subnet: {
47 | id: privateEndpointSubnetId
48 | }
49 | privateLinkServiceConnections: [
50 | {
51 | name: 'private-endpoint-connection'
52 | properties: {
53 | privateLinkServiceId: keyVault.id
54 | groupIds: ['vault']
55 | }
56 | }
57 | ]
58 | }
59 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
60 | name: 'zg-${keyVaultName}'
61 | properties: {
62 | privateDnsZoneConfigs: [
63 | {
64 | name: 'default'
65 | properties: {
66 | privateDnsZoneId: privateDnsZoneId
67 | }
68 | }
69 | ]
70 | }
71 | }
72 | }
73 |
74 | resource secretsUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
75 | name: '4633458b-17de-408a-b874-0445c86b69e6'
76 | }
77 |
78 | resource secretsUserAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
79 | for principal in grantAccessTo: if (!empty(principal.id)) {
80 | name: guid(principal.id, keyVault.id, secretsUser.id)
81 | scope: keyVault
82 | properties: {
83 | roleDefinitionId: secretsUser.id
84 | principalId: principal.id
85 | principalType: principal.type
86 | }
87 | }
88 | ]
89 |
90 |
91 | output keyVaultID string = keyVault.id
92 | output keyVaultName string = keyVault.name
93 | output keyVaultEndpoint string = keyVault.properties.vaultUri
94 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/network.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetName string
3 | param vnetAddressPrefixes array
4 | param privateEndpointSubnetName string
5 | param privateEndpointSubnetAddressPrefix string
6 | param appSubnetName string
7 | param appSubnetAddressPrefix string
8 | param tags object = {}
9 |
10 | resource vnet 'Microsoft.Network/virtualNetworks@2020-11-01' = {
11 | name: vnetName
12 | location: location
13 | tags: tags
14 | properties: {
15 | addressSpace: {
16 | addressPrefixes: vnetAddressPrefixes
17 | }
18 | subnets: [
19 | {
20 | name: privateEndpointSubnetName
21 | properties: {
22 | addressPrefix: privateEndpointSubnetAddressPrefix
23 | }
24 | }
25 | {
26 | name: appSubnetName
27 | properties: {
28 | addressPrefix: appSubnetAddressPrefix
29 | delegations: [
30 | {
31 | name: 'default'
32 | properties: {
33 | serviceName: 'Microsoft.Web/serverFarms'
34 | }
35 | }
36 | ]
37 | }
38 | }
39 | ]
40 | }
41 | }
42 |
43 | output vnetId string = vnet.id
44 | output vnetName string = vnet.name
45 | output privateEndpointSubnetId string = vnet.properties.subnets[0].id
46 | output appSubnetId string = vnet.properties.subnets[1].id
47 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/searchService.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetLocation string = location
3 | param searchName string
4 | param tags object = {}
5 | param publicNetworkAccess string
6 | param privateEndpointSubnetId string
7 | param privateDnsZoneId string
8 | param allowedIpAddresses array = []
9 |
10 | resource search 'Microsoft.Search/searchServices@2024-06-01-preview' = {
11 | name: searchName
12 | location: location
13 | tags: tags
14 | sku: {
15 | name: 'standard'
16 | }
17 | identity: {
18 | type: 'SystemAssigned'
19 | }
20 | properties: {
21 | networkRuleSet: {
22 | bypass: 'AzureServices'
23 | ipRules: [
24 | for ipAddress in allowedIpAddresses: {
25 | value: ipAddress
26 | }
27 | ]
28 | }
29 | disableLocalAuth: true
30 | replicaCount: 1
31 | partitionCount: 1
32 | hostingMode: 'default'
33 | publicNetworkAccess: publicNetworkAccess
34 | }
35 | }
36 |
37 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
38 | name: 'pl-${searchName}'
39 | location: vnetLocation
40 | tags: tags
41 | properties: {
42 | subnet: {
43 | id: privateEndpointSubnetId
44 | }
45 | privateLinkServiceConnections: [
46 | {
47 | name: 'private-endpoint-connection'
48 | properties: {
49 | privateLinkServiceId: search.id
50 | groupIds: ['searchService']
51 | }
52 | }
53 | ]
54 | }
55 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
56 | name: 'zg-${searchName}'
57 | properties: {
58 | privateDnsZoneConfigs: [
59 | {
60 | name: 'default'
61 | properties: {
62 | privateDnsZoneId: privateDnsZoneId
63 | }
64 | }
65 | ]
66 | }
67 | }
68 | }
69 |
70 | output searchID string = search.id
71 | output searchPrincipalId string = search.identity.principalId
72 | output searchName string = search.name
73 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/sharedPrivateLinks.bicep:
--------------------------------------------------------------------------------
1 | param searchName string
2 |
3 | param storageName string
4 | param aiServicesName string
5 | param grantAccessTo array
6 |
7 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = {
8 | name: aiServicesName
9 | }
10 |
11 | resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
12 | name: storageName
13 | }
14 |
15 | resource search 'Microsoft.Search/searchServices@2024-06-01-preview' existing = {
16 | name: searchName
17 |
18 | resource linkToStorage 'sharedPrivateLinkResources' = {
19 | name: 'link-to-storage-account'
20 | properties: {
21 | groupId: 'blob'
22 | privateLinkResourceId: storage.id
23 | requestMessage: 'Requested Private Endpoint Connection from Search Service ${searchName}'
24 | }
25 | }
26 | resource linkToAI 'sharedPrivateLinkResources' = {
27 | name: 'link-to-ai-service'
28 | properties: {
29 | groupId: 'openai_account'
30 | privateLinkResourceId: aiServices.id
31 | requestMessage: 'Requested Private Endpoint Connection from Search Service ${searchName}'
32 | }
33 | dependsOn: [
34 | linkToStorage
35 | ]
36 | }
37 | }
38 |
39 | resource searchServiceContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
40 | name: '7ca78c08-252a-4471-8644-bb5ff32d4ba0'
41 | }
42 |
43 | resource searchIndexDataContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
44 | name: '8ebe5a00-799e-43f5-93ac-243d3dce84a7'
45 | }
46 |
47 | resource serviceAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
48 | for principal in grantAccessTo: if (!empty(principal.id)) {
49 | name: guid(principal.id, search.id, searchServiceContributor.id)
50 | scope: search
51 | properties: {
52 | roleDefinitionId: searchServiceContributor.id
53 | principalId: principal.id
54 | principalType: principal.type
55 | }
56 | }
57 | ]
58 |
59 | resource indexAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
60 | for principal in grantAccessTo: if (!empty(principal.id)) {
61 | name: guid(principal.id, search.id, searchIndexDataContributor.id)
62 | scope: search
63 | properties: {
64 | roleDefinitionId: searchIndexDataContributor.id
65 | principalId: principal.id
66 | principalType: principal.type
67 | }
68 | }
69 | ]
70 |
--------------------------------------------------------------------------------
/infra/modules/aistudio/storage.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetLocation string = location
3 | param storageName string
4 | param tags object = {}
5 | param publicNetworkAccess string
6 | param authMode string
7 | param privateEndpointSubnetId string
8 | param privateDnsZoneId string
9 | param grantAccessTo array = []
10 |
11 | resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
12 | name: storageName
13 | location: location
14 | tags: tags
15 | sku: {
16 | name: 'Standard_LRS'
17 | }
18 | kind: 'StorageV2'
19 | properties: {
20 | isLocalUserEnabled: authMode == 'accessKey'
21 | allowSharedKeyAccess: authMode == 'accessKey'
22 | accessTier: 'Hot'
23 | encryption: {
24 | keySource: 'Microsoft.Storage'
25 | services: {
26 | blob: {
27 | enabled: true
28 | keyType: 'Account'
29 | }
30 | file: {
31 | enabled: true
32 | keyType: 'Account'
33 | }
34 | }
35 | }
36 | minimumTlsVersion: 'TLS1_2'
37 | networkAcls: {
38 | bypass: 'AzureServices'
39 | defaultAction: publicNetworkAccess == 'Enabled' ? 'Allow' : 'Deny'
40 | }
41 | supportsHttpsTrafficOnly: true
42 | }
43 | }
44 |
45 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
46 | name: 'pl-${storageName}'
47 | location: vnetLocation
48 | tags: tags
49 | properties: {
50 | subnet: {
51 | id: privateEndpointSubnetId
52 | }
53 | privateLinkServiceConnections: [
54 | {
55 | name: 'private-endpoint-connection'
56 | properties: {
57 | privateLinkServiceId: storage.id
58 | groupIds: ['blob']
59 | }
60 | }
61 | ]
62 | }
63 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
64 | name: 'zg-${storageName}'
65 | properties: {
66 | privateDnsZoneConfigs: [
67 | {
68 | name: 'default'
69 | properties: {
70 | privateDnsZoneId: privateDnsZoneId
71 | }
72 | }
73 | ]
74 | }
75 | }
76 | }
77 | resource storageBlobDataContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
78 | name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
79 | }
80 |
81 | resource writerAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
82 | for principal in grantAccessTo: if (!empty(principal.id)) {
83 | name: guid(principal.id, storage.id, storageBlobDataContributor.id)
84 | scope: storage
85 | properties: {
86 | roleDefinitionId: storageBlobDataContributor.id
87 | principalId: principal.id
88 | principalType: principal.type
89 | }
90 | }
91 | ]
92 |
93 | output storageID string = storage.id
94 | output storageName string = storage.name
95 |
--------------------------------------------------------------------------------
/infra/modules/appinsights.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param logAnalyticsWorkspaceName string
3 | param appInsightsName string
4 | param sku string = 'PerGB2018'
5 | param kind string = 'web'
6 | param tags object = {}
7 | param publicNetworkAccess string
8 | param authMode string
9 |
10 |
11 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' = {
12 | name: logAnalyticsWorkspaceName
13 | location: location
14 | properties: {
15 | sku: {
16 | name: sku
17 | }
18 | retentionInDays: 30
19 | publicNetworkAccessForIngestion: publicNetworkAccess
20 | publicNetworkAccessForQuery: publicNetworkAccess
21 | }
22 | }
23 |
24 | resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
25 | name: appInsightsName
26 | location: location
27 | tags: tags
28 | kind: kind
29 | properties: {
30 | DisableLocalAuth: authMode == 'accessKey' ? false : true
31 | Application_Type: kind
32 | WorkspaceResourceId: logAnalyticsWorkspace.id
33 | Flow_Type: 'Bluefield'
34 | }
35 | }
36 |
37 | output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id
38 | output appInsightsId string = appInsights.id
39 | output appInsightsInstrumentationKey string = appInsights.properties.InstrumentationKey
40 |
--------------------------------------------------------------------------------
/infra/modules/appservice.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetLocation string = location
3 | param appServicePlanName string
4 | param appServiceName string
5 | param msiID string
6 | param msiClientID string
7 | param sku string = 'P0v3'
8 | param tags object = {}
9 | param deploymentName string
10 |
11 | param aiServicesName string
12 | param bingName string
13 | param cosmosName string
14 | param aiHubName string
15 | param aiProjectName string
16 | param keyVaultName string
17 |
18 | param authMode string
19 | param publicNetworkAccess string
20 | param privateEndpointSubnetId string
21 | param appSubnetId string
22 | param privateDnsZoneId string
23 | param allowedIpAddresses array = []
24 |
25 | var allowedIpRestrictions = [
26 | for allowedIpAddressesArray in allowedIpAddresses: {
27 | ipAddress: '${allowedIpAddressesArray}/32'
28 | action: 'Allow'
29 | priority: 300
30 | }
31 | ]
32 |
33 | var ipSecurityRestrictions = concat(allowedIpRestrictions, [
34 | { action: 'Allow', ipAddress: 'AzureBotService', priority: 100, tag: 'ServiceTag' }
35 | // Allow Teams Messaging IPs
36 | { action: 'Allow', ipAddress: '13.107.64.0/18', priority: 200 }
37 | { action: 'Allow', ipAddress: '52.112.0.0/14', priority: 201 }
38 | { action: 'Allow', ipAddress: '52.120.0.0/14', priority: 202 }
39 | { action: 'Allow', ipAddress: '52.238.119.141/32', priority: 203 }
40 | ])
41 |
42 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = {
43 | name: aiServicesName
44 | }
45 |
46 | resource aiProject 'Microsoft.MachineLearningServices/workspaces@2024-04-01-preview' existing = {
47 | name: aiProjectName
48 | }
49 |
50 | resource bingAccount 'Microsoft.Bing/accounts@2020-06-10' existing = {
51 | name: bingName
52 | }
53 |
54 | resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' existing = {
55 | name: cosmosName
56 | }
57 |
58 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
59 | name: keyVaultName
60 | }
61 |
62 | resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = {
63 | name: appServicePlanName
64 | location: location
65 | tags: tags
66 | sku: {
67 | name: sku
68 | capacity: 1
69 | }
70 | properties: {
71 | reserved: true
72 | }
73 | kind: 'linux'
74 | }
75 |
76 | resource backend 'Microsoft.Web/sites@2023-12-01' = {
77 | name: appServiceName
78 | location: location
79 | tags: union(tags, { 'azd-service-name': 'azure-agents-app' })
80 | identity: {
81 | type: 'UserAssigned'
82 | userAssignedIdentities: {
83 | '${msiID}': {}
84 | }
85 | }
86 | properties: {
87 | serverFarmId: appServicePlan.id
88 | httpsOnly: true
89 | virtualNetworkSubnetId: !empty(appSubnetId) ? appSubnetId : null
90 | keyVaultReferenceIdentity: msiID
91 | vnetRouteAllEnabled: true
92 | siteConfig: {
93 | keyVaultReferenceIdentity: msiID
94 | vnetRouteAllEnabled: true
95 | httpLoggingEnabled: true
96 | logsDirectorySizeLimit: 35
97 | ipSecurityRestrictions: ipSecurityRestrictions
98 | publicNetworkAccess: 'Enabled'
99 | ipSecurityRestrictionsDefaultAction: 'Allow'
100 | scmIpSecurityRestrictionsDefaultAction: 'Allow'
101 | http20Enabled: true
102 | linuxFxVersion: 'PYTHON|3.10'
103 | webSocketsEnabled: true
104 | appCommandLine: 'gunicorn app:app'
105 | alwaysOn: true
106 | appSettings: [
107 | {
108 | name: 'MicrosoftAppType'
109 | value: 'UserAssignedMSI'
110 | }
111 | {
112 | name: 'MicrosoftAppId'
113 | value: msiClientID
114 | }
115 | {
116 | name: 'MicrosoftAppTenantId'
117 | value: tenant().tenantId
118 | }
119 | {
120 | name: 'AZURE_AI_PROJECT_CONNECTION_STRING'
121 | value: '${split(aiProject.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${aiProjectName}'
122 | }
123 | {
124 | name: 'SSO_ENABLED'
125 | value: 'false'
126 | }
127 | {
128 | name: 'SSO_CONFIG_NAME'
129 | value: ''
130 | }
131 | {
132 | name: 'SSO_MESSAGE_TITLE'
133 | value: 'Please sign in to continue.'
134 | }
135 | {
136 | name: 'SSO_MESSAGE_PROMPT'
137 | value: 'Sign in'
138 | }
139 | {
140 | name: 'SSO_MESSAGE_SUCCESS'
141 | value: 'User logged in successfully! Please repeat your question.'
142 | }
143 | {
144 | name: 'SSO_MESSAGE_FAILED'
145 | value: 'Log in failed. Type anything to retry.'
146 | }
147 | {
148 | name: 'AZURE_OPENAI_API_ENDPOINT'
149 | value: aiServices.properties.endpoint
150 | }
151 | {
152 | name: 'AZURE_OPENAI_API_VERSION'
153 | value: '2024-05-01-preview'
154 | }
155 | {
156 | name: 'AZURE_OPENAI_DEPLOYMENT_NAME'
157 | value: deploymentName
158 | }
159 | {
160 | name: 'AZURE_OPENAI_ASSISTANT_ID'
161 | value: 'YOUR_ASSISTANT_ID'
162 | }
163 | {
164 | name: 'AZURE_OPENAI_STREAMING'
165 | value: 'true'
166 | }
167 | {
168 | name: 'AZURE_OPENAI_API_KEY'
169 | value: authMode == 'accessKey' ? aiServices.listKeys().key1 : ''
170 | }
171 | {
172 | name: 'AZURE_BING_API_ENDPOINT'
173 | value: 'https://api.bing.microsoft.com/'
174 | }
175 | {
176 | name: 'AZURE_BING_API_KEY'
177 | value: bingAccount.listKeys().key1
178 | }
179 | {
180 | name: 'AZURE_COSMOSDB_ENDPOINT'
181 | value: cosmos.properties.documentEndpoint
182 | }
183 | {
184 | name: 'AZURE_COSMOSDB_DATABASE_ID'
185 | value: 'GenAIBot'
186 | }
187 | {
188 | name: 'AZURE_COSMOSDB_CONTAINER_ID'
189 | value: 'Conversations'
190 | }
191 | {
192 | name: 'AZURE_COSMOS_AUTH_KEY'
193 | value: '@Microsoft.KeyVault(VaultName=${keyVault.name};SecretName=AZURE-COSMOS-AUTH-KEY)'
194 | }
195 | {
196 | name: 'AZURE_DIRECT_LINE_SECRET'
197 | value: '@Microsoft.KeyVault(VaultName=${keyVault.name};SecretName=AZURE-DIRECT-LINE-SECRET)'
198 | }
199 | {
200 | name: 'MAX_TURNS'
201 | value: '10'
202 | }
203 | {
204 | name: 'LLM_WELCOME_MESSAGE'
205 | value: 'Welcome to the Travel Agent Sample! You can use this chat to:
- Get recommendations of places to visit and things to do;
- Upload your travel bookings and generate an itinerary;
- Upload pictures of signs, menus and more to get information about them;
- Ask for help with budgeting a trip;
- And more!
To upload files, use the attachment button on the left, attach a file and hit enter to upload. You will get confirmation when the file is ready to use. You will also be prompted whether you\'d like to add it to Code Interpreter or File Search. Use Code Interpreter for mathematical operations, and File Search to use the file contents as context for your question. You may skip this step for images.'
206 | }
207 | {
208 | name: 'LLM_INSTRUCTIONS'
209 | value: 'You are a helpful travel agent who can assist with many travel-related inquiries, including:\n- Reading travel documents and putting together itineraries\n- Viewing and interpreting pictures that may be in different languages\n- Locating landmarks and suggesting places to visit\n- Budgeting and graphing cost information\n- Looking up information on the web for up to date information\nYou should do your best to respond to travel-related questions, but politely decline to help with unrelated questions.\nAny time the information you want to provide requires up-to-date sources - for example, hotels, restaurants and more - you should use the Bing Search tool and provide sources.'
210 | }
211 | {
212 | name: 'SCM_DO_BUILD_DURING_DEPLOYMENT'
213 | value: 'true'
214 | }
215 | {
216 | name: 'ENABLE_ORYX_BUILD'
217 | value: 'true'
218 | }
219 | {
220 | name: 'DEBUG'
221 | value: 'true'
222 | }
223 | {
224 | name:'AZURE_KEY_VAULT_ENDPOINT'
225 | value: keyVault.properties.vaultUri
226 | }
227 | ]
228 | }
229 | }
230 | }
231 |
232 | // resource backendAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
233 | // name: 'pl-${appServiceName}'
234 | // location: location
235 | // tags: tags
236 | // properties: {
237 | // subnet: {
238 | // id: privateEndpointSubnetId
239 | // }
240 | // privateLinkServiceConnections: [
241 | // {
242 | // name: 'private-endpoint-connection'
243 | // properties: {
244 | // privateLinkServiceId: backend.id
245 | // groupIds: ['sites']
246 | // }
247 | // }
248 | // ]
249 | // }
250 | // resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
251 | // name: 'zg-${appServiceName}'
252 | // properties: {
253 | // privateDnsZoneConfigs: [
254 | // {
255 | // name: 'default'
256 | // properties: {
257 | // privateDnsZoneId: privateDnsZoneId
258 | // }
259 | // }
260 | // ]
261 | // }
262 | // }
263 | // }
264 |
265 | output backendAppName string = backend.name
266 | output backendHostName string = backend.properties.defaultHostName
267 |
--------------------------------------------------------------------------------
/infra/modules/bing.bicep:
--------------------------------------------------------------------------------
1 | param bingName string
2 | param keyVaultName string
3 | param tags object = {}
4 | // param privateEndpointSubnetId string
5 | // param publicNetworkAccess string
6 | // param bingPrivateDnsZoneId string
7 | // param grantAccessTo array
8 | // param allowedIpAddresses array = []
9 | // param authMode string
10 |
11 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
12 | name: keyVaultName
13 |
14 | resource secret 'secrets' = {
15 | name: 'BING-API-KEY'
16 | properties: {
17 | value: bing.listKeys().key1
18 | }
19 | }
20 | }
21 | resource bing 'Microsoft.Bing/accounts@2020-06-10' = {
22 | name: bingName
23 | location: 'global'
24 | tags: tags
25 | sku: {
26 | name: 'S1'
27 | }
28 | kind: 'Bing.Search.v7'
29 | }
30 |
31 | output bingID string = bing.id
32 | output bingName string = bing.name
33 | output bingApiEndpoint string = bing.properties.endpoint
34 |
--------------------------------------------------------------------------------
/infra/modules/botservice.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param botServiceName string
3 | param keyVaultName string
4 | param endpoint string
5 | param msiID string
6 | param msiClientID string
7 | param sku string = 'F0'
8 | param kind string = 'azurebot'
9 | param tags object = {}
10 | param publicNetworkAccess string
11 |
12 |
13 | resource botservice 'Microsoft.BotService/botServices@2022-09-15' = {
14 | name: botServiceName
15 | location: location
16 | tags: tags
17 | sku: {
18 | name: sku
19 | }
20 | kind: kind
21 | properties: {
22 | displayName: botServiceName
23 | endpoint: endpoint
24 | msaAppMSIResourceId: msiID
25 | msaAppId: msiClientID
26 | msaAppType: 'UserAssignedMSI'
27 | msaAppTenantId: tenant().tenantId
28 | publicNetworkAccess: 'Enabled'
29 | disableLocalAuth: true
30 | }
31 |
32 | resource directline 'channels' = {
33 | name: 'DirectLineChannel'
34 | properties: {
35 | channelName: 'DirectLineChannel'
36 | }
37 | }
38 | }
39 |
40 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
41 | name: keyVaultName
42 |
43 | resource secret 'secrets' = {
44 | name: 'AZURE-DIRECT-LINE-SECRET'
45 | properties: {
46 | value: botservice::directline.listChannelWithKeys().setting.sites[0].key
47 | }
48 | }
49 | }
50 |
51 | output name string = botservice.name
52 |
--------------------------------------------------------------------------------
/infra/modules/cosmos.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param vnetLocation string = location
3 | param cosmosName string
4 | param keyVaultName string
5 | param tags object = {}
6 | param publicNetworkAccess string
7 | param privateEndpointSubnetId string
8 | param privateDnsZoneId string
9 | param grantAccessTo array = []
10 | param allowedIpAddresses array = []
11 | param authMode string
12 |
13 | resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' = {
14 | name: cosmosName
15 | location: location
16 | tags: tags
17 | kind: 'GlobalDocumentDB'
18 | properties: {
19 | locations: [
20 | {
21 | locationName: location
22 | failoverPriority: 0
23 | isZoneRedundant: false
24 | }
25 | ]
26 | databaseAccountOfferType: 'Standard'
27 | publicNetworkAccess: !empty(allowedIpAddresses) ? 'Enabled' : publicNetworkAccess
28 | networkAclBypass: 'AzureServices'
29 | ipRules: [
30 | for ipAddress in allowedIpAddresses: {
31 | ipAddressOrRange: ipAddress
32 | }
33 | ]
34 | disableLocalAuth: authMode == 'accessKey' ? false : true
35 | }
36 |
37 | resource db 'sqlDatabases' = {
38 | name: 'GenAIBot'
39 | properties: {
40 | resource: {
41 | id: 'GenAIBot'
42 | }
43 | }
44 |
45 | resource col 'containers' = {
46 | name: 'Conversations'
47 | properties: {
48 | resource: {
49 | id: 'Conversations'
50 | partitionKey: {
51 | paths: ['/id']
52 | kind: 'Hash'
53 | }
54 | }
55 | }
56 | }
57 | }
58 | }
59 |
60 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') {
61 | name: 'pl-${cosmosName}'
62 | location: vnetLocation
63 | tags: tags
64 | properties: {
65 | subnet: {
66 | id: privateEndpointSubnetId
67 | }
68 | privateLinkServiceConnections: [
69 | {
70 | name: 'private-endpoint-connection'
71 | properties: {
72 | privateLinkServiceId: cosmos.id
73 | groupIds: ['Sql']
74 | }
75 | }
76 | ]
77 | }
78 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = {
79 | name: 'zg-${cosmosName}'
80 | properties: {
81 | privateDnsZoneConfigs: [
82 | {
83 | name: 'default'
84 | properties: {
85 | privateDnsZoneId: privateDnsZoneId
86 | }
87 | }
88 | ]
89 | }
90 | }
91 | }
92 |
93 | resource cosmosAccountReader 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
94 | name: 'fbdf93bf-df7d-467e-a4d2-9458aa1360c8'
95 | }
96 | resource cosmosDataContributor 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2021-10-15' existing = {
97 | name: '00000000-0000-0000-0000-000000000002'
98 | parent: cosmos
99 | }
100 |
101 | resource accountReaderAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
102 | for principal in grantAccessTo: if (!empty(principal.id)) {
103 | name: guid(principal.id, cosmos.id, cosmosAccountReader.id)
104 | scope: cosmos
105 | properties: {
106 | roleDefinitionId: cosmosAccountReader.id
107 | principalId: principal.id
108 | principalType: principal.type
109 | }
110 | }
111 | ]
112 |
113 | resource writerAccess 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = [
114 | for principal in grantAccessTo: if (!empty(principal.id)) {
115 | name: guid(principal.id, cosmos.id, cosmosDataContributor.id)
116 | parent: cosmos
117 | properties: {
118 | roleDefinitionId: cosmosDataContributor.id
119 | principalId: principal.id
120 | scope: cosmos.id
121 | }
122 | }
123 | ]
124 |
125 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
126 | name: keyVaultName
127 |
128 | resource secret 'secrets' = {
129 | name: 'AZURE-COSMOS-AUTH-KEY'
130 | properties: {
131 | value: cosmos.listKeys().primaryMasterKey
132 | }
133 | }
134 | }
135 |
136 | output cosmosID string = cosmos.id
137 | output cosmosName string = cosmos.name
138 | output cosmosEndpoint string = cosmos.properties.documentEndpoint
139 |
--------------------------------------------------------------------------------
/infra/modules/gptDeployment.bicep:
--------------------------------------------------------------------------------
1 | param aiServicesName string
2 | param modelName string
3 | param modelVersion string
4 | param modelCapacity int = 10
5 |
6 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = {
7 | name: aiServicesName
8 |
9 | resource gptdeployment 'deployments' = if (startsWith(modelName, 'gpt')) {
10 | name: modelName
11 | properties: {
12 | model: {
13 | format: 'OpenAI'
14 | name: modelName
15 | version: modelVersion
16 | }
17 | }
18 | sku: {
19 | capacity: modelCapacity
20 | name: 'Standard'
21 | }
22 | }
23 | }
24 |
25 | output modelName string = aiServices::gptdeployment.name
26 | output modelVersion string = aiServices::gptdeployment.properties.model.version
27 |
--------------------------------------------------------------------------------
/infra/modules/msi.bicep:
--------------------------------------------------------------------------------
1 | param location string
2 | param msiName string
3 | param tags object = {}
4 |
5 | resource msi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
6 | name: msiName
7 | location: location
8 | tags: tags
9 | }
10 |
11 | output msiID string = msi.id
12 | output msiName string = msi.name
13 | output msiClientID string = msi.properties.clientId
14 | output msiPrincipalID string = msi.properties.principalId
15 |
--------------------------------------------------------------------------------
/media/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/media/architecture.png
--------------------------------------------------------------------------------
/scripts/setupSso.ps1:
--------------------------------------------------------------------------------
1 | Write-Host "Loading azd .env file from current environment..."
2 |
3 | $envValues = azd env get-values
4 | $envValues.Split("`n") | ForEach-Object {
5 | $key, $value = $_.Split('=')
6 | $value = $value.Trim('"')
7 | Set-Variable -Name $key -Value $value -Scope Global
8 | }
9 |
10 | if ($ENABLE_AUTH -ne "true") {
11 | return
12 | }
13 |
14 | # If App Registration was not created, create it
15 | if ($CLIENT_ID -eq $null) {
16 | Write-Host "Creating app registration..."
17 | $APP = (az ad app create --display-name $BACKEND_APP_NAME --web-redirect-uris "https://$BACKEND_APP_NAME.azurewebsites.net/.auth/login/aad/callback" "https://token.botframework.com/.auth/web/redirect" --enable-id-token-issuance --required-resource-accesses '[{
18 | \"resourceAppId\": \"00000003-0000-0000-c000-000000000000\",
19 | \"resourceAccess\": [
20 | {
21 | \"id\": \"37f7f235-527c-4136-accd-4a02d197296e\",
22 | \"type\": \"Scope\"
23 | }
24 | ]
25 | }]' | ConvertFrom-Json)
26 | $APP_ID = $APP.id
27 | $CLIENT_ID = $APP.appId
28 | }
29 |
30 | # If a federated identity doesn't exist, create it
31 |
32 | $uuid_no_hyphens = $AZURE_TENANT_ID -replace "-", ""
33 | $uuid_reordered = $uuid_no_hyphens.Substring(6, 2) + $uuid_no_hyphens.Substring(4, 2) + $uuid_no_hyphens.Substring(2, 2) + $uuid_no_hyphens.Substring(0, 2) +
34 | $uuid_no_hyphens.Substring(10, 2) + $uuid_no_hyphens.Substring(8, 2) +
35 | $uuid_no_hyphens.Substring(14, 2) + $uuid_no_hyphens.Substring(12, 2) +
36 | $uuid_no_hyphens.Substring(16, 16)
37 | $uuid_binary = [System.Convert]::FromHexString($uuid_reordered)
38 | $B64_AZURE_TENANT_ID = [Convert]::ToBase64String($uuid_binary) -replace '\+', '-' -replace '/', '_' -replace '=', ''
39 |
40 | $FED_ID = (az ad app federated-credential list --id $CLIENT_ID | ConvertFrom-Json)[0].id
41 | Write-Host "Federated identity: $FED_ID"
42 | echo "{
43 | `"audiences`": [`"api://AzureADTokenExchange`"],
44 | `"description`": `"`",
45 | `"issuer`": `"https://login.microsoftonline.com/$AZURE_TENANT_ID/v2.0`",
46 | `"name`": `"default`",
47 | `"subject`": `"/eid1/c/pub/t/$B64_AZURE_TENANT_ID/a/9ExAW52n_ky4ZiS_jhpJIQ/$MSI_PRINCIPAL_ID`"
48 | }" | Out-File tmp.json
49 | if ($FED_ID -eq $null) {
50 | Write-Host "Creating federated identity..."
51 | az ad app federated-credential create --id $CLIENT_ID --parameters tmp.json | Out-Null
52 | } else {
53 | Write-Host "Federated identity already exists. Skipping..."
54 | }
55 | rm tmp.json
56 |
57 | # Set up authorization for the app
58 | az webapp auth config-version upgrade -g $AZURE_RESOURCE_GROUP_NAME -n $BACKEND_APP_NAME
59 | az webapp auth update -g $AZURE_RESOURCE_GROUP_NAME -n $BACKEND_APP_NAME --enabled $true --action RedirectToLoginPage --redirect-provider azureactivedirectory --excluded-paths "[/api/messages]"
60 | az webapp auth microsoft update -g $AZURE_RESOURCE_GROUP_NAME -n $BACKEND_APP_NAME --allowed-token-audiences "https://$BACKEND_APP_NAME.azurewebsites.net/.auth/login/aad/callback" --client-id $CLIENT_ID --issuer "https://sts.windows.net/$AZURE_TENANT_ID/"
--------------------------------------------------------------------------------
/scripts/setupSso.sh:
--------------------------------------------------------------------------------
1 | echo "Loading azd .env file from current environment..."
2 |
3 | while IFS='=' read -r key value; do
4 | value=$(echo "$value" | sed 's/^"//' | sed 's/"$//')
5 | export "$key=$value"
6 | done < web.Application:
43 | app = web.Application(middlewares=[aiohttp_error_middleware])
44 | app.add_routes(messages_routes(adapter, bot))
45 | app.add_routes(directline_routes(secret_client))
46 | app.add_routes(file_routes(agents_client))
47 | app.add_routes(static_routes())
48 | return app
49 |
50 | config = DefaultConfig()
51 |
52 | # Create adapter.
53 | # See https://aka.ms/about-bot-adapter to learn more about how bots work.
54 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config))
55 |
56 | # Set up service authentication
57 | credential = DefaultAzureCredential(managed_identity_client_id=os.getenv("MicrosoftAppId"))
58 |
59 | # Key Vault
60 | secret_client = SecretClient(vault_url=os.getenv("AZURE_KEY_VAULT_ENDPOINT"), credential=credential)
61 |
62 | # Azure AI Services
63 | aoai_client = AzureOpenAI(
64 | api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
65 | azure_endpoint=os.getenv("AZURE_OPENAI_API_ENDPOINT"),
66 | api_key=os.getenv("AZURE_OPENAI_API_KEY"),
67 | azure_ad_token_provider=get_bearer_token_provider(
68 | credential,
69 | "https://cognitiveservices.azure.com/.default"
70 | )
71 | )
72 |
73 | project_client = AIProjectClient.from_connection_string(
74 | credential=credential,
75 | conn_str=os.getenv("AZURE_AI_PROJECT_CONNECTION_STRING")
76 | )
77 | agents_client = project_client.agents
78 |
79 | bing_client = BingClient(os.getenv("AZURE_BING_API_KEY"))
80 | graph_client = GraphClient()
81 |
82 | # Conversation history storage
83 | storage = None
84 | if os.getenv("AZURE_COSMOSDB_ENDPOINT"):
85 | auth_key=os.getenv("AZURE_COSMOSDB_AUTH_KEY", secret_client.get_secret("AZURE-COSMOS-AUTH-KEY").value)
86 | storage = CosmosDbPartitionedStorage(
87 | CosmosDbPartitionedConfig(
88 | cosmos_db_endpoint=os.getenv("AZURE_COSMOSDB_ENDPOINT"),
89 | database_id=os.getenv("AZURE_COSMOSDB_DATABASE_ID"),
90 | container_id=os.getenv("AZURE_COSMOSDB_CONTAINER_ID"),
91 | credential=credential,
92 | )
93 | )
94 | else:
95 | storage = MemoryStorage()
96 |
97 | # Create conversation and user state
98 | user_state = UserState(storage)
99 | conversation_state = ConversationState(storage)
100 |
101 | dialog = LoginDialog()
102 |
103 | assistant_id = create_or_update_agent(agents_client, os.getenv("AZURE_OPENAI_ASSISTANT_NAME"))
104 |
105 | # Create the bot
106 | bot = AssistantBot(
107 | conversation_state, user_state,
108 | aoai_client,
109 | agents_client,
110 | assistant_id,
111 | bing_client,
112 | graph_client,
113 | dialog
114 | )
115 | app = create_app(adapter, bot, agents_client, secret_client)
116 |
117 | if __name__ == "__main__":
118 | web.run_app(app, host="localhost", port=3978)
--------------------------------------------------------------------------------
/src/azure_ai_projects-1.0.0b1-py3-none-any.whl:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/azure_ai_projects-1.0.0b1-py3-none-any.whl
--------------------------------------------------------------------------------
/src/bots/__init__.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | from .assistant_bot import AssistantBot
5 |
6 | __all__ = ["AssistantBot"]
7 |
--------------------------------------------------------------------------------
/src/bots/assistant_bot.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | import os
5 | import io
6 | import json
7 | import base64
8 | import urllib.request
9 |
10 | from azure.ai.projects.operations import AgentsOperations
11 |
12 | from botbuilder.core import ConversationState, TurnContext, UserState, MessageFactory
13 | from botbuilder.schema import ChannelAccount, CardAction, ActionTypes
14 | from botbuilder.dialogs import Dialog
15 |
16 | from openai import AzureOpenAI
17 |
18 | from data_models import ConversationData, Attachment, mime_type
19 | from bots.state_management_bot import StateManagementBot
20 | from services.bing import BingClient
21 | from services.graph import GraphClient
22 |
23 | class AssistantBot(StateManagementBot):
24 |
25 | def __init__(
26 | self,
27 | conversation_state: ConversationState,
28 | user_state: UserState,
29 | aoai_client: AzureOpenAI,
30 | agents_client: AgentsOperations,
31 | agent_id: str,
32 | bing_client: BingClient,
33 | graph_client: GraphClient,
34 | dialog: Dialog
35 | ):
36 | super().__init__(conversation_state, user_state, dialog)
37 | self.aoai_client = aoai_client
38 | self.chat_client = aoai_client.chat
39 | self.agents_client = agents_client
40 | self.bing_client = bing_client
41 | self.graph_client = graph_client
42 |
43 | self.deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME")
44 | self.instructions = os.getenv("LLM_INSTRUCTIONS")
45 | self.welcome_message = os.getenv("LLM_WELCOME_MESSAGE", "Hello and welcome to the Assistant Bot Python!")
46 | self.agent_id = agent_id
47 | self.streaming = os.getenv("AZURE_OPENAI_STREAMING", False)
48 |
49 | async def on_members_added_activity(self, members_added: list[ChannelAccount], turn_context: TurnContext):
50 | for member in members_added:
51 | if member.id != turn_context.activity.recipient.id:
52 | await turn_context.send_activity(self.welcome_message)
53 | await self.handle_login(turn_context)
54 |
55 | async def on_message_activity(self, turn_context: TurnContext):
56 |
57 | # Enforce login
58 | loggedIn = await self.handle_login(turn_context)
59 | if not loggedIn:
60 | return False
61 |
62 | # Load conversation state
63 | conversation_data = await self.conversation_data_accessor.get(turn_context, ConversationData([]))
64 |
65 | # Create a new thread if one does not exist
66 | if conversation_data.thread_id is None:
67 | thread = self.agents_client.create_thread()
68 | conversation_data.thread_id = thread.id
69 |
70 | # Delete thread if user asks
71 | if turn_context.activity.text == 'clear':
72 | self.agents_client.delete_thread(conversation_data.thread_id)
73 | conversation_data.thread_id = None
74 | conversation_data.attachments = []
75 | conversation_data.history = []
76 | await turn_context.send_activity('Conversation cleared!')
77 | return True
78 |
79 | # Check if this is a file upload and process it
80 | files_uploaded = await self.handle_file_uploads(turn_context, conversation_data.thread_id, conversation_data)
81 |
82 | # Return early if there is no text in the message
83 | if (files_uploaded or turn_context.activity.text == None):
84 | return
85 |
86 | # Check if this is a file upload follow up - user selects a file upload option
87 | if (turn_context.activity.text.startswith(":")):
88 | # Get upload metadata
89 | tool = turn_context.activity.text.split(':').pop().strip()
90 | # Get file from attachments
91 | attachment = conversation_data.attachments[-1]
92 | # Add file upload to relevant tool
93 | with urllib.request.urlopen(attachment.url) as f:
94 | bytes = io.BytesIO(f.read())
95 | bytes.name = attachment.name
96 | file_response = self.agents_client.upload_file(file=bytes, purpose="assistants")
97 | # Send the file to the assistant
98 | tools = []
99 | if tool == "Code Interpreter":
100 | tools.append({
101 | "type": "code_interpreter"
102 | })
103 | if tool == "File Search":
104 | tools.append({
105 | "type": "file_search"
106 | })
107 | self.agents_client.create_message(
108 | thread_id=conversation_data.thread_id,
109 | role="user",
110 | content=f"File uploaded: {attachment.name}",
111 | attachments=[{
112 | "file_id": file_response.id,
113 | "tools": tools
114 | }]
115 | )
116 | # Send feedback to user
117 | await turn_context.send_activity(MessageFactory.text(f"File added to {tool} successfully!"))
118 | return True
119 |
120 | # Add user message to history
121 | conversation_data.add_turn("user", turn_context.activity.text)
122 |
123 | # Send user message to thread
124 | self.agents_client.create_message(
125 | thread_id=conversation_data.thread_id,
126 | role="user",
127 | content=turn_context.activity.text
128 | )
129 |
130 | # Run thread
131 | run = self.agents_client.create_stream(
132 | thread_id=conversation_data.thread_id,
133 | assistant_id=self.agent_id,
134 | instructions=self.instructions
135 | )
136 |
137 | # Process run streaming
138 | await self.process_run_streaming(run, conversation_data, turn_context)
139 |
140 | return True
141 |
142 | async def process_run_streaming(self, run, conversation_data, turn_context, stream_id = None):
143 | # Start streaming response
144 | current_message = ""
145 | tool_outputs = []
146 | current_run_id = ""
147 | activity_id = ""
148 | stream_sequence = 1
149 | activity_id = await self.send_interim_message(turn_context, "Typing...", stream_sequence, stream_id, "typing")
150 |
151 | for event in run:
152 | event_type = event[0]
153 | event_data = event[1]
154 | if event_type == "thread.run.failed":
155 | current_message = event_data.last_error.message
156 | break
157 | if event_type == "thread.run.created":
158 | current_run_id = event_data.id
159 | if event_type == "thread.run.requires_action":
160 | tool_calls = event_data.required_action.submit_tool_outputs.tool_calls
161 | for tool_call in tool_calls:
162 | arguments = json.loads(tool_call.function.arguments)
163 | if tool_call.function.name == "image_query":
164 | response = await self.image_query(conversation_data, arguments["query"], arguments["image_name"])
165 | tool_outputs.append({"tool_call_id": tool_call.id, "output": response})
166 | elif tool_call.function.name == "bing_query":
167 | response = await self.bing_query(conversation_data, arguments["query"], arguments["type"])
168 | tool_outputs.append({"tool_call_id": tool_call.id, "output": response})
169 | elif tool_call.function.name == "schedule_event":
170 | response = await self.schedule_event(conversation_data, turn_context.activity.token, arguments["subject"], arguments["start"], arguments["end"])
171 | tool_outputs.append({"tool_call_id": tool_call.id, "output": response})
172 | else:
173 | tool_outputs.append({"tool_call_id": tool_call.id, "output": "Tool not found"})
174 |
175 | if event_type == "thread.message.delta":
176 | deltaBlock = event_data.delta.content[0]
177 | if deltaBlock.type == "text":
178 | current_message += deltaBlock.text.value
179 | stream_sequence += 1
180 | # Flush content every 50 messages
181 | if (stream_sequence % 50 == 0):
182 | await self.send_interim_message(turn_context, current_message, stream_sequence, activity_id, "typing")
183 |
184 | elif deltaBlock.type == "image_file":
185 | current_message += f""
186 |
187 | messages = self.agents_client.get_messages(thread_id=conversation_data.thread_id).messages
188 | # Recursively process the run with the tool outputs
189 | if len(tool_outputs) > 0:
190 | new_run = self.agents_client.submit_tool_outputs_to_stream(thread_id=conversation_data.thread_id, run_id=current_run_id, tool_outputs=tool_outputs)
191 | await self.process_run_streaming(new_run, conversation_data, turn_context, activity_id)
192 | return
193 | response = current_message
194 |
195 | # Add assistant message to history
196 | conversation_data.add_turn("assistant", response)
197 |
198 | # Respond back to user
199 | stream_sequence += 1
200 | await self.send_interim_message(turn_context, current_message, stream_sequence, activity_id, "message")
201 |
202 |
203 | # Helper to handle file uploads from user
204 | async def handle_file_uploads(self, turn_context: TurnContext, thread_id: str, conversation_data: ConversationData):
205 | files_uploaded = False
206 | # Check if incoming message has attached files
207 | if turn_context.activity.attachments is not None:
208 | for attachment in turn_context.activity.attachments:
209 | if attachment.content_url == None:
210 | continue
211 | files_uploaded = True
212 | download_url = attachment.content_url
213 | if attachment.content and "downloadUrl" in attachment.content:
214 | download_url = attachment.content["downloadUrl"]
215 | # Add file to attachments in case we need to reference it in Function Calling
216 | conversation_data.attachments.append(Attachment(
217 | name = attachment.name,
218 | content_type = mime_type(attachment.name),
219 | url = download_url
220 | ))
221 |
222 | # Add file upload notice to conversation history, frontend, and assistant
223 | conversation_data.add_turn("user", f"File uploaded: {attachment.name}")
224 | await turn_context.send_activity(MessageFactory.text(f"File uploaded: {attachment.name}"))
225 | self.agents_client.create_message(thread_id=thread_id,role="user",content=f"File uploaded: {attachment.name}",)
226 | # Ask whether to add file to a tool
227 | await turn_context.send_activity(MessageFactory.suggested_actions(
228 | [
229 | CardAction(title= ":Code Interpreter", type= ActionTypes.im_back, value= ":Code Interpreter"),
230 | CardAction( title= ":File Search", type= ActionTypes.im_back, value= ":File Search"),
231 | ],
232 | "Add to a tool? (ignore if not needed)",
233 | ))
234 |
235 |
236 | # Return True if files were uploaded
237 | return files_uploaded
238 |
239 | async def image_query(self, conversation_data: ConversationData, query: str, image_name: str):
240 | # Find image in attachments by name
241 | image = next(filter(lambda a: a.name == image_name.split("/")[-1], conversation_data.attachments))
242 |
243 | # Read image.url
244 | with urllib.request.urlopen(image.url) as f:
245 | # get file as base64
246 | bytes = base64.b64encode(f.read()).decode()
247 |
248 | # Send image to assistant
249 | response = self.chat_client.completions.create(
250 | model=self.deployment,
251 | messages=[
252 | {"role": "user", "content": [
253 | {"type": "text", "text": query},
254 | {"type": "image_url", "image_url": {
255 | "url": f"data:{image.content_type};base64,{bytes}"}
256 | }
257 | ]}
258 | ]
259 | )
260 | return response.choices[0].message.content
261 |
262 | async def bing_query(self, conversation_data: ConversationData, query: str, type: str):
263 | return self.bing_client.query(query, type)
264 |
265 | async def schedule_event(self, conversation_data: ConversationData, token: str, subject: str, start: str, end: str):
266 | return self.graph_client.schedule_event(token, subject, start, end)
267 |
268 |
--------------------------------------------------------------------------------
/src/bots/state_management_bot.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 | import os
4 | import jwt
5 | from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState, MessageFactory
6 | from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus
7 | from botframework.connector.auth.user_token_client import UserTokenClient
8 |
9 | class StateManagementBot(ActivityHandler):
10 | def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog):
11 | self.conversation_state = conversation_state
12 | self.user_state = user_state
13 | self.conversation_data_accessor = self.conversation_state.create_property("ConversationData")
14 | self.dialog = dialog
15 | self.sso_enabled = os.getenv("SSO_ENABLED", False)
16 | if (self.sso_enabled == "false"):
17 | self.sso_enabled = False
18 | self.sso_config_name = os.getenv("SSO_CONFIG_NAME", "default")
19 |
20 | async def on_turn(self, turn_context: TurnContext):
21 | await super().on_turn(turn_context)
22 | # Save any state changes. The load happened during the execution of the Dialog.
23 | await self.conversation_state.save_changes(turn_context)
24 | await self.user_state.save_changes(turn_context)
25 |
26 | async def handle_login(self, turn_context: TurnContext):
27 | if not self.sso_enabled:
28 | return True
29 | if turn_context.activity.text == 'logout':
30 | await self.handle_logout(turn_context)
31 | return False
32 |
33 | user_profile_accessor = self.user_state.create_property("UserProfile")
34 | user_profile = await user_profile_accessor.get(turn_context, lambda: {})
35 |
36 | user_token_client = turn_context.turn_state.get(UserTokenClient.__name__, None)
37 |
38 | try:
39 | user_token = await user_token_client.get_user_token(turn_context.activity.from_property.id, self.sso_config_name, turn_context.activity.channel_id, None)
40 | decoded_token = jwt.decode(user_token.token, options={"verify_signature": False})
41 | turn_context.activity.token = user_token.token
42 | user_profile["name"] = decoded_token.get("name")
43 | return True
44 | except Exception as error:
45 | dialog_set = DialogSet(self.conversation_state.create_property("DialogState"))
46 | dialog_set.add(self.dialog)
47 | dialog_context = await dialog_set.create_context(turn_context)
48 | results = await dialog_context.continue_dialog()
49 | if results.status == DialogTurnStatus.Empty:
50 | await dialog_context.begin_dialog(self.dialog.id)
51 | return False
52 |
53 | async def handle_logout(self, turn_context):
54 | user_token_client = turn_context.turn_state.get(UserTokenClient.__name__, None)
55 | await user_token_client.sign_out_user(turn_context.activity.from_property.id, self.sso_config_name, turn_context.activity.channel_id)
56 | await turn_context.send_activity("Signed out")
57 |
58 | async def send_interim_message(
59 | self,
60 | turn_context,
61 | interim_message,
62 | stream_sequence,
63 | stream_id,
64 | stream_type
65 | ):
66 | stream_supported = self.streaming and turn_context.activity.channel_id == "directline"
67 | update_supported = self.streaming and turn_context.activity.channel_id == "msteams"
68 | # If we can neither stream or update, return null
69 | if stream_type == "typing" and not stream_supported and not update_supported:
70 | return None
71 | # If we can update messages, do so
72 | if update_supported:
73 | if stream_id == None:
74 | create_activity = await turn_context.send_activity(interim_message)
75 | return create_activity.id
76 | else:
77 | update_message = MessageFactory.text(interim_message)
78 | update_message.id = stream_id
79 | update_message.type = "message"
80 | update_activity = await turn_context.update_activity(update_message)
81 | return update_activity.id
82 | # If we can stream messages, do so
83 | channel_data = {
84 | "streamId": stream_id,
85 | "streamSequence": stream_sequence,
86 | "streamType": "streaming" if stream_type == "typing" else "final"
87 | }
88 | message = MessageFactory.text(interim_message)
89 | message.channel_data = channel_data if stream_supported else None
90 | message.type = stream_type
91 | activity = await turn_context.send_activity(message)
92 | return activity.id
--------------------------------------------------------------------------------
/src/config.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # Copyright (c) Microsoft Corporation. All rights reserved.
3 | # Licensed under the MIT License.
4 |
5 | import os
6 |
7 | """ Bot Configuration """
8 |
9 |
10 | class DefaultConfig:
11 | """ Bot Configuration """
12 | APP_ID = os.environ.get("MicrosoftAppId", "")
13 | APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "")
14 | APP_TYPE = os.environ.get("MicrosoftAppType", "MultiTenant")
15 | APP_TENANTID = os.environ.get("MicrosoftAppTenantId", "")
--------------------------------------------------------------------------------
/src/conftest.py:
--------------------------------------------------------------------------------
1 | pytest_plugins = 'aiohttp.pytest_plugin'
--------------------------------------------------------------------------------
/src/data_models/__init__.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | from .conversation_data import ConversationData, ConversationTurn, Attachment
5 | from .mime_type import mime_type
6 |
7 | __all__ = ["ConversationData", "ConversationTurn", "Attachment", "mime_type"]
8 |
--------------------------------------------------------------------------------
/src/data_models/conversation_data.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | class ConversationTurn:
5 | def __init__(
6 | self,
7 | role: str = None,
8 | content: str = None
9 | ):
10 | self.role = role
11 | self.content = content
12 |
13 | class Attachment:
14 | def __init__(
15 | self,
16 | name: str = None,
17 | content_type: str = None,
18 | url: str = None
19 | ):
20 | self.name = name
21 | self.content_type = content_type
22 | self.url = url
23 |
24 | class ConversationData:
25 | def __init__(
26 | self,
27 | history: list[ConversationTurn],
28 | max_turns: int = 10,
29 | thread_id: str = None,
30 | ):
31 | self.thread_id = thread_id
32 | self.history = history
33 | self.max_turns = max_turns
34 | self.attachments = []
35 |
36 | def add_turn(self, role: str, content: str):
37 | self.history.append(ConversationTurn(role, content))
38 | if len(self.history) > self.max_turns:
39 | self.history.pop(0)
--------------------------------------------------------------------------------
/src/data_models/mime_type.py:
--------------------------------------------------------------------------------
1 | types = {
2 | "c": "text/x-c",
3 | "cpp": "text/x-c++",
4 | "cs": "text/x-csharp",
5 | "css": "text/css",
6 | "csv": "text/csv",
7 | "doc": "application/msword",
8 | "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
9 | "gif": "image/gif",
10 | "go": "text/x-golang",
11 | "html": "text/html",
12 | "java": "text/x-java",
13 | "jpg": "image/jpeg",
14 | "jpeg": "image/jpeg",
15 | "js": "text/javascript",
16 | "json": "application/json",
17 | "md": "text/markdown",
18 | "pdf": "application/pdf",
19 | "php": "text/x-php",
20 | "png": "image/png",
21 | "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
22 | "py": "text/x-python",
23 | "py": "text/x-script.python",
24 | "rb": "text/x-ruby",
25 | "sh": "application/x-sh",
26 | "tex": "text/x-tex",
27 | "ts": "application/typescript",
28 | "txt": "text/plain",
29 | "webp": "image/webp",
30 | "xls": "application/vnd.ms-excel",
31 | "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
32 | }
33 |
34 | def mime_type(filename):
35 | ext = filename.split('.').pop()
36 | return types[ext] or "application/octet-stream"
--------------------------------------------------------------------------------
/src/dialogs/__init__.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | from .login_dialog import LoginDialog
5 |
6 | __all__ = ["LoginDialog"]
7 |
--------------------------------------------------------------------------------
/src/dialogs/login_dialog.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | import os
5 | from botbuilder.dialogs import (
6 | WaterfallDialog,
7 | WaterfallStepContext,
8 | DialogTurnResult,
9 | ComponentDialog
10 | )
11 | from botbuilder.dialogs.prompts import OAuthPrompt, OAuthPromptSettings, ConfirmPrompt
12 |
13 |
14 | class LoginDialog(ComponentDialog):
15 | def __init__(self):
16 | super(LoginDialog, self).__init__(LoginDialog.__name__)
17 | self.connection_name = os.getenv("SSO_CONFIG_NAME", "default")
18 | self.login_success_message = os.getenv("SSO_MESSAGE_SUCCESS", "Login success")
19 | self.login_failed_message = os.getenv("SSO_MESSAGE_FAILED", "Login failed")
20 |
21 | self.add_dialog(
22 | OAuthPrompt(
23 | OAuthPrompt.__name__,
24 | OAuthPromptSettings(
25 | connection_name=self.connection_name,
26 | text=os.getenv("SSO_MESSAGE_TITLE"),
27 | title=os.getenv("SSO_MESSAGE_PROMPT"),
28 | timeout=300000,
29 | ),
30 | )
31 | )
32 |
33 | self.add_dialog(
34 | WaterfallDialog(
35 | "WaterfallDialog",
36 | [
37 | self.prompt_step,
38 | self.login_step
39 | ],
40 | )
41 | )
42 |
43 | self.initial_dialog_id = "WaterfallDialog"
44 |
45 | async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult:
46 | return await step_context.begin_dialog(OAuthPrompt.__name__)
47 |
48 | async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult:
49 | # Get the token from the previous step. Note that we could also have gotten the
50 | # token directly from the prompt itself. There is an example of this in the next method.
51 | token_response = step_context.result
52 | if token_response:
53 | await step_context.context.send_activity(self.login_success_message)
54 | return await step_context.end_dialog()
55 |
56 | await step_context.context.send_activity(self.login_failed_message)
57 | return await step_context.end_dialog()
--------------------------------------------------------------------------------
/src/gunicorn.conf.py:
--------------------------------------------------------------------------------
1 | import multiprocessing
2 |
3 | max_requests = 1000
4 | max_requests_jitter = 50
5 | log_file = "-"
6 | bind = "0.0.0.0:8000"
7 |
8 | timeout = 600
9 | # https://learn.microsoft.com/en-us/troubleshoot/azure/app-service/web-apps-performance-faqs#why-does-my-request-time-out-after-230-seconds
10 |
11 | num_cpus = multiprocessing.cpu_count()
12 | workers = (num_cpus * 2) + 1
13 | # workers = 1
14 | worker_class = "aiohttp.GunicornWebWorker"
15 | port = 8000
--------------------------------------------------------------------------------
/src/public/images/BotServices-Translucent.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/public/images/BotServices.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/public/images/BotServices.png
--------------------------------------------------------------------------------
/src/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Web Chat Direct Line Token Demo
11 |
12 |
13 |
14 |
15 |
69 |
70 |
71 |
72 |
73 |
78 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/src/public/webchat.js.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/public/webchat.js.gz
--------------------------------------------------------------------------------
/src/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | ; asyncio_mode = auto
3 | asyncio_default_fixture_loop_scope = function
--------------------------------------------------------------------------------
/src/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp==3.9.5
2 | aiohttp-compress==0.2.1
3 | azure-core==1.31.0
4 | azure-cosmos==4.8.0
5 | azure-identity==1.19.0
6 | azure-keyvault-secrets==4.9.0
7 | azure-search==1.0.0b2
8 | azure-search-documents==11.5.1
9 | botbuilder-core==4.16.1
10 | botbuilder-dialogs==4.16.1
11 | botbuilder-integration-aiohttp==4.16.1
12 | python-dotenv==1.0.1
13 | pytest-aiohttp==1.0.5
14 | openai==1.55.3
15 | azure_ai_projects-1.0.0b1-py3-none-any.whl
--------------------------------------------------------------------------------
/src/routes/api/directline.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | import os
5 | import requests
6 | from aiohttp import web
7 | from aiohttp.web import Request, Response, json_response
8 | from azure.keyvault.secrets import SecretClient
9 |
10 | def directline_routes(secret_client: SecretClient):
11 | direct_line_secret = os.getenv('AZURE_DIRECT_LINE_SECRET', secret_client.get_secret("AZURE-DIRECT-LINE-SECRET").value)
12 | async def get_directline_token(req: Request) -> Response:
13 | user_id = f"dl_{os.urandom(16).hex()}"
14 | headers = {
15 | "Authorization": f"Bearer {direct_line_secret}",
16 | "Content-type": "application/json"
17 | }
18 | body = {
19 | "User": { "Id": user_id }
20 | }
21 | response = requests.post("https://directline.botframework.com/v3/directline/tokens/generate", headers=headers, json=body)
22 | token_response = response
23 | return json_response(token_response.json(), status=token_response.status_code)
24 |
25 |
26 | return [
27 | web.get("/api/directline/token", get_directline_token)
28 | ]
29 |
--------------------------------------------------------------------------------
/src/routes/api/files.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | from aiohttp import web
5 | from aiohttp.web import Request, Response, StreamResponse
6 | from azure.ai.projects.operations import AgentsOperations
7 |
8 |
9 | def file_routes(agents_client: AgentsOperations):
10 | async def get_assistant_file(req: Request) -> Response:
11 | file_id = req.match_info['file_id']
12 | content = agents_client.get_file_content(file_id)
13 | response = StreamResponse()
14 | response.content_type = 'image/png'
15 | await response.prepare(req)
16 | for bytes in content:
17 | await response.write(bytes)
18 | return response
19 |
20 |
21 | return [
22 | web.get(r"/api/files/{file_id:.*}", get_assistant_file)
23 | ]
24 |
--------------------------------------------------------------------------------
/src/routes/api/messages.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | import sys
5 | import traceback
6 | from http import HTTPStatus
7 | from aiohttp import web
8 | from aiohttp.web import Request, Response, json_response
9 | from botbuilder.core import (
10 | ActivityHandler,
11 | TurnContext
12 | )
13 | from botbuilder.integration.aiohttp import CloudAdapter
14 | from botbuilder.schema import Activity
15 |
16 | # Catch-all for errors.
17 | async def on_error(context: TurnContext, error: Exception):
18 | print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr)
19 | traceback.print_exc()
20 |
21 | # Send a message to the user
22 | await context.send_activity("The bot encountered an error or bug.")
23 | await context.send_activity(
24 | "To continue to run this bot, please fix the bot source code."
25 | )
26 | await context.send_activity(str(error))
27 |
28 | def messages_routes(adapter: CloudAdapter, bot: ActivityHandler):
29 | # Listen for incoming requests on /api/messages.
30 | async def messages(req: Request) -> Response:
31 | # Parse incoming request
32 | if "application/json" in req.headers["Content-Type"]:
33 | body = await req.json()
34 | else:
35 | return Response(status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
36 | activity = Activity().deserialize(body)
37 | auth_header = req.headers["Authorization"] if "Authorization" in req.headers else ""
38 |
39 | # Route received a request to adapter for processing
40 | response = await adapter.process_activity(auth_header, activity, bot.on_turn)
41 | if response:
42 | return json_response(data=response.body, status=response.status)
43 | return Response(status=HTTPStatus.OK)
44 |
45 | # Set the error handler on the Adapter.
46 | adapter.on_turn_error = on_error
47 |
48 | return [
49 | web.post("/api/messages", messages)
50 | ]
--------------------------------------------------------------------------------
/src/routes/static/static.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 |
4 | from aiohttp import web
5 | from aiohttp.web import FileResponse
6 |
7 | def static_routes():
8 | return [
9 | web.get("/", lambda _: FileResponse("public/index.html")),
10 | web.static("/public", "public"),
11 | ]
--------------------------------------------------------------------------------
/src/services/bing.py:
--------------------------------------------------------------------------------
1 | import json
2 | import requests
3 |
4 | class BingClient():
5 | def __init__(self, api_key: str, endpoint="https://api.bing.microsoft.com/v7.0/search"):
6 | self.endpoint = endpoint
7 | self.headers = {"Ocp-Apim-Subscription-Key": api_key}
8 | pass
9 |
10 | def query(self, query: str, type: str):
11 | params = {"q": query, "textDecorations": True, "textFormat": "HTML"}
12 | response = requests.get(self.endpoint, headers=self.headers, params=params)
13 | response.raise_for_status()
14 | search_results = response.json()
15 | return json.dumps(search_results)
--------------------------------------------------------------------------------
/src/services/cosmos.py:
--------------------------------------------------------------------------------
1 | """Implements a CosmosDB based storage provider using partitioning for a bot.
2 | """
3 |
4 | # Copyright (c) Microsoft Corporation. All rights reserved.
5 | # Licensed under the MIT License.
6 | from typing import Dict, List
7 | from threading import Lock, Semaphore
8 | import json
9 |
10 | from azure.cosmos import documents, PartitionKey, DatabaseProxy
11 | from azure.identity import ChainedTokenCredential
12 | from jsonpickle.pickler import Pickler
13 | from jsonpickle.unpickler import Unpickler
14 | import azure.cosmos.cosmos_client as cosmos_client # pylint: disable=no-name-in-module,import-error
15 | import azure.cosmos.errors as cosmos_errors # pylint: disable=no-name-in-module,import-error
16 | from botbuilder.core.storage import Storage
17 |
18 | # Copyright (c) Microsoft Corporation. All rights reserved.
19 | # Licensed under the MIT License.
20 | from hashlib import sha256
21 | import warnings
22 | from botbuilder.core.storage import Storage
23 |
24 |
25 | class CosmosDbPartitionedConfig:
26 | """The class for partitioned CosmosDB configuration for the Azure Bot Framework."""
27 |
28 | def __init__(
29 | self,
30 | cosmos_db_endpoint: str = None,
31 | credential: ChainedTokenCredential = None,
32 | database_id: str = None,
33 | container_id: str = None,
34 | cosmos_client_options: dict = None,
35 | container_throughput: int = 400,
36 | key_suffix: str = "",
37 | compatibility_mode: bool = False,
38 | **kwargs,
39 | ):
40 | """Create the Config object.
41 |
42 | :param cosmos_db_endpoint: The CosmosDB endpoint.
43 | :param auth_key: The authentication key for Cosmos DB.
44 | :param database_id: The database identifier for Cosmos DB instance.
45 | :param container_id: The container identifier.
46 | :param cosmos_client_options: The options for the CosmosClient. Currently only supports connection_policy and
47 | consistency_level
48 | :param container_throughput: The throughput set when creating the Container. Defaults to 400.
49 | :param key_suffix: The suffix to be added to every key. The keySuffix must contain only valid ComosDb
50 | key characters. (e.g. not: '\\', '?', '/', '#', '*')
51 | :param compatibility_mode: True if keys should be truncated in order to support previous CosmosDb
52 | max key length of 255.
53 | :return CosmosDbPartitionedConfig:
54 | """
55 | self.__config_file = kwargs.get("filename")
56 | if self.__config_file:
57 | kwargs = json.load(open(self.__config_file))
58 | self.cosmos_db_endpoint = cosmos_db_endpoint or kwargs.get("cosmos_db_endpoint")
59 | self.credential = credential or kwargs.get("credential")
60 | self.database_id = database_id or kwargs.get("database_id")
61 | self.container_id = container_id or kwargs.get("container_id")
62 | self.cosmos_client_options = cosmos_client_options or kwargs.get(
63 | "cosmos_client_options", {}
64 | )
65 | self.container_throughput = container_throughput or kwargs.get(
66 | "container_throughput"
67 | )
68 | self.key_suffix = key_suffix or kwargs.get("key_suffix")
69 | self.compatibility_mode = compatibility_mode or kwargs.get("compatibility_mode")
70 |
71 |
72 | class CosmosDbPartitionedStorage(Storage):
73 | """A CosmosDB based storage provider using partitioning for a bot."""
74 |
75 | def __init__(self, config: CosmosDbPartitionedConfig):
76 | """Create the storage object.
77 |
78 | :param config:
79 | """
80 | super(CosmosDbPartitionedStorage, self).__init__()
81 | self.config = config
82 | self.client = None
83 | self.database = None
84 | self.container = None
85 | self.compatability_mode_partition_key = False
86 | # Lock used for synchronizing container creation
87 | self.__lock = Lock()
88 | if config.key_suffix is None:
89 | config.key_suffix = ""
90 | if not config.key_suffix.__eq__(""):
91 | if config.compatibility_mode:
92 | raise Exception(
93 | "compatibilityMode cannot be true while using a keySuffix."
94 | )
95 | suffix_escaped = CosmosDbKeyEscape.sanitize_key(config.key_suffix)
96 | if not suffix_escaped.__eq__(config.key_suffix):
97 | raise Exception(
98 | f"Cannot use invalid Row Key characters: {config.key_suffix} in keySuffix."
99 | )
100 |
101 | async def read(self, keys: List[str]) -> Dict[str, object]:
102 | """Read storeitems from storage.
103 |
104 | :param keys:
105 | :return dict:
106 | """
107 | if not keys:
108 | raise Exception("Keys are required when reading")
109 |
110 | await self.initialize()
111 |
112 | store_items = {}
113 |
114 | for key in keys:
115 | try:
116 | escaped_key = CosmosDbKeyEscape.sanitize_key(
117 | key, self.config.key_suffix, self.config.compatibility_mode
118 | )
119 | read_item_response = self.container.read_item(
120 | self.__item_link(escaped_key), self.__get_partition_key(escaped_key)
121 | )
122 | document_store_item = read_item_response
123 | if document_store_item:
124 | store_items[document_store_item["realId"]] = self.__create_si(
125 | document_store_item
126 | )
127 | # When an item is not found a CosmosException is thrown, but we want to
128 | # return an empty collection so in this instance we catch and do not rethrow.
129 | # Throw for any other exception.
130 | except cosmos_errors.HttpResponseError as err:
131 | if (
132 | err.status_code
133 | == cosmos_errors.http_constants.StatusCodes.NOT_FOUND
134 | ):
135 | continue
136 | raise err
137 | except Exception as err:
138 | raise err
139 | return store_items
140 |
141 | async def write(self, changes: Dict[str, object]):
142 | """Save storeitems to storage.
143 |
144 | :param changes:
145 | :return:
146 | """
147 | if changes is None:
148 | raise Exception("Changes are required when writing")
149 | if not changes:
150 | return
151 |
152 | await self.initialize()
153 |
154 | for key, change in changes.items():
155 | e_tag = None
156 | if isinstance(change, dict):
157 | e_tag = change.get("e_tag", None)
158 | elif hasattr(change, "e_tag"):
159 | e_tag = change.e_tag
160 | doc = {
161 | "id": CosmosDbKeyEscape.sanitize_key(
162 | key, self.config.key_suffix, self.config.compatibility_mode
163 | ),
164 | "realId": key,
165 | "document": self.__create_dict(change),
166 | }
167 | if e_tag == "":
168 | raise Exception("cosmosdb_storage.write(): etag missing")
169 |
170 | access_condition = {
171 | "accessCondition": {"type": "IfMatch", "condition": e_tag}
172 | }
173 | options = (
174 | access_condition if e_tag != "*" and e_tag and e_tag != "" else None
175 | )
176 | try:
177 | self.container.upsert_item(
178 | doc
179 | )
180 | except cosmos_errors.HttpResponseError as err:
181 | raise err
182 | except Exception as err:
183 | raise err
184 |
185 | async def delete(self, keys: List[str]):
186 | """Remove storeitems from storage.
187 |
188 | :param keys:
189 | :return:
190 | """
191 | await self.initialize()
192 |
193 | for key in keys:
194 | escaped_key = CosmosDbKeyEscape.sanitize_key(
195 | key, self.config.key_suffix, self.config.compatibility_mode
196 | )
197 | try:
198 | self.container.delete_item(
199 | self.__item_link(escaped_key)
200 | )
201 | except cosmos_errors.HttpResponseError as err:
202 | if (
203 | err.status_code
204 | == cosmos_errors.http_constants.StatusCodes.NOT_FOUND
205 | ):
206 | continue
207 | raise err
208 | except Exception as err:
209 | raise err
210 |
211 | async def initialize(self):
212 | if not self.container:
213 | if not self.client:
214 | self.client = cosmos_client.CosmosClient(
215 | self.config.cosmos_db_endpoint,
216 | credential=self.config.credential
217 | )
218 |
219 | if not self.database:
220 | with self.__lock:
221 | self.database = DatabaseProxy(self.client.client_connection, self.config.database_id)
222 | self.__get_or_create_container()
223 |
224 | def __get_or_create_container(self):
225 | with self.__lock:
226 | if not self.container:
227 | self.container = self.database.create_container_if_not_exists(
228 | self.config.container_id,
229 | partition_key=PartitionKey(["/id"], kind=documents.PartitionKind.Hash),
230 | offer_throughput=self.config.container_throughput,
231 | )
232 |
233 | def __get_partition_key(self, key: str) -> str:
234 | return None if self.compatability_mode_partition_key else key
235 |
236 | @staticmethod
237 | def __create_si(result) -> object:
238 | """Create an object from a result out of CosmosDB.
239 |
240 | :param result:
241 | :return object:
242 | """
243 | # get the document item from the result and turn into a dict
244 | doc = result.get("document")
245 | # read the e_tag from Cosmos
246 | if result.get("_etag"):
247 | doc["e_tag"] = result["_etag"]
248 |
249 | result_obj = Unpickler().restore(doc)
250 |
251 | # create and return the object
252 | return result_obj
253 |
254 | @staticmethod
255 | def __create_dict(store_item: object) -> Dict:
256 | """Return the dict of an object.
257 |
258 | This eliminates non_magic attributes and the e_tag.
259 |
260 | :param store_item:
261 | :return dict:
262 | """
263 | # read the content
264 | json_dict = Pickler().flatten(store_item)
265 | if "e_tag" in json_dict:
266 | del json_dict["e_tag"]
267 |
268 | # loop through attributes and write and return a dict
269 | return json_dict
270 |
271 | def __item_link(self, identifier) -> str:
272 | """Return the item link of a item in the container.
273 |
274 | :param identifier:
275 | :return str:
276 | """
277 | return identifier
278 |
279 | @property
280 | def __container_link(self) -> str:
281 | """Return the container link in the database.
282 |
283 | :param:
284 | :return str:
285 | """
286 | return self.config.container_id
287 |
288 | @property
289 | def __database_link(self) -> str:
290 | """Return the database link.
291 |
292 | :return str:
293 | """
294 | return self.config.database_id
295 |
296 |
297 | """Implements a CosmosDB based storage provider.
298 | """
299 | class CosmosDbConfig:
300 | """The class for CosmosDB configuration for the Azure Bot Framework."""
301 |
302 | def __init__(
303 | self,
304 | endpoint: str = None,
305 | masterkey: str = None,
306 | database: str = None,
307 | container: str = None,
308 | partition_key: str = None,
309 | database_creation_options: dict = None,
310 | container_creation_options: dict = None,
311 | **kwargs,
312 | ):
313 | """Create the Config object.
314 |
315 | :param endpoint:
316 | :param masterkey:
317 | :param database:
318 | :param container:
319 | :param filename:
320 | :return CosmosDbConfig:
321 | """
322 | self.__config_file = kwargs.get("filename")
323 | if self.__config_file:
324 | kwargs = json.load(open(self.__config_file))
325 | self.endpoint = endpoint or kwargs.get("endpoint")
326 | self.masterkey = masterkey or kwargs.get("masterkey")
327 | self.database = database or kwargs.get("database", "bot_db")
328 | self.container = container or kwargs.get("container", "bot_container")
329 | self.partition_key = partition_key or kwargs.get("partition_key")
330 | self.database_creation_options = database_creation_options or kwargs.get(
331 | "database_creation_options"
332 | )
333 | self.container_creation_options = container_creation_options or kwargs.get(
334 | "container_creation_options"
335 | )
336 |
337 |
338 | class CosmosDbKeyEscape:
339 | @staticmethod
340 | def sanitize_key(
341 | key: str, key_suffix: str = "", compatibility_mode: bool = True
342 | ) -> str:
343 | """Return the sanitized key.
344 |
345 | Replace characters that are not allowed in keys in Cosmos.
346 |
347 | :param key: The provided key to be escaped.
348 | :param key_suffix: The string to add a the end of all RowKeys.
349 | :param compatibility_mode: True if keys should be truncated in order to support previous CosmosDb
350 | max key length of 255. This behavior can be overridden by setting
351 | cosmosdb_partitioned_config.compatibility_mode to False.
352 | :return str:
353 | """
354 | # forbidden characters
355 | bad_chars = ["\\", "?", "/", "#", "\t", "\n", "\r", "*"]
356 | # replace those with with '*' and the
357 | # Unicode code point of the character and return the new string
358 | key = "".join(map(lambda x: "*" + str(ord(x)) if x in bad_chars else x, key))
359 |
360 | if key_suffix is None:
361 | key_suffix = ""
362 |
363 | return CosmosDbKeyEscape.truncate_key(f"{key}{key_suffix}", compatibility_mode)
364 |
365 | @staticmethod
366 | def truncate_key(key: str, compatibility_mode: bool = True) -> str:
367 | max_key_len = 255
368 |
369 | if not compatibility_mode:
370 | return key
371 |
372 | if len(key) > max_key_len:
373 | aux_hash = sha256(key.encode("utf-8"))
374 | aux_hex = aux_hash.hexdigest()
375 |
376 | key = key[0 : max_key_len - len(aux_hex)] + aux_hex
377 |
378 | return key
--------------------------------------------------------------------------------
/src/services/graph.py:
--------------------------------------------------------------------------------
1 | import json
2 | import requests
3 |
4 | class GraphClient():
5 | def __init__(self, endpoint="https://graph.microsoft.com/v1.0"):
6 | self.endpoint = endpoint
7 | pass
8 |
9 | def schedule_event(self, token: str, subject: str, start: str, end: str):
10 | body = {
11 | "subject": subject,
12 | "start": {
13 | "dateTime": start,
14 | "timeZone": "UTC"
15 | },
16 | "end": {
17 | "dateTime": end,
18 | "timeZone": "UTC"
19 | }
20 | }
21 | response = requests.post(f"{self.endpoint}/me/events", headers={"Authorization": f"Bearer {token}"}, json=body)
22 | response.raise_for_status()
23 | search_results = response.json()
24 | return json.dumps(search_results)
--------------------------------------------------------------------------------
/src/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/tests/__init__.py
--------------------------------------------------------------------------------
/src/tests/test_bot.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import pytest
4 | from dotenv import load_dotenv
5 | from unittest.mock import MagicMock
6 | from botbuilder.core import ConversationState, UserState, MemoryStorage, TurnContext
7 | from botbuilder.schema import Attachment as BotAttachment, ChannelAccount
8 | from azure.identity import DefaultAzureCredential, get_bearer_token_provider
9 | from azure.ai.projects import AIProjectClient
10 | from openai import AzureOpenAI
11 |
12 | from bots import AssistantBot
13 | from services.bing import BingClient
14 | from services.graph import GraphClient
15 | from dialogs import LoginDialog
16 | from data_models import Attachment
17 | from utils import create_or_update_agent
18 |
19 | current_directory = os.path.dirname(__file__)
20 | credential = DefaultAzureCredential(managed_identity_client_id=os.getenv("MicrosoftAppId"))
21 | load_dotenv()
22 |
23 | @pytest.fixture()
24 | async def turn_context(loop):
25 | return MagicMock(spec=TurnContext)
26 |
27 | @pytest.fixture()
28 | async def aoai_client(loop):
29 | aoai_client = AzureOpenAI(
30 | api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
31 | azure_endpoint=os.getenv("AZURE_OPENAI_API_ENDPOINT"),
32 | api_key=os.getenv("AZURE_OPENAI_API_KEY"),
33 | azure_ad_token_provider=get_bearer_token_provider(
34 | credential,
35 | "https://cognitiveservices.azure.com/.default"
36 | )
37 | )
38 | # aoai_client = MagicMock(spec=AzureOpenAI)
39 | return aoai_client
40 |
41 | project_client = AIProjectClient.from_connection_string(
42 | credential=credential,
43 | conn_str=os.getenv("AZURE_AI_PROJECT_CONNECTION_STRING")
44 | )
45 | agents_client = project_client.agents
46 | agent_id = create_or_update_agent(agents_client, os.getenv("AZURE_OPENAI_AGENT_NAME"))
47 |
48 | @pytest.fixture()
49 | async def bot(aoai_client, turn_context):
50 | _bot = AssistantBot(
51 | conversation_state=ConversationState(MemoryStorage()),
52 | user_state=UserState(MemoryStorage()),
53 | aoai_client=aoai_client,
54 | agents_client=agents_client,
55 | agent_id=agent_id,
56 | bing_client=BingClient(os.getenv("AZURE_BING_API_KEY")),
57 | graph_client=GraphClient(),
58 | dialog=LoginDialog()
59 | )
60 | conversation_data = await _bot.conversation_data_accessor.get(turn_context)
61 | conversation_data.thread_id = None
62 | return _bot
63 |
64 | async def test_welcome_message(bot, turn_context):
65 | await bot.on_members_added_activity([ChannelAccount(id='user1')], turn_context)
66 | turn_context.send_activity.assert_called_with(os.getenv("LLM_WELCOME_MESSAGE"))
67 |
68 | async def test_text_message(bot, turn_context):
69 | turn_context.activity.text = "This is a test. Please respond with \"Test succeeded\""
70 | await bot.on_message_activity(turn_context)
71 | assert "Test succeeded" in turn_context.send_activity.mock_calls[0][1][0].text
72 |
73 | async def test_teams_message(bot, turn_context):
74 | # Teams messages contain HTML attachments of the same message, which should be ignored
75 | turn_context.activity.text = "This is a test. Please respond with \"Test succeeded\""
76 | turn_context.activity.attachments = [BotAttachment(content_type="text/html", content="This is a test. Please respond with \"Test succeeded\"", content_url=None)]
77 | await bot.on_message_activity(turn_context)
78 | assert "Test succeeded" in turn_context.send_activity.mock_calls[0][1][0].text
79 |
80 | async def test_streaming_response(bot, turn_context):
81 | turn_context.activity.text = "Please write a paragraph on AI"
82 | turn_context.activity.channel_id = "directline"
83 | await bot.on_message_activity(turn_context)
84 |
85 | assert len(turn_context.send_activity.mock_calls) > 1
86 | # Each new message should contain the previous
87 | for i in range(1, len(turn_context.send_activity.mock_calls)):
88 | assert len(turn_context.send_activity.mock_calls[i][1][0].text) > len(turn_context.send_activity.mock_calls[i-1][1][0].text)
89 | assert turn_context.send_activity.mock_calls[i-1][1][0].text == "Typing..." or \
90 | turn_context.send_activity.mock_calls[i][1][0].text.startswith(turn_context.send_activity.mock_calls[i-1][1][0].text)
91 |
92 | async def test_updating_response(bot, turn_context):
93 | turn_context.activity.text = "Please write a paragraph on AI"
94 | turn_context.activity.channel_id = "msteams"
95 | await bot.on_message_activity(turn_context)
96 |
97 | assert len(turn_context.update_activity.mock_calls) > 1
98 | # Each new message should contain the previous
99 | for i in range(1, len(turn_context.update_activity.mock_calls)):
100 | assert len(turn_context.update_activity.mock_calls[i][1][0].text) > len(turn_context.update_activity.mock_calls[i-1][1][0].text)
101 | assert turn_context.update_activity.mock_calls[i-1][1][0].text == "Typing..." or \
102 | turn_context.update_activity.mock_calls[i][1][0].text.startswith(turn_context.update_activity.mock_calls[i-1][1][0].text)
103 |
104 | async def test_image_response(bot, turn_context):
105 | turn_context.activity.text = "Please plot numbers 1-10"
106 | await bot.on_message_activity(turn_context)
107 | # Should contain an image markdown link in any position
108 | assert re.search(r"!\[.*\]\(.*\)", turn_context.send_activity.mock_calls[0][1][0].text)
109 |
110 | async def test_clear_message(bot, turn_context, aoai_client):
111 | conversation_data = await bot.conversation_data_accessor.get(turn_context)
112 | conversation_data.history = ["message_1", "message_2"]
113 | turn_context.activity.text = "clear"
114 | await bot.on_message_activity(turn_context)
115 | # Should clear the thread id and history from the conversation state
116 | assert conversation_data.thread_id is None
117 | assert len(conversation_data.history) == 0
118 |
119 | async def test_file_upload(bot, turn_context, aoai_client):
120 | attachment = MagicMock(spec=BotAttachment)
121 | attachment.name = "file_name.txt"
122 | attachment.content_type = "file_type"
123 | attachment.content_url = "file_url"
124 | attachment.content = None
125 | turn_context.activity.attachments = [attachment]
126 | await bot.on_message_activity(turn_context)
127 |
128 | async def test_teams_upload(bot, turn_context, aoai_client):
129 | attachment = MagicMock(spec=BotAttachment)
130 | attachment.name = "file_name.txt"
131 | attachment.content_type = "file_type"
132 | attachment.content_url = "file_url"
133 | attachment.content = {
134 | "downloadUrl": f"file://{current_directory}/../../data/ContosoBenefits.pdf",
135 | }
136 | turn_context.activity.attachments = [attachment]
137 | await bot.on_message_activity(turn_context)
138 |
139 | async def test_tool_selection(bot, turn_context, aoai_client):
140 | conversation_data = await bot.conversation_data_accessor.get(turn_context)
141 | conversation_data.attachments = [Attachment(name="file_name.txt", content_type="file_type", url=f"file://{current_directory}/../../data/ContosoBenefits.pdf")]
142 | turn_context.activity.text = ":Code Interpreter"
143 | await bot.on_message_activity(turn_context)
144 |
145 | async def test_image_query(bot, turn_context, aoai_client):
146 | attachment = BotAttachment(
147 | name="fork.jpg",
148 | content_type="image/jpeg",
149 | content_url=f"file://{current_directory}/../../data/fork.jpeg"
150 | )
151 | turn_context.activity.attachments = [attachment]
152 | await bot.on_message_activity(turn_context)
153 | assert "File uploaded: fork.jpg" in turn_context.send_activity.mock_calls[0][1][0].text
154 | assert "Add to a tool?" in turn_context.send_activity.mock_calls[1][1][0].text
155 | conversation_data = await bot.conversation_data_accessor.get(turn_context)
156 | conversation_data.attachments = [Attachment(name="fork.jpg", content_type="image/jpeg", url=f"file://{current_directory}/../../data/fork.jpg")]
157 | turn_context.activity.attachments = []
158 | turn_context.activity.text = "What's in this image?"
159 | await bot.on_message_activity(turn_context)
160 | assert "fork" in turn_context.send_activity.mock_calls[2][1][0].text
161 |
162 | async def test_file_search(bot, turn_context, aoai_client):
163 | attachment = BotAttachment(
164 | name="ContosoBenefits.pdf",
165 | content_type="image/jpeg",
166 | content_url=f"file://{current_directory}/../../data/ContosoBenefits.pdf"
167 | )
168 | turn_context.activity.attachments = [attachment]
169 | await bot.on_message_activity(turn_context)
170 | assert "File uploaded: ContosoBenefits.pdf" in turn_context.send_activity.mock_calls[0][1][0].text
171 | assert "Add to a tool?" in turn_context.send_activity.mock_calls[1][1][0].text
172 | conversation_data = await bot.conversation_data_accessor.get(turn_context)
173 | conversation_data.attachments = [Attachment(name="ContosoBenefits.pdf", content_type="application/pdf", url=f"file://{current_directory}/../../data/ContosoBenefits.pdf")]
174 | turn_context.activity.attachments = []
175 | turn_context.activity.text = ":File Search"
176 | await bot.on_message_activity(turn_context)
177 | assert "added to File Search" in turn_context.send_activity.mock_calls[2][1][0].text
178 | turn_context.activity.text = "What is my dental care coverage limit?"
179 | await bot.on_message_activity(turn_context)
180 | assert "1000" or "1,000" in turn_context.send_activity.mock_calls[3][1][0].text
181 |
--------------------------------------------------------------------------------
/src/tests/test_directline.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pytest
3 | from unittest.mock import MagicMock
4 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication
5 | from azure.identity import DefaultAzureCredential
6 | from azure.keyvault.secrets import SecretClient
7 | from azure.ai.projects.operations import AgentsOperations
8 |
9 | from config import DefaultConfig
10 | from bots import AssistantBot
11 | from app import create_app
12 |
13 |
14 | @pytest.fixture()
15 | async def client(aiohttp_client):
16 | config = DefaultConfig()
17 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config))
18 | bot = MagicMock(spec=AssistantBot)
19 | agents_client = MagicMock(spec=AgentsOperations)
20 | credential = DefaultAzureCredential(managed_identity_client_id=os.getenv("MicrosoftAppId"))
21 | secret_client = SecretClient(vault_url=os.getenv("AZURE_KEY_VAULT_ENDPOINT"), credential=credential)
22 | return await aiohttp_client(create_app(adapter, bot, agents_client, secret_client))
23 |
24 | async def test_directline_token(client):
25 | resp = await client.get('/api/directline/token')
26 | assert resp.status == 200
27 | data = await resp.json()
28 | assert 'conversationId' in data
29 | assert 'token' in data
30 | assert data['token'].startswith('eyJ')
--------------------------------------------------------------------------------
/src/tests/test_files.py:
--------------------------------------------------------------------------------
1 | import os
2 | import io
3 | import pytest
4 | from unittest.mock import MagicMock
5 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication
6 | from azure.keyvault.secrets import SecretClient
7 | from azure.ai.projects.operations import AgentsOperations
8 | from openai.resources.files import Files
9 |
10 | from config import DefaultConfig
11 | from bots import AssistantBot
12 | from app import create_app
13 |
14 | current_directory = os.path.dirname(__file__)
15 |
16 | @pytest.fixture()
17 | async def client(aiohttp_client):
18 | config = DefaultConfig()
19 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config))
20 | bot = MagicMock(spec=AssistantBot)
21 | agents_client = MagicMock(spec=AgentsOperations)
22 | secrets_client = MagicMock(spec=SecretClient)
23 | return await aiohttp_client(create_app(adapter, bot, agents_client, secrets_client))
24 |
25 | async def test_file_download(client):
26 | resp = await client.get('/api/files/MY_FILE')
27 | # resp = await client.get('/api/directline/token')
28 | # assert aoai_client.files.content.assert_called_with('my-file-id')
29 | assert resp.status == 200
--------------------------------------------------------------------------------
/src/tests/test_messages.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from unittest.mock import MagicMock
3 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication
4 | from botbuilder.core import TurnContext
5 | from azure.keyvault.secrets import SecretClient
6 | from azure.ai.projects.operations import AgentsOperations
7 |
8 | from config import DefaultConfig
9 | from bots import AssistantBot
10 | from app import create_app
11 |
12 |
13 | config = DefaultConfig()
14 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config))
15 | bot = MagicMock(spec=AssistantBot)
16 | agents_client = MagicMock(spec=AgentsOperations)
17 | secrets_client = MagicMock(spec=SecretClient)
18 | @pytest.fixture()
19 | async def client(aiohttp_client):
20 | bot.reset_mock()
21 | return await aiohttp_client(create_app(adapter, bot, agents_client, secrets_client))
22 |
23 | async def test_welcome_message(client):
24 | bot.configure_mock(**{"on_turn.return_value": "Mock Response"})
25 | resp = await client.post('/api/messages', json={'type': 'conversationUpdate', 'membersAdded': [{'id': '8c4406d0-9306-11ef-a822-c7d38543b543', 'name': 'Bot'}, {'id': '7c3792c0-c8a3-4df1-9ee8-f5c8b99b53f3', 'name': 'User'}], 'membersRemoved': [], 'channelId': 'emulator', 'conversation': {'id': 'bcabaf70-9311-11ef-a822-c7d38543b543|livechat'}, 'id': 'bcbf3770-9311-11ef-b0e8-69aa58527b92', 'localTimestamp': '2024-10-25T17:43:06-03:00', 'recipient': {'id': '8c4406d0-9306-11ef-a822-c7d38543b543', 'name': 'Bot', 'role': 'bot'}, 'timestamp': '2024-10-25T20:43:06.214Z', 'from': {'id': '7c3792c0-c8a3-4df1-9ee8-f5c8b99b53f3', 'name': 'User', 'role': 'user'}, 'locale': 'en-US', 'serviceUrl': 'http://localhost:55301'})
26 | assert resp.status == 200
27 |
28 | async def test_invalid_content(client):
29 | resp = await client.post('/api/messages', headers={"Content-Type": "text/plain"}, data="Hello, World!")
30 | assert resp.status == 415
31 |
32 | async def test_adapter_on_error(client):
33 | mockContext = MagicMock(spec=TurnContext)
34 | await adapter.on_turn_error(mockContext, Exception("Test Exception"))
35 | mockContext.send_activity.assert_called_with("Test Exception")
--------------------------------------------------------------------------------
/src/tests/test_models.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from unittest.mock import MagicMock
3 | from botbuilder.core import TurnContext
4 | from data_models import ConversationData
5 |
6 | @pytest.fixture()
7 | def turn_context():
8 | return MagicMock(spec=TurnContext)
9 |
10 | def test_add_conversation_turn(turn_context):
11 | conversation_data = ConversationData([], max_turns=6)
12 | conversation_data.add_turn("user", "This is a test.")
13 | conversation_data.add_turn("assistant", "This is a response.")
14 | assert len(conversation_data.history) == 2
15 | conversation_data.add_turn("user", "This is a test.")
16 | conversation_data.add_turn("assistant", "This is a response.")
17 | assert len(conversation_data.history) == 4
18 | conversation_data.add_turn("user", "This is a test.")
19 | conversation_data.add_turn("assistant", "This is a response.")
20 | assert len(conversation_data.history) == 6
21 | # Caps out at max context length
22 | conversation_data.add_turn("user", "This is a test.")
23 | assert len(conversation_data.history) == 6
24 | conversation_data.add_turn("assistant", "This is a response.")
25 | assert len(conversation_data.history) == 6
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/tests/test_sample.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from unittest.mock import MagicMock
3 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication
4 | from azure.keyvault.secrets import SecretClient
5 | from azure.ai.projects.operations import AgentsOperations
6 |
7 | from config import DefaultConfig
8 | from bots import AssistantBot
9 | from app import create_app
10 |
11 |
12 | @pytest.fixture()
13 | async def client(aiohttp_client):
14 | config = DefaultConfig()
15 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config))
16 | bot = MagicMock(spec=AssistantBot)
17 | agents_client = MagicMock(spec=AgentsOperations)
18 | secrets_client = MagicMock(spec=SecretClient)
19 | return await aiohttp_client(create_app(adapter, bot, agents_client, secrets_client))
20 |
21 | async def test_homepage_has_reference(client):
22 | resp = await client.get('/')
23 | assert resp.status == 200
24 | text = await resp.text()
25 | assert 'https://github.com/microsoft/BotFramework-WebChat' in text
--------------------------------------------------------------------------------
/src/tools/BingQuery.txt:
--------------------------------------------------------------------------------
1 | {
2 | "type": "function",
3 | "function": {
4 | "name": "bing_query",
5 | "description": "Search the internet by text using Bing. Terms and conditions require you to explicitly say results were pulled from the web, and list links the links associated with any information you provide. Before using this function, the assistant should always ask whether the user would like it to search the web.",
6 | "parameters": {
7 | "type": "object",
8 | "properties": {
9 | "query": {
10 | "type": "string",
11 | "description": "The query to pass into Bing"
12 | },
13 | "type": {
14 | "type": "string",
15 | "enum": [
16 | "webpages",
17 | "images",
18 | "videos",
19 | "news"
20 | ],
21 | "description": "The result type you are looking for. One of \"webpages\",\"images\",\"videos\",\"news\". If no news are returned, you may try webpages as a fallback."
22 | }
23 | },
24 | "required": [
25 | "query",
26 | "type"
27 | ]
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/src/tools/ImageQuery.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "function",
3 | "function": {
4 | "name": "image_query",
5 | "description": "Get information about an image that was uploaded. Valid only for png, jpg/jpeg, gif and webp files.",
6 | "parameters": {
7 | "type": "object",
8 | "properties": {
9 | "query": {
10 | "type": "string",
11 | "description": "The question about the image"
12 | },
13 | "image_name": {
14 | "type": "string",
15 | "description": "The name of the image file, not including the directory"
16 | }
17 | },
18 | "required": [
19 | "query", "image_name"
20 | ]
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/src/tools/ScheduleEvent.txt:
--------------------------------------------------------------------------------
1 | {
2 | "type": "function",
3 | "function": {
4 | "name": "schedule_event",
5 | "description": "Schedules an event on the calendar",
6 | "parameters": {
7 | "type": "object",
8 | "properties": {
9 | "subject": {
10 | "type": "string",
11 | "description": "The title of the event"
12 | },
13 | "start": {
14 | "type": "string",
15 | "description": "Start time of the event in ISO 8601 format, e.g. 2020-01-01T12:00:00Z"
16 | },
17 | "end": {
18 | "type": "string",
19 | "description": "End time of the event in ISO 8601 format, e.g. 2020-01-01T12:00:00Z"
20 | }
21 | },
22 | "required": [
23 | "subject",
24 | "start",
25 | "end"
26 | ]
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/src/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | from azure.ai.projects.operations import AgentsOperations
4 | from azure.ai.projects.models import CodeInterpreterTool, FileSearchTool, BingGroundingTool
5 |
6 | def create_or_update_agent(
7 | agents_client: AgentsOperations,
8 | agent_name: str
9 | ) -> str:
10 | # Create agent if it doesn't exist
11 | agents = agents_client.list_agents(limit=100)
12 |
13 | options = {
14 | "name": agent_name,
15 | "model": os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"),
16 | "instructions": os.getenv("LLM_INSTRUCTIONS"),
17 | "tools": [
18 | *CodeInterpreterTool().definitions,
19 | *FileSearchTool().definitions,
20 | # *BingGroundingTool(connection_id=os.getenv("AZURE_BING_CONNECTION_ID")).definitions
21 | ],
22 | "headers": {"x-ms-enable-preview": "true"}
23 | }
24 |
25 | for tool in os.listdir("tools"):
26 | if tool.endswith(".json"):
27 | with open(f"tools/{tool}", "r") as f:
28 | options["tools"].append(json.loads(f.read()))
29 | if agents.has_more:
30 | raise Exception("Too many agents")
31 | for agent in agents.data:
32 | if agent.name == os.getenv("AZURE_OPENAI_AGENT_NAME"):
33 | options["assistant_id"] = agent.id
34 | agent = agents_client.update_agent(**options)
35 | break
36 | if "assistant_id" not in options:
37 | agent = agents_client.create_agent(**options)
38 | options["assistant_id"] = agent.id
39 | return options["assistant_id"]
--------------------------------------------------------------------------------