chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# Extend Copilot Studio with Semantic Kernel
|
||||
|
||||
This template demonstrates how to build a [Copilot Studio Skill](https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-use-skills) that allows to extend agent capabilities with a custom API running in Azure with the help of the Semantic Kernel.
|
||||
|
||||

|
||||
|
||||
## Rationale
|
||||
|
||||
[Microsoft Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/fundamentals-what-is-copilot-studio) is a graphical, low-code tool for both creating an agent — including building automation with Power Automate — and extending a Microsoft 365 Copilot with your own enterprise data and scenarios.
|
||||
|
||||
However, in some cases you may need to extend the default agent capabilities by leveraing a pro-code approach, where specific requirements apply.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Azure Subscription
|
||||
- Azure CLI
|
||||
- Azure Developer CLI
|
||||
- Python 3.12 or later
|
||||
- A Microsoft 365 tenant with Copilot Studio enabled
|
||||
|
||||
> [!NOTE]
|
||||
> You don't need the Azure subscription to be on the same tenant as the Microsoft 365 tenant where Copilot Studio is enabled.
|
||||
>
|
||||
> However, you need to have the necessary permissions to register an application in the Azure Active Directory of the tenant where Copilot Studio is enabled.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Clone this repository to your local machine.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/microsoft/semantic-kernel
|
||||
cd semantic-kernel/python/samples/demos/copilot_studio_skill
|
||||
```
|
||||
|
||||
2. Create a App Registration in Azure Entra ID, with a client secret.
|
||||
|
||||
```powershell
|
||||
az login --tenant <COPILOT-tenant-id>
|
||||
$appId = az ad app create --display-name "SKCopilotSkill" --query appId -o tsv
|
||||
$secret = az ad app credential reset --id $appId --append --query password -o tsv
|
||||
```
|
||||
|
||||
4. Run `azd up` to deploy the Azure resources.
|
||||
|
||||
```bash
|
||||
azd auth login --tenant <AZURE-tenant-id>
|
||||
azd up
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When prompted, provide the `botAppId`, `botPassword` and `botTenantId` values from above.
|
||||
>
|
||||
> You will also need to input and existing Azure OpenAI resource name and its resource group.
|
||||
|
||||
> [!TIP]
|
||||
> Once the deployment is complete, you can find the URL of the deployed API in the `output` section of the Azure Developer CLI. Copy this URL.
|
||||
|
||||
5. Ensure the App Registration `homeUrl` is set to the URL of the deployed API. This is required for the bot to be able to respond to requests from Copilot Studio.
|
||||
|
||||
6. Register the bot in Copilot Studio as skill
|
||||
|
||||
- Open the Copilot Studio in your Microsoft 365 tenant.
|
||||
- Create a new agent or reuse an existing one.
|
||||
- Go to "Settings" in the upper right corner of the agent page.
|
||||
- Go to the "Skills" tab and click on "Add a skill".
|
||||
- Now input as URL `API_URL/manifest` where `API_URL` is the URL of the deployed API.
|
||||
- Click on "Next" to register the skill.
|
||||
- Once the skill is registered, you can [start using it in your agent](https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-use-skills). Edit a Topic or create a new one, and add the skill as a node to the topic flow.
|
||||
|
||||
## Architecture
|
||||
|
||||
The architecture features `Azure Bot Service` as the main entry point for the requests. The bot service is responsible for routing the requests to the appropriate backend service, which in this case is a custom API running in `Azure Container Apps` leveraging Semantic Kernel.
|
||||
|
||||
Below is the updated markdown content with the new call included:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Clients
|
||||
A[Copilot Studio]
|
||||
end
|
||||
|
||||
C[Azure Bot Service]
|
||||
D["SK App<br/>(Azure Container Apps)"]
|
||||
|
||||
A -- "Initiates Request" --> C
|
||||
C -- "Forwards Request" --> D
|
||||
D -- "Processes & Returns Response" --> C
|
||||
C -- "Routes Response" --> A
|
||||
|
||||
%% Una tantum call to fetch manifest directly from SK App
|
||||
A -- "Fetch Manifest" --> D
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
Please refer to the original [Bot Framework documentation](https://learn.microsoft.com/en-us/azure/bot-service/skill-implement-skill?view=azure-bot-service-4.0&tabs=python) for more details on how to implement the bot service skill and the custom API.
|
||||
|
||||
> [!NOTE]
|
||||
> As of today, Bot Framework SDK offers only `aiohttp` support for Python.
|
||||
@@ -0,0 +1,11 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
|
||||
|
||||
name: azure-bot
|
||||
services:
|
||||
api:
|
||||
project: src/api
|
||||
host: containerapp
|
||||
language: python
|
||||
docker:
|
||||
path: dockerfile
|
||||
remoteBuild: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,115 @@
|
||||
param uniqueId string
|
||||
param prefix string
|
||||
param userAssignedIdentityResourceId string
|
||||
param userAssignedIdentityClientId string
|
||||
param openAiEndpoint string
|
||||
param openAiApiKey string
|
||||
param openAiApiVersion string = '2024-08-01-preview'
|
||||
param openAiModel string = 'gpt-4o'
|
||||
param applicationInsightsConnectionString string
|
||||
param containerRegistry string = '${prefix}acr${uniqueId}'
|
||||
param location string = resourceGroup().location
|
||||
param logAnalyticsWorkspaceName string
|
||||
param apiAppExists bool
|
||||
param emptyContainerImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
|
||||
param botAppId string
|
||||
@secure()
|
||||
param botPassword string
|
||||
param botTenantId string
|
||||
|
||||
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
|
||||
name: logAnalyticsWorkspaceName
|
||||
}
|
||||
|
||||
// see https://azureossd.github.io/2023/01/03/Using-Managed-Identity-and-Bicep-to-pull-images-with-Azure-Container-Apps/
|
||||
resource containerAppEnv 'Microsoft.App/managedEnvironments@2023-11-02-preview' = {
|
||||
name: '${prefix}-containerAppEnv-${uniqueId}'
|
||||
location: location
|
||||
identity: {
|
||||
type: 'UserAssigned'
|
||||
userAssignedIdentities: {
|
||||
'${userAssignedIdentityResourceId}': {}
|
||||
}
|
||||
}
|
||||
properties: {
|
||||
appLogsConfiguration: {
|
||||
destination: 'log-analytics'
|
||||
logAnalyticsConfiguration: {
|
||||
customerId: logAnalyticsWorkspace.properties.customerId
|
||||
sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When azd passes parameters, it will tell if apps were already created
|
||||
// In this case, we don't overwrite the existing image
|
||||
// See https://johnnyreilly.com/using-azd-for-faster-incremental-azure-container-app-deployments-in-azure-devops#the-does-your-service-exist-parameter
|
||||
module fetchLatestImageApi './fetch-container-image.bicep' = {
|
||||
name: 'api-app-image'
|
||||
params: {
|
||||
exists: apiAppExists
|
||||
name: '${prefix}-api-${uniqueId}'
|
||||
}
|
||||
}
|
||||
|
||||
resource apiContainerApp 'Microsoft.App/containerApps@2023-11-02-preview' = {
|
||||
name: '${prefix}-api-${uniqueId}'
|
||||
location: location
|
||||
tags: { 'azd-service-name': 'api' }
|
||||
identity: {
|
||||
type: 'UserAssigned'
|
||||
userAssignedIdentities: {
|
||||
'${userAssignedIdentityResourceId}': {}
|
||||
}
|
||||
}
|
||||
properties: {
|
||||
managedEnvironmentId: containerAppEnv.id
|
||||
configuration: {
|
||||
activeRevisionsMode: 'Single'
|
||||
ingress: {
|
||||
external: true
|
||||
targetPort: 80
|
||||
transport: 'auto'
|
||||
}
|
||||
registries: [
|
||||
{
|
||||
server: '${containerRegistry}.azurecr.io'
|
||||
identity: userAssignedIdentityResourceId
|
||||
}
|
||||
]
|
||||
}
|
||||
template: {
|
||||
scale: {
|
||||
minReplicas: 1
|
||||
maxReplicas: 1
|
||||
}
|
||||
containers: [
|
||||
{
|
||||
name: 'api'
|
||||
image: apiAppExists ? fetchLatestImageApi.outputs.containers[0].image : emptyContainerImage
|
||||
resources: {
|
||||
cpu: 1
|
||||
memory: '2Gi'
|
||||
}
|
||||
env: [
|
||||
{ name: 'AZURE_CLIENT_ID', value: userAssignedIdentityClientId }
|
||||
{ name: 'BOT_APP_ID', value: botAppId }
|
||||
{ name: 'BOT_PASSWORD', value: botPassword }
|
||||
{ name: 'BOT_TENANT_ID', value: botTenantId }
|
||||
{ name: 'APPLICATIONINSIGHTS_CONNECTIONSTRING', value: applicationInsightsConnectionString }
|
||||
{ name: 'APPLICATIONINSIGHTS_SERVICE_NAME', value: 'api' }
|
||||
{ name: 'AZURE_OPENAI_ENDPOINT', value: openAiEndpoint }
|
||||
{ name: 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME', value: openAiModel }
|
||||
{ name: 'AZURE_OPENAI_API_KEY', value: '' }
|
||||
{ name: 'AZURE_OPENAI_API_VERSION', value: openAiApiVersion }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output messagesEndpoint string = 'https://${apiContainerApp.properties.configuration.ingress.fqdn}/api/messages'
|
||||
output manifestUrl string = 'https://${apiContainerApp.properties.configuration.ingress.fqdn}/manifest'
|
||||
output homeUrl string = 'https://${apiContainerApp.properties.configuration.ingress.fqdn}'
|
||||
@@ -0,0 +1,29 @@
|
||||
param uniqueId string
|
||||
param prefix string
|
||||
param userAssignedIdentityPrincipalId string
|
||||
param acrName string = '${prefix}acr${uniqueId}'
|
||||
param location string = resourceGroup().location
|
||||
|
||||
resource acr 'Microsoft.ContainerRegistry/registries@2021-06-01-preview' = {
|
||||
name: acrName
|
||||
location: location
|
||||
sku: {
|
||||
name: 'Standard' // Choose between Basic, Standard, and Premium based on your needs
|
||||
}
|
||||
properties: {
|
||||
adminUserEnabled: false
|
||||
}
|
||||
}
|
||||
|
||||
resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
|
||||
name: guid(acr.id, userAssignedIdentityPrincipalId, 'acrpull')
|
||||
scope: acr
|
||||
properties: {
|
||||
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') // Role definition ID for AcrPull
|
||||
principalId: userAssignedIdentityPrincipalId
|
||||
principalType: 'ServicePrincipal'
|
||||
}
|
||||
}
|
||||
|
||||
output acrName string = acrName
|
||||
output acrEndpoint string = acr.properties.loginServer
|
||||
@@ -0,0 +1,46 @@
|
||||
param uniqueId string
|
||||
param prefix string
|
||||
@secure()
|
||||
param userAssignedIdentityPrincipalId string
|
||||
param location string = resourceGroup().location
|
||||
param appInsightsName string = '${prefix}-appin-${uniqueId}'
|
||||
param logAnalyticsWorkspaceName string = '${prefix}-law-${uniqueId}'
|
||||
|
||||
// Create or reference an existing Log Analytics Workspace
|
||||
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' = {
|
||||
name: logAnalyticsWorkspaceName
|
||||
location: location
|
||||
properties: {
|
||||
sku: {
|
||||
name: 'PerGB2018'
|
||||
}
|
||||
retentionInDays: 30
|
||||
}
|
||||
}
|
||||
|
||||
// Create Application Insights resource linked to the Log Analytics Workspace
|
||||
resource applicationInsights 'Microsoft.Insights/components@2020-02-02-preview' = {
|
||||
name: appInsightsName
|
||||
location: location
|
||||
kind: 'web'
|
||||
properties: {
|
||||
Application_Type: 'web'
|
||||
WorkspaceResourceId: logAnalyticsWorkspace.id
|
||||
}
|
||||
}
|
||||
|
||||
// Assign "Monitoring Metrics Publisher" role to the Application Insights resource
|
||||
resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
|
||||
name: guid(applicationInsights.id, userAssignedIdentityPrincipalId, 'appinsightsPublisher')
|
||||
scope: applicationInsights
|
||||
properties: {
|
||||
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') // Role definition ID for Monitoring Metrics Publisher
|
||||
principalId: userAssignedIdentityPrincipalId
|
||||
principalType: 'ServicePrincipal'
|
||||
}
|
||||
}
|
||||
|
||||
output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id
|
||||
output logAnalyticsWorkspaceName string = logAnalyticsWorkspaceName
|
||||
output applicationInsightsInstrumentationKey string = applicationInsights.properties.InstrumentationKey
|
||||
output applicationInsightsConnectionString string = applicationInsights.properties.ConnectionString
|
||||
@@ -0,0 +1,37 @@
|
||||
param uniqueId string
|
||||
param prefix string
|
||||
param messagesEndpoint string
|
||||
param botAppId string
|
||||
param botTenantId string
|
||||
|
||||
resource bot 'Microsoft.BotService/botServices@2023-09-15-preview' = {
|
||||
name: '${prefix}bot${uniqueId}'
|
||||
location: 'global'
|
||||
sku: {
|
||||
name: 'F0'
|
||||
}
|
||||
kind: 'azurebot'
|
||||
properties: {
|
||||
iconUrl: 'https://docs.botframework.com/static/devportal/client/images/bot-framework-default.png'
|
||||
displayName: '${prefix}bot${uniqueId}'
|
||||
endpoint: messagesEndpoint
|
||||
description: 'Bot created by Bicep'
|
||||
publicNetworkAccess: 'Enabled'
|
||||
msaAppId: botAppId
|
||||
msaAppTenantId: botTenantId
|
||||
msaAppType: 'SingleTenant'
|
||||
msaAppMSIResourceId: null
|
||||
schemaTransformationVersion: '1.3'
|
||||
isStreamingSupported: false
|
||||
}
|
||||
}
|
||||
|
||||
// Connect the bot service to Microsoft Teams
|
||||
resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = {
|
||||
parent: bot
|
||||
location: 'global'
|
||||
name: 'MsTeamsChannel'
|
||||
properties: {
|
||||
channelName: 'MsTeamsChannel'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
param exists bool
|
||||
param name string
|
||||
|
||||
resource existingApp 'Microsoft.App/containerApps@2023-05-01' existing = if (exists) {
|
||||
name: name
|
||||
}
|
||||
|
||||
output containers array = exists ? existingApp.properties.template.containers : []
|
||||
@@ -0,0 +1,141 @@
|
||||
targetScope = 'subscription'
|
||||
|
||||
@minLength(1)
|
||||
@maxLength(64)
|
||||
@description('Name of the environment that can be used as part of naming resource convention')
|
||||
param environmentName string
|
||||
|
||||
@description('The current user ID, to assign RBAC permissions to')
|
||||
param currentUserId string
|
||||
|
||||
// Main deployment parameters
|
||||
param prefix string = 'copstsk'
|
||||
@minLength(1)
|
||||
@description('Primary location for all resources')
|
||||
param location string
|
||||
|
||||
@minLength(1)
|
||||
@description('Name of the Azure OpenAI resource')
|
||||
param openAIName string
|
||||
|
||||
@minLength(1)
|
||||
@description('Name of the Azure Resource Group where the OpenAI resource is located')
|
||||
param openAIResourceGroupName string
|
||||
|
||||
@description('Azure Bot app ID')
|
||||
param botAppId string
|
||||
@description('Azure Bot app password')
|
||||
@secure()
|
||||
param botPassword string
|
||||
@description('Azure Bot tenant ID')
|
||||
param botTenantId string
|
||||
|
||||
param openAIModel string
|
||||
param openAIApiVersion string
|
||||
param apiAppExists bool = false
|
||||
param runningOnGh string = ''
|
||||
|
||||
var tags = {
|
||||
'azd-env-name': environmentName
|
||||
}
|
||||
|
||||
resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = {
|
||||
name: 'rg-${environmentName}'
|
||||
location: location
|
||||
tags: tags
|
||||
}
|
||||
|
||||
var uniqueId = uniqueString(rg.id)
|
||||
var principalType = empty(runningOnGh) ? 'User' : 'ServicePrincipal'
|
||||
|
||||
module uami './uami.bicep' = {
|
||||
name: 'uami'
|
||||
scope: rg
|
||||
params: {
|
||||
uniqueId: uniqueId
|
||||
prefix: prefix
|
||||
location: location
|
||||
}
|
||||
}
|
||||
|
||||
module appin './appin.bicep' = {
|
||||
name: 'appin'
|
||||
scope: rg
|
||||
params: {
|
||||
uniqueId: uniqueId
|
||||
prefix: prefix
|
||||
location: location
|
||||
userAssignedIdentityPrincipalId: uami.outputs.principalId
|
||||
}
|
||||
}
|
||||
|
||||
module acrModule './acr.bicep' = {
|
||||
name: 'acr'
|
||||
scope: rg
|
||||
params: {
|
||||
uniqueId: uniqueId
|
||||
prefix: prefix
|
||||
userAssignedIdentityPrincipalId: uami.outputs.principalId
|
||||
location: location
|
||||
}
|
||||
}
|
||||
|
||||
module openAI './openAI.bicep' = {
|
||||
name: 'openAI'
|
||||
scope: resourceGroup(openAIResourceGroupName)
|
||||
params: {
|
||||
openAIName: openAIName
|
||||
userAssignedIdentityPrincipalId: uami.outputs.principalId
|
||||
}
|
||||
}
|
||||
|
||||
module aca './aca.bicep' = {
|
||||
name: 'aca'
|
||||
scope: rg
|
||||
params: {
|
||||
uniqueId: uniqueId
|
||||
prefix: prefix
|
||||
userAssignedIdentityResourceId: uami.outputs.identityId
|
||||
containerRegistry: acrModule.outputs.acrName
|
||||
location: location
|
||||
logAnalyticsWorkspaceName: appin.outputs.logAnalyticsWorkspaceName
|
||||
applicationInsightsConnectionString: appin.outputs.applicationInsightsConnectionString
|
||||
openAiApiKey: '' // Force ManId, otherwise set openAI.listKeys().key1
|
||||
openAiEndpoint: openAI.outputs.openAIEndpoint
|
||||
openAiModel: openAIModel
|
||||
openAiApiVersion: openAIApiVersion
|
||||
userAssignedIdentityClientId: uami.outputs.clientId
|
||||
apiAppExists: apiAppExists
|
||||
botAppId: botAppId
|
||||
botPassword: botPassword
|
||||
botTenantId: botTenantId
|
||||
}
|
||||
}
|
||||
|
||||
module bot 'bot.bicep' = {
|
||||
name: 'bot'
|
||||
scope: rg
|
||||
params: {
|
||||
uniqueId: uniqueId
|
||||
prefix: prefix
|
||||
botAppId: botAppId
|
||||
botTenantId: botTenantId
|
||||
messagesEndpoint: aca.outputs.messagesEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
// These outputs are copied by azd to .azure/<env name>/.env file
|
||||
// post provision script will use these values, too
|
||||
output AZURE_RESOURCE_GROUP string = rg.name
|
||||
output APPLICATIONINSIGHTS_CONNECTIONSTRING string = appin.outputs.applicationInsightsConnectionString
|
||||
output AZURE_TENANT_ID string = subscription().tenantId
|
||||
output AZURE_USER_ASSIGNED_IDENTITY_ID string = uami.outputs.identityId
|
||||
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrModule.outputs.acrEndpoint
|
||||
output AZURE_OPENAI_MODEL string = openAIModel
|
||||
output AZURE_OPENAI_ENDPOINT string = openAI.outputs.openAIEndpoint
|
||||
output AZURE_OPENAI_API_VERSION string = openAIApiVersion
|
||||
output ENDPOINT_URL string = aca.outputs.messagesEndpoint
|
||||
output MANIFEST_URL string = aca.outputs.manifestUrl
|
||||
output HOME_URL string = aca.outputs.homeUrl
|
||||
output BOT_APP_ID string = botAppId
|
||||
output BOT_TENANT_ID string = botTenantId
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"parameters": {
|
||||
"environmentName": {
|
||||
"value": "${AZURE_ENV_NAME}"
|
||||
},
|
||||
"currentUserId": {
|
||||
"value": "${AZURE_PRINCIPAL_ID}"
|
||||
},
|
||||
"runningOnGh": {
|
||||
"value": "${GITHUB_ACTIONS}"
|
||||
},
|
||||
"location": {
|
||||
"value": "${AZURE_LOCATION}"
|
||||
},
|
||||
"openAiName": {
|
||||
"value": "${AZURE_OPENAI_NAME}"
|
||||
},
|
||||
"openAiResourceGroupName": {
|
||||
"value": "${AZURE_OPENAI_RG}"
|
||||
},
|
||||
"openAIModel": {
|
||||
"value": "${AZURE_OPENAI_MODEL=gpt-4o}"
|
||||
},
|
||||
"openAIApiVersion": {
|
||||
"value": "${AZURE_OPENAI_API_VERSION=2024-08-01-preview}"
|
||||
},
|
||||
"apiAppExists": {
|
||||
"value": "${SERVICE_API_RESOURCE_EXISTS=false}"
|
||||
},
|
||||
"botAppId": {
|
||||
"value": "${BOT_APPID}"
|
||||
},
|
||||
"botPassword": {
|
||||
"value": "${BOT_PASSWORD}"
|
||||
},
|
||||
"botTenantId": {
|
||||
"value": "${BOT_TENANT_ID}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
targetScope = 'resourceGroup'
|
||||
|
||||
param openAIName string
|
||||
param userAssignedIdentityPrincipalId string
|
||||
|
||||
resource openAI 'Microsoft.CognitiveServices/accounts@2022-03-01' existing = {
|
||||
name: openAIName
|
||||
}
|
||||
|
||||
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
|
||||
name: guid(openAI.id, userAssignedIdentityPrincipalId, 'Cognitive Services OpenAI User')
|
||||
scope: openAI
|
||||
properties: {
|
||||
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Role definition ID for Cognitive Services OpenAI User
|
||||
principalId: userAssignedIdentityPrincipalId
|
||||
principalType: 'ServicePrincipal'
|
||||
}
|
||||
}
|
||||
|
||||
output openAIEndpoint string = openAI.properties.endpoint
|
||||
@@ -0,0 +1,13 @@
|
||||
param uniqueId string
|
||||
param prefix string
|
||||
param location string = resourceGroup().location
|
||||
param identityName string = '${prefix}uami${uniqueId}'
|
||||
|
||||
resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = {
|
||||
name: identityName
|
||||
location: location
|
||||
}
|
||||
|
||||
output identityId string = userAssignedIdentity.id
|
||||
output clientId string = userAssignedIdentity.properties.clientId
|
||||
output principalId string = userAssignedIdentity.properties.principalId
|
||||
@@ -0,0 +1,47 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyd
|
||||
|
||||
# Caches of various types
|
||||
.cache/
|
||||
.pip/
|
||||
|
||||
# Development environments
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Project backups
|
||||
*.bak
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories and files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from botbuilder.core import (
|
||||
MessageFactory,
|
||||
TurnContext,
|
||||
)
|
||||
from botbuilder.integration.aiohttp import (
|
||||
CloudAdapter,
|
||||
ConfigurationBotFrameworkAuthentication,
|
||||
)
|
||||
from botbuilder.schema import Activity, ActivityTypes, InputHints
|
||||
|
||||
|
||||
class AdapterWithErrorHandler(CloudAdapter):
|
||||
def __init__(
|
||||
self,
|
||||
settings: ConfigurationBotFrameworkAuthentication,
|
||||
):
|
||||
super().__init__(settings)
|
||||
|
||||
self.on_turn_error = self._handle_turn_error
|
||||
|
||||
async def _handle_turn_error(self, turn_context: TurnContext, error: Exception):
|
||||
# This check writes out errors to console log
|
||||
# NOTE: In production environment, you should consider logging this to Azure
|
||||
# application insights.
|
||||
print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
await self._send_error_message(turn_context, error)
|
||||
await self._send_eoc_to_parent(turn_context, error)
|
||||
|
||||
async def _send_error_message(self, turn_context: TurnContext, error: Exception):
|
||||
try:
|
||||
# Send a message to the user.
|
||||
error_message_text = "The skill encountered an error or bug."
|
||||
error_message = MessageFactory.text(error_message_text, error_message_text, InputHints.ignoring_input)
|
||||
await turn_context.send_activity(error_message)
|
||||
|
||||
error_message_text = "To continue to run this bot, please fix the bot source code."
|
||||
error_message = MessageFactory.text(error_message_text, error_message_text, InputHints.ignoring_input)
|
||||
await turn_context.send_activity(error_message)
|
||||
|
||||
# Send a trace activity, which will be displayed in Bot Framework Emulator.
|
||||
await turn_context.send_trace_activity(
|
||||
label="TurnError",
|
||||
name="on_turn_error Trace",
|
||||
value=f"{error}",
|
||||
value_type="https://www.botframework.com/schemas/error",
|
||||
)
|
||||
except Exception as exception:
|
||||
print(
|
||||
f"\n Exception caught on _send_error_message : {exception}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
traceback.print_exc()
|
||||
|
||||
async def _send_eoc_to_parent(self, turn_context: TurnContext, error: Exception):
|
||||
try:
|
||||
# Send an EndOfConversation activity to the skill caller with the error to end the conversation,
|
||||
# and let the caller decide what to do.
|
||||
end_of_conversation = Activity(type=ActivityTypes.end_of_conversation)
|
||||
end_of_conversation.code = "SkillError"
|
||||
end_of_conversation.text = str(error)
|
||||
|
||||
await turn_context.send_activity(end_of_conversation)
|
||||
except Exception as exception:
|
||||
print(
|
||||
f"\n Exception caught on _send_eoc_to_parent : {exception}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from bot import bot
|
||||
from config import config
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Endpoint for processing messages
|
||||
async def messages(req: Request):
|
||||
"""
|
||||
Endpoint for processing messages with the Skill Bot.
|
||||
"""
|
||||
logger.info("Received a message.")
|
||||
body = await req.json()
|
||||
logger.info("Request body: %s", body)
|
||||
|
||||
# Process the incoming request
|
||||
# NOTE in the context of Skills, we MUST return the response to the Copilot Studio as the response to the request
|
||||
# In other channel (ex. Teams), this would not be required, and activities would be sent to the Bot Framework
|
||||
return await bot.process(req)
|
||||
|
||||
|
||||
async def copilot_manifest(req: Request):
|
||||
# load manifest from file and interpolate with env vars
|
||||
with open("copilot-studio.manifest.json") as f:
|
||||
manifest = f.read()
|
||||
|
||||
# Get container app current ingress fqdn
|
||||
# See https://learn.microsoft.com/en-us/azure/container-apps/environment-variables?tabs=portal
|
||||
fqdn = f"https://{os.getenv('CONTAINER_APP_NAME')}.{os.getenv('CONTAINER_APP_ENV_DNS_SUFFIX')}/api/messages"
|
||||
|
||||
manifest = manifest.replace("__botEndpoint", fqdn).replace("__botAppId", config.APP_ID)
|
||||
|
||||
return Response(
|
||||
text=manifest,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
APP = web.Application()
|
||||
APP.router.add_post("/api/messages", messages)
|
||||
APP.router.add_get("/manifest", copilot_manifest)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
web.run_app(APP, host=config.HOST, port=config.PORT)
|
||||
except Exception as error:
|
||||
raise error
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# See https://github.com/microsoft/BotBuilder-Samples/blob/main/samples/python/80.skills-simple-bot-to-bot/echo-skill-bot/authentication/allowed_callers_claims_validator.py
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from botframework.connector.auth import JwtTokenValidation, SkillValidation
|
||||
from config import Config
|
||||
|
||||
|
||||
class AllowedCallersClaimsValidator:
|
||||
config_key = "ALLOWED_CALLERS"
|
||||
|
||||
def __init__(self, config: Config):
|
||||
if not config:
|
||||
raise TypeError("AllowedCallersClaimsValidator: config object cannot be None.")
|
||||
|
||||
# ALLOWED_CALLERS is the setting in config.py file
|
||||
# that consists of the list of parent bot ids that are allowed to access the skill
|
||||
# to add a new parent bot simply go to the AllowedCallers and add
|
||||
# the parent bot's microsoft app id to the list
|
||||
caller_list = getattr(config, self.config_key)
|
||||
if caller_list is None:
|
||||
raise TypeError(f'"{self.config_key}" not found in configuration.')
|
||||
self._allowed_callers = frozenset(caller_list)
|
||||
|
||||
@property
|
||||
def claims_validator(self) -> Callable[[list[dict]], Awaitable]:
|
||||
async def allow_callers_claims_validator(claims: dict[str, object]):
|
||||
# if allowed_callers is None we allow all calls
|
||||
if "*" not in self._allowed_callers and SkillValidation.is_skill_claim(claims):
|
||||
# Check that the appId claim in the skill request is in the list of skills configured for this bot.
|
||||
app_id = JwtTokenValidation.get_app_id_from_claims(claims)
|
||||
if app_id not in self._allowed_callers:
|
||||
raise PermissionError(
|
||||
f'Received a request from a bot with an app ID of "{app_id}".'
|
||||
f" To enable requests from this caller, add the app ID to your configuration file."
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
return allow_callers_claims_validator
|
||||
@@ -0,0 +1,79 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from adapter import AdapterWithErrorHandler
|
||||
|
||||
# Custom classes to handle errors and claims validation
|
||||
from auth import AllowedCallersClaimsValidator
|
||||
from botbuilder.core import MemoryStorage, MessageFactory, TurnContext
|
||||
from botbuilder.integration.aiohttp import ConfigurationBotFrameworkAuthentication
|
||||
from botbuilder.schema import (
|
||||
Activity,
|
||||
EndOfConversationCodes,
|
||||
InputHints,
|
||||
)
|
||||
from botframework.connector.auth import AuthenticationConfiguration
|
||||
from config import config
|
||||
|
||||
# This is the SK agent that will be used to handle the conversation
|
||||
from sk_conversation_agent import agent
|
||||
from teams import Application, ApplicationOptions
|
||||
from teams.state import TurnState
|
||||
|
||||
from semantic_kernel.contents import ChatHistory
|
||||
|
||||
# This is required for bot to work as Copilot Skill,
|
||||
# not adding a claims validator will result in an error
|
||||
claims_validator = AllowedCallersClaimsValidator(config)
|
||||
auth = AuthenticationConfiguration(tenant_id=config.APP_TENANTID, claims_validator=claims_validator.claims_validator)
|
||||
|
||||
# Create the bot application
|
||||
# We use the Teams Application class to create the bot application,
|
||||
# then we added a custom adapter for skill errors handling.
|
||||
bot = Application[TurnState](
|
||||
ApplicationOptions(
|
||||
bot_app_id=config.APP_ID,
|
||||
storage=MemoryStorage(),
|
||||
# CANNOT PASS A DICT HERE; MUST PASS A CLASS WITH APP_ID, APP_PASSWORD, AND APP_TENANTID ATTRIBUTES
|
||||
adapter=AdapterWithErrorHandler(ConfigurationBotFrameworkAuthentication(config, auth_configuration=auth)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bot.before_turn
|
||||
async def setup_chathistory(context: TurnContext, state: TurnState):
|
||||
chat_history = state.conversation.get("chat_history") or ChatHistory()
|
||||
|
||||
state.conversation["chat_history"] = chat_history
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@bot.activity("message")
|
||||
async def on_message(context: TurnContext, state: TurnState):
|
||||
user_message = context.activity.text
|
||||
|
||||
# Get the chat_history from the conversation state
|
||||
chat_history: ChatHistory = state.conversation.get("chat_history")
|
||||
|
||||
# Add the new user message
|
||||
chat_history.add_user_message(user_message)
|
||||
|
||||
# Get the response from the semantic kernel agent (v1.22.0 and later)
|
||||
sk_response = await agent.get_response(history=chat_history, user_input=user_message)
|
||||
|
||||
# Store the updated chat_history back into conversation state
|
||||
state.conversation["chat_history"] = chat_history
|
||||
|
||||
# Send the response back to the user
|
||||
# NOTE in the context of a Copilot Skill,
|
||||
# the response is sent as a Response from /api/messages endpoint
|
||||
await context.send_activity(MessageFactory.text(sk_response, input_hint=InputHints.ignoring_input))
|
||||
|
||||
# Skills must send an EndOfConversation activity to indicate the conversation is complete
|
||||
# NOTE: this is a simple example, in a real skill you would likely want to send this
|
||||
# only when the user has completed their task
|
||||
end = Activity.create_end_of_conversation_activity()
|
||||
end.code = EndOfConversationCodes.completed_successfully
|
||||
await context.send_activity(end)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
class Config:
|
||||
"""Bot Configuration"""
|
||||
|
||||
HOST = os.getenv("HOST", "localhost")
|
||||
PORT = int(os.getenv("PORT", 8080))
|
||||
|
||||
# DO NOT CHANGE THIS KEYS!!
|
||||
# These keys are used to validate the bot's identity
|
||||
# and must match these named as Bot configuration expects
|
||||
APP_ID = os.getenv("BOT_APP_ID")
|
||||
APP_PASSWORD = os.getenv("BOT_PASSWORD")
|
||||
APP_TENANTID = os.getenv("BOT_TENANT_ID")
|
||||
APP_TYPE = os.getenv("APP_TYPE", "singletenant")
|
||||
|
||||
# Required for Copilot Skill
|
||||
# Can be a list of allowed agent Ids,
|
||||
# or "*" to allow any agent
|
||||
ALLOWED_CALLERS = os.getenv("ALLOWED_CALLERS", ["*"])
|
||||
|
||||
# Required for Azure OpenAI
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME")
|
||||
AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION")
|
||||
|
||||
def validate(self):
|
||||
if not self.HOST or not self.PORT:
|
||||
raise Exception("Missing required configuration. HOST and PORT must be set.")
|
||||
if not self.APP_ID or not self.APP_PASSWORD or not self.APP_TENANTID:
|
||||
raise Exception("Missing required configuration. APP_ID, APP_PASSWORD, and APP_TENANT_ID must be set.")
|
||||
|
||||
if not self.ALLOWED_CALLERS:
|
||||
raise Exception("Missing required configuration. ALLOWED_CALLERS must be set.")
|
||||
|
||||
|
||||
config = Config()
|
||||
config.validate()
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://schemas.botframework.com/schemas/skills/v2.2/skill-manifest.json",
|
||||
"$id": "SKCopilotSkill",
|
||||
"name": "SK Copilot Skill",
|
||||
"version": "1.0",
|
||||
"description": "This is a sample skill using Semantic Kernel",
|
||||
"publisherName": "Microsoft",
|
||||
"privacyUrl": "https://www.microsoft.com/en-us/privacy/privacystatement",
|
||||
"iconUrl": "https://docs.botframework.com/static/devportal/client/images/bot-framework-default.png",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "default",
|
||||
"protocol": "BotFrameworkV3",
|
||||
"description": "Default endpoint for the bot",
|
||||
"endpointUrl": "__botEndpoint",
|
||||
"msAppId": "__botAppId"
|
||||
}
|
||||
],
|
||||
"activities": {
|
||||
"message": {
|
||||
"type": "message",
|
||||
"description": "Invoke Semantic Kernel skill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Step 1 - Install dependencies
|
||||
WORKDIR /app
|
||||
|
||||
# Step 2 - Copy only requirements.txt
|
||||
COPY requirements.txt /app
|
||||
|
||||
# Step 4 - Install pip dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Step 5 - Copy the rest of the files
|
||||
COPY . .
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 80
|
||||
|
||||
ENV HOST 0.0.0.0
|
||||
ENV PORT 80
|
||||
|
||||
# do not change the arguments
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,4 @@
|
||||
python-dotenv>=1.0.1
|
||||
botbuilder-integration-aiohttp>=4.15.0
|
||||
teams-ai>=1.4.0,<2.0.0
|
||||
semantic-kernel>=1.22.0
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
from semantic_kernel.agents import ChatCompletionAgent
|
||||
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
|
||||
|
||||
agent = ChatCompletionAgent(
|
||||
service=AzureChatCompletion(credential=AzureCliCredential()),
|
||||
name="ChatAgent",
|
||||
instructions="You invent jokes to have a fun conversation with the user.",
|
||||
)
|
||||
Reference in New Issue
Block a user