From a59eddacfdf363ef8ceba84a854bcd481348ed00 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 18 Aug 2024 12:02:36 -0700 Subject: [PATCH] Documentation, assistant-base, and python example updates (#27) * add git config autoSetupRemote to devcontainer setup * improved handling of config from env * better azure app service logging support * async keyvault api * adds recommended vscode extensions * improved documentation * additional clean up for python example --- .devcontainer/README.md | 73 ++++++++++--- .devcontainer/devcontainer.json | 7 +- .vscode/extensions.json | 11 ++ .vscode/settings.json | 35 ++++++ README.md | 8 +- docs/ASSISTANT_DEVELOPMENT_GUIDE.md | 65 +++++------ docs/CUSTOM_APP_REGISTRATION.md | 39 +++++++ docs/LOCAL_ASSISTANT_WITH_REMOTE_WORKBENCH.md | 5 +- .../python-example01/.vscode/settings.json | 102 ++++++++++-------- examples/python-example01/README.md | 24 ++++- examples/python-example01/pyproject.toml | 3 +- semantic-workbench/v1/app/src/Constants.ts | 5 +- .../assistant_base.py | 6 +- .../semantic_workbench_assistant/config.py | 37 ++++--- .../logging_config.py | 38 ++++++- .../assistant_api_key.py | 68 +++++++----- .../controller/assistant.py | 81 +++++++++----- .../assistant_service_registration.py | 28 +++-- 18 files changed, 434 insertions(+), 201 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 docs/CUSTOM_APP_REGISTRATION.md diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 3f8936f5..a8a93b0d 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -1,23 +1,64 @@ # Using GitHub Codespaces with devcontainers for Semantic Workbench -- Create app registration at https://portal.azure.com - - Name: Semantic Workbench - - Supported account types: Accounts in any organizational directory and personal Microsoft accounts - - Redirect URI: Single-page application (SPA) & `https://-4000.app.github.dev` - - Copy the Application (client) ID from Overview -- Update `constants.ts` with the Application (client) ID -- Update `middleware.py` with the Application (client) ID -- Use VS Code > Run and Debug > `Semantic Workbench` to start both the app and the service -- After launching semantic-workbench-service, go to Ports and make 3000 public -- In the VS Code terminal tab, find the semantic-workbench-app and click on the link to open the app +This folder contains the configuration files for using GitHub Codespaces with devcontainers for Semantic Workbench and assistant development. -## Python assistant example +GitHub Codespaces is a feature of GitHub that provides a cloud-based development environment for your repository. It allows you to develop, build, and test your code in a consistent environment, without needing to install dependencies or configure your local machine. -We have included an example Python assistant that echos the user's input and can serve as a starting point for your own assistant. +## Why -See the [Python assistant example README](../examples/python-example01/README.md) for more details. +- **Consistent environment**: All developers use the same environment, regardless of their local setup. +- **Platform agnostic**: Works on any system with a web browser and internet connection, including Chromebooks, tablets, and mobile devices. +- **Isolated environment**: The devcontainer is isolated from the host machine, so you can install dependencies without affecting your local setup. +- **Quick setup**: You can start developing in a few minutes, without needing to install dependencies or configure your environment. -## TODO +## How to use -- [ ] Add support for reading Application (client) ID from environment variables -- [ ] Improve this README with details on App Registration setup, and more detailed steps +### Pre-requisites + +#### Create a new GitHub Codespace + +- Open the repository in GitHub Codespaces + - Navigate to the [repository in GitHub](https://github.com/microsoft/semanticworkbench) + - Click on the `Code` button and select the `Codespaces` tab + - Click on the `Codespaces` > `+` button + - Allow the Codespace to build and start, which may take a few minutes - you may continue to the next steps while it builds + - Make a note of your Codespace host name, which is the part of the URL before `.github.dev`. + +#### Set up the workbench app and service + +While the Codespaces environment makes it easy to start developing, the hard-coded app registration details in the app and service cannot support a wildcard redirect URI. This means you will need to create your own Azure app registration and update the app and service files with the new app registration details. + +Follow the instructions in the [Custom app registration](../docs/CUSTOM_APP_REGISTRATION.md) guide to create a new Azure app registration and update the app and service files with the new app registration details. + +### Using the Codespace in VS Code + +#### Launch the Codespace + +- Open the repository in VS Code + - Click on the `Code` button and select the Codespaces tab and choose the Codespace you created + - Since the Codespace is available as a Progressive Web App (PWA), you can also run the Codespace as a local app (in its own window, taskbar/dock/home icon, etc.) for quicker access. See the following links for general information on installing PWAs: + - [Microsoft Edge](https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/ux) + - Search the internet for `install PWA on ` for help with other browsers/platforms + +#### Start the app and service + +- Use VS Code > `Run and Debug` (Ctrl/Cmd+Shift+D) > `Semantic Workbench` to start both the app and the service +- Once `semantic-workbench-service` has launched, you will need to make it `public` + - The app uses the service to fetch data and it must be accessible from the browser of the user logged into the app, which is why the service endpoint must be public. This also means it is accessible to anyone with the URL. To provide a minimal level of security, the app registration is required. Any calls to the service must include an access token from a user logged into the configured app registration. + - In VS Code go to the `ports` tab (same pane as the `terminal` where the services are running) + - Right-click on the `service:semantic-workbench-service` (Port 3000) and select `Port Visibility` > `Public` +- In the VS Code `terminal` tab, find the `semantic-workbench-app` and click on the `Local` link to open the app + - This will automatically open the app in a new browser tab, navigating to the correct Codespace host and setting the necessary header values to privately access the app +- You can now interact with the app and service in the browser +- Next steps: + - Launch an assistant service, using an [example assistant](../examples/) or your own assistant + - If launching an assistant service from within the same Codespace, it will be automatically accessible to the Semantic Workbench service + - Add the assistant to the workbench app by clicking the `Add Assistant` button in the app and selecting the assistant from the list + - Configure the assistant and interact with it in the app by clicking on the assistant in the list + - From the assistant configuration screen, click `New Conversation` to start a new conversation with the assistant + +## Assistant service example + +We have included an example Python assistant service that echos the user's input and can serve as a starting point for your own assistant service. + +See the [python-example01/README](../examples/python-example01/README.md) for more details. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0ae17a36..1a8525c6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -44,7 +44,9 @@ // Build and restore dependencies for key projects in the repo "make_workbench_app": "make -C /workspaces/semanticworkbench/semantic-workbench/v1/app", "make_workbench_service": "make -C /workspaces/semanticworkbench/semantic-workbench/v1/service", - "make_python_examples01": "make -C /workspaces/semanticworkbench/examples/python-examples01" + "make_python_examples01": "make -C /workspaces/semanticworkbench/examples/python-example01", + // Set up git to automatically set up the remote when pushing if it doesn't exist + "git_config": "git config --add push.autoSetupRemote true" }, // Configure tool-specific properties. @@ -58,7 +60,8 @@ "esbenp.prettier-vscode", "ms-python.black-formatter", "ms-python.flake8", - "ms-python.python" + "ms-python.python", + "streetsidesoftware.code-spell-checker" ] } } diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..5573bbc3 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "aaron-bond.better-comments", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-python.black-formatter", + "ms-python.flake8", + "ms-python.python", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 823ffb5d..070e19f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -153,5 +153,40 @@ "bold": false, "italic": false } + ], + "cSpell.words": [ + "aarontamasfe", + "appsettings", + "arcname", + "asgi", + "azurewebsites", + "cachetools", + "Codespace", + "Codespaces", + "dbaeumer", + "devcontainer", + "devcontainers", + "devtunnel", + "dotenv", + "esbenp", + "fastapi", + "hashkey", + "httpx", + "jsonlogger", + "jungaretti", + "keyvault", + "levelname", + "levelno", + "msal", + "pydantic", + "pylance", + "pyproject", + "pythonjsonlogger", + "semanticworkbench", + "sqlalchemy", + "sqlmodel", + "tracebacks", + "webservice", + "winget" ] } diff --git a/README.md b/README.md index 47a17f98..53e30c17 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,13 @@ in various development environments. ![Semantic Workbench architecture](docs/architecture-animation.gif) -# Quick start +# Quick start (Recommended) - GitHub Codespaces for turn-key development environment + +GitHub Codespaces provides a cloud-based development environment for your repository. It allows you to develop, build, and test your code in a consistent environment, without needing to install dependencies or configure your local machine. It works with any system with a web browser and internet connection, including Windows, MacOS, Linux, Chromebooks, tablets, and mobile devices. + +See the [GitHub Codespaces / devcontainer README](.devcontainer/README.md) for more information on how to set up and use GitHub Codespaces with Semantic Workbench. + +# Quick start - Local development environment - Start the backend service, see [here for instructions](semantic-workbench/v1/service/README.md). - Start the frontend app, see [here for instructions](semantic-workbench/v1/app/README.md). diff --git a/docs/ASSISTANT_DEVELOPMENT_GUIDE.md b/docs/ASSISTANT_DEVELOPMENT_GUIDE.md index b398637b..5cde376e 100644 --- a/docs/ASSISTANT_DEVELOPMENT_GUIDE.md +++ b/docs/ASSISTANT_DEVELOPMENT_GUIDE.md @@ -3,15 +3,22 @@ ## Overview For assistants to be instantiated in Semantic Workbench, you need to implement an assistant service that the workbench can talk with via http. + We provide several python base classes to make this easier: [semantic-workbench-assistant](../semantic-workbench/v1/service/semantic-workbench-assistant/README.md) Example assistant services: - [Canonical assistant example](../semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/canonical.py) +- Python assistant [example 1](../examples/python-example01/README.md) - .NET agent [example 1](../examples/dotnet-example01/README.md) and [example 2](../examples/dotnet-example02/README.md) ## Top level concepts +RECOMMENDED FOR PYTHON: Use the `semantic-workbench-assistant` base classes to create your assistant service. These classes provide a lot of the boilerplate code for you. + +See the [semantic-workbench-assistant.assistant_base](../service/semantic-workbench-assistant/semantic_workbench_assistant/assistant_base.py) for the base classes +and the Python [example 1](../examples/python-example01/README.md) for an example of how to use them. + ### assistant_service.FastAPIAssistantService Your main job is to implement a service that supports all the Semantic Workbench methods. The [Canonical assistant example](../semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/canonical.py) demonstrates a minimal implementation. @@ -22,42 +29,36 @@ It implements an assistant service that inherits from FastAPIAssistantService: This service is now a FastAPIAssistantService containing all the assistant methods that can be overridden as desired. -## Node Engine assistants - -Though not mandatory, you might find it helpful to use the [Node Engine](https://github.com/microsoft/nodeengine) for creating assistants. -Node Engine gives you utilities for defining complex LLM interactions using a flow-based architecture where a "context" is modified through several flow components. -We have provided a NodeEngineAssistant base class that makes using the Node Engine simpler. +## Assistant service development: general steps -### Inheriting BaseNodeEngineAssistantInstance in your AssistantInstanceModel +- Set up your dev environment + - SUGGESTED: Use GitHub Codespaces for a quick, easy, and consistent dev + environment: [/.devcontainer/README.md](../.devcontainer/README.md) + - ALTERNATIVE: Local setup following the [main README](../README.md#quick-start---local-development-environment) +- Create a dir for your projects. If you create this in the repo root, any assistant example projects will already have the correct relative paths set up to access the `semantic-workbench-*` packages or libraries. +- Create a project for your new assistant in your projects dir, e.g. `//` +- Getting started with the assistant service + - Copy skeleton of an existing project: e.g. one of the projects from the [examples](../examples) directory + - Alternatively, consider using the canonical assistant as a starting point if you want to implement a new custom base + - Set up .env +- Build and Launch assistant. Run workbench service. Run workbench app. Add assistant local url to workbench via UI. +- NOTE: See additional documentation in [/semantic-workbench/v1/app/docs](../semantic-workbench/v1/app/docs/) regarding app features that can be used in the assistant service. -You need to create an instance model for your assistant, e.g. `AssistantInstanceModel`. -It should implement the `node_engine_assistant.BaseAssistantInstance` Pydantic model and ABC. -This Pydantic model holds the id, assistant_name, config, and conversations attributes and the ABC ensures that my assistant sets up `config`, implements a `construct` factory static method, and a `config_ui_schema` static method. -These static methods are used by the workbench service. +## Assistant service deployment -Your assistant (service) should inherit `BaseNodeEngineAssistant` typed as my `AssistantInstanceModel`. -For example: +DISCLAIMER: The security considerations of hosting a service with a public endpoint are beyond the scope of this document. Please ensure you understand the implications of hosting a service before doing so. It is not recommended to host a publicly available instance of the Semantic Workbench app. -```jsx -MyAssistant( - node_engine_assistant.BaseNodeEngineAssistant[AssistantInstanceModel] -): -``` +If you want to deploy your assistant service to a public endpoint, you will need to create your own Azure app registration and update the app and service files with the new app registration details. See the [Custom app registration](../docs/CUSTOM_APP_REGISTRATION.md) guide for more information. -`BaseNodeEngineAssistant` is an ABC generic for any type that inherits from `BaseAssistantInstance` (like my `AssistantInstanceModel`). -This class inherits `assistant_service.FastAPIAssistantService` which is an ABC for all the service methods. -`assistant_service_api(app: FastAPI, service: FastAPIAssistantService)` is a function that configures the app as a `FastApiAssistantService` (implementing its required ABC methods), the implemented methods forwards method calls to the provided `FastAPIAssistantService`. +### Steps -## Assistant service development: general steps +TODO: Add more detailed steps, this is a high-level overview -- Set up your dev environment - - Create a dir for your stuff - - Create a project there - - copy dev env skeleton files: .black.toml, Dockerfile, .gitignore, .flake8, Makefile, pyproject.toml which has all of the above files, ready to go. - - Update README.md -- Getting started with the assistant service - - copy skeleton of project: e.g. a node engine assistant - - Consider using the canonical assistant as a starting point, - - or... copy node_engine scaffolding (make sure to create **init**.py files and a service.py file) - - Set up .env -- Build and Launch assistant. Run workbench service. Run workbench app. Add assistant local url to workbench via UI. +- Create a new Azure app registration +- Update your app and service files with the new app registration details +- Deploy your service to a public endpoint +- Update the workbench app with the new service URL +- Deploy your assistant service to an endpoint that the workbench service can access + - Suggested: limit access to the assistant service to only the workbench service using a private network or other security measures) +- Deploy the workbench app to an endpoint that users can access + - Suggested: limit access to the workbench app to only trusted users diff --git a/docs/CUSTOM_APP_REGISTRATION.md b/docs/CUSTOM_APP_REGISTRATION.md new file mode 100644 index 00000000..de719786 --- /dev/null +++ b/docs/CUSTOM_APP_REGISTRATION.md @@ -0,0 +1,39 @@ +# Custom app registration + +The code in this repo is intended to allow for quick-to-try usage. This includes a hard-coded app registration details in the app and service. While this works for minimal setup for development and usage in localhost environments, you will need to create your own Azure app registration and update the app and service files with the new app registration details if you want to use this in a hosted environment. + +**DISCLAIMER**: The security considerations of hosting a service with a public endpoint are beyond the scope of this document. Please ensure you understand the implications of hosting a service before doing so. It is **not recommended** to host a publicly available instance of the Semantic Workbench app. + +## Create a new Azure app registration + +### Prerequisites + +In order to complete these steps, you will need to have an Azure account. If you don't have an Azure account, you can create a free account by navigating to https://azure.microsoft.com/en-us/free. + +App registration is a free service, but you may need to provide a credit card for verification purposes. + +### Steps + +- Navigate to the [Azure portal > App registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) + - Click on `New registration` and fill in the details + - Name: `Semantic Workbench` (or any name you prefer) + - Supported account types: `Accounts in any organizational directory and personal Microsoft accounts` + - Redirect URI: `Single-page application (SPA)` & `https://` + - Example (if using [Codespaces](../.devcontainer/README.md)): `https://-4000.app.github.dev` + - Click on `Register` +- View the `Overview` page for the newly registered app and copy the `Application (client) ID` for the next steps + +## Update your app and service files with the new app registration details + +Edit the following files with the new app registration details: + +- Semantic Workbench app: [constants.ts](../semantic-workbench/v1/app/src/Constants.ts) + + - Update the `msal.auth.clientId` with the `Application (client) ID` + +- Semantic Workbench service: [middleware.py](../semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/middleware.py) + - Update the `allowed_app_ids` with the `Application (client) ID` + +## TODO + +- [ ] Update the codebase to allow app registration details to be passed in as environment variables diff --git a/docs/LOCAL_ASSISTANT_WITH_REMOTE_WORKBENCH.md b/docs/LOCAL_ASSISTANT_WITH_REMOTE_WORKBENCH.md index e746c140..47de403a 100644 --- a/docs/LOCAL_ASSISTANT_WITH_REMOTE_WORKBENCH.md +++ b/docs/LOCAL_ASSISTANT_WITH_REMOTE_WORKBENCH.md @@ -1,6 +1,7 @@ # Local Assistant / Remote Semantic Workbench This guide will walk you through the process of creating a local assistant that communicates with a remote Semantic Workbench instance. + This guide assumes you have already set up a Semantic Workbench instance and have a basic understanding of how to create an assistant. ## Prerequisites @@ -11,12 +12,12 @@ This guide assumes you have already set up a Semantic Workbench instance and hav ## Steps [Microsoft Dev Tunnel](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/) is a tool that allows you to securely expose your local development environment to the cloud. This is useful for testing your local assistant with a remote Semantic Workbench instance. + The following steps will guide you through the process of setting up a tunnel to your local machine. **Note:** The tunnel will only be accessible while it is running, so you will need to keep the tunnel running while testing and may not want to share the URL with others without being explicit about the availability. -**SECURITY NOTE:** Be aware that the tunnel will expose your local machine to the internet and allow anonymous access to the assistant. -Make sure you are aware of the security implications and take appropriate precautions. +**SECURITY NOTE:** Be aware that the tunnel will expose your local machine to the internet and allow anonymous access to the assistant. Make sure you are aware of the security implications and take appropriate precautions. - [Install](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started?tabs=windows#install) - `winget install Microsoft.devtunnel` (PowerShell) diff --git a/examples/python-example01/.vscode/settings.json b/examples/python-example01/.vscode/settings.json index f9cc7b57..b8893681 100644 --- a/examples/python-example01/.vscode/settings.json +++ b/examples/python-example01/.vscode/settings.json @@ -1,50 +1,58 @@ { - "black-formatter.args": ["--config", "./.black.toml"], - "editor.bracketPairColorization.enabled": true, + "black-formatter.args": ["--config", "./.black.toml"], + "editor.bracketPairColorization.enabled": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + }, + "editor.guides.bracketPairs": "active", + "editor.formatOnPaste": true, + "editor.formatOnType": true, + "editor.formatOnSave": true, + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + "flake8.args": ["--config", "./.flake8"], + "isort.args": ["--profile", "black", "--gitignore"], + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "python.analysis.autoFormatStrings": true, + "python.analysis.autoImportCompletions": true, + "python.analysis.diagnosticMode": "workspace", + "python.analysis.exclude": [ + "**/.venv/**", + "**/.data/**", + "**/__pycache__/**" + ], + "python.analysis.fixAll": ["source.unusedImports"], + "python.analysis.inlayHints.functionReturnTypes": true, + "python.analysis.typeCheckingMode": "basic", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", "editor.codeActionsOnSave": { + "source.unusedImports": "explicit", "source.organizeImports": "explicit", - "source.fixAll": "explicit" - }, - "editor.guides.bracketPairs": "active", - "editor.formatOnPaste": true, - "editor.formatOnType": true, - "editor.formatOnSave": true, - "files.eol": "\n", - "files.trimTrailingWhitespace": true, - "flake8.args": ["--config", "./.flake8"], - "isort.args": ["--profile", "black", "--gitignore"], - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "python.analysis.autoFormatStrings": true, - "python.analysis.autoImportCompletions": true, - "python.analysis.diagnosticMode": "workspace", - "python.analysis.exclude": [ - "**/.venv/**", - "**/.data/**", - "**/__pycache__/**" - ], - "python.analysis.fixAll": ["source.unusedImports"], - "python.analysis.inlayHints.functionReturnTypes": true, - "python.analysis.typeCheckingMode": "basic", - "python.defaultInterpreterPath": "${workspaceFolder}/.venv", - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", - "editor.codeActionsOnSave": { - "source.unusedImports": "explicit", - "source.organizeImports": "explicit", - "source.fixAll": "explicit", - "source.formatDocument": "explicit" - } - }, - "search.exclude": { - "**/.venv": true, - "**/.data": true, - "**/__pycache__": true - }, - } + "source.fixAll": "explicit", + "source.formatDocument": "explicit" + } + }, + "search.exclude": { + "**/.venv": true, + "**/.data": true, + "**/__pycache__": true + }, + "cSpell.words": [ + "Codespaces", + "devcontainer", + "dyanmic", + "jsonschema", + "pydantic", + "pyproject" + ] +} diff --git a/examples/python-example01/README.md b/examples/python-example01/README.md index 37200caa..21bdf928 100644 --- a/examples/python-example01/README.md +++ b/examples/python-example01/README.md @@ -2,16 +2,17 @@ A python chat assistant example that echos the user's input. ## Pre-requisites -- Complete the steps in either: - - [main README](../../README.md) - - [GitHub Codespaces / devcontainers README](../../.devcontainer/README.md) +- Set up your dev environment + - SUGGESTED: Use GitHub Codespaces for a quick, easy, and consistent dev + environment: [/.devcontainer/README.md](../../.devcontainer/README.md) + - ALTERNATIVE: Local setup following the [main README](../../README.md#quick-start---local-development-environment) - Set up and verify that the workbench app and service are running -- Stop the services and open the `python-examples01.code-workspace` in VS Code +- Stop the services and open the [python-examples01.code-workspace](./python-examples01.code-workspace) in VS Code ## Steps - Use VS Code > Run and Debug > `example assistant and semantic-workbench` to start the assistant. -- If running in a devcontainer, follow the instructions in [GitHub Codespaces / devcontainers README](../../.devcontainer/README.md) for any additional steps. +- If running in a devcontainer, follow the instructions in [GitHub Codespaces / devcontainer README](../../.devcontainer/README.md#start-the-app-and-service) for any additional steps. - Return to the workbench app to interact with the assistant - Add a new assistant from the main menu of the app, choose `Python Example 01 Assistant` - Click the newly created assistant to configure and interact with it @@ -28,3 +29,16 @@ poetry install poetry run start-semantic-workbench-assistant assistant.chat:app ``` + +## Create your own assistant + +Copy the contents of this folder to your project. + +- The paths are already set if you put in the same repo root and relative path of `//` +- If placed in a different location, update the references in the `pyproject.toml` to point to the appropriate locations for the `semantic-workbench-*` packages + +## Suggested Development Environment + +- Use GitHub Codespaces for a quick, turn-key dev environment: [/.devcontainer/README.md](../../../.devcontainer/README.md) +- VS Code is recommended for development +- diff --git a/examples/python-example01/pyproject.toml b/examples/python-example01/pyproject.toml index 453f4f38..75b4bd80 100644 --- a/examples/python-example01/pyproject.toml +++ b/examples/python-example01/pyproject.toml @@ -10,9 +10,8 @@ packages = [{ include = "assistant" }] python = "~3.11" openai = "^1.3.9" -semantic-workbench-api-model = { path = "../../semantic-workbench/v1/service/semantic-workbench-api-model", develop = true, extras=["dev"] } +# If you copy this file to your project, you should verify the relative path to the following: semantic-workbench-assistant = { path = "../../semantic-workbench/v1/service/semantic-workbench-assistant", develop = true, extras=["dev"] } -semantic-workbench-service = { path = "../../semantic-workbench/v1/service/semantic-workbench-service", develop = true, extras=["dev"] } black = { version = "^24.3.0", optional = true } flake8 = { version = "^6.1.0", optional = true } diff --git a/semantic-workbench/v1/app/src/Constants.ts b/semantic-workbench/v1/app/src/Constants.ts index 8ef95533..f857c730 100644 --- a/semantic-workbench/v1/app/src/Constants.ts +++ b/semantic-workbench/v1/app/src/Constants.ts @@ -32,7 +32,10 @@ export const Constants = { }, assistantCategories: { Recommended: [''], - 'Example Implementations': ['openai.example', 'canonical-assistant.semantic-workbench'], + 'Example Implementations': [ + 'python-example01-assistant.python-example', + 'canonical-assistant.semantic-workbench', + ], Experimental: [''], }, msal: { diff --git a/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/assistant_base.py b/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/assistant_base.py index a38cba63..077e500a 100644 --- a/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/assistant_base.py +++ b/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/assistant_base.py @@ -21,7 +21,7 @@ import asgi_correlation_id from fastapi import HTTPException, status from fastapi.responses import FileResponse, JSONResponse, StreamingResponse -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ConfigDict, ValidationError from semantic_workbench_api_model import assistant_model, workbench_model from . import assistant_service, settings, storage @@ -33,6 +33,10 @@ class AssistantConfigModel(ABC, BaseModel): + model_config = ConfigDict( + title="Assistant Configuration", + ) + @abstractmethod def overwrite_defaults_from_env(self) -> Self: ... diff --git a/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/config.py b/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/config.py index 3254d366..cb30f1ef 100644 --- a/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/config.py +++ b/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/config.py @@ -88,6 +88,23 @@ def callback_url(self) -> str: _dotenv_values = dotenv.dotenv_values() +def first_env_var(*env_vars: str, include_dotenv: bool = True, include_upper_and_lower: bool = True) -> str | None: + def first_not_none(*vals: str | None) -> str | None: + for val in vals: + if val is not None: + return val + return None + + if include_upper_and_lower: + env_vars = (*env_vars, *[env_var.upper() for env_var in env_vars], *[env_var.lower() for env_var in env_vars]) + + env_values = [os.environ.get(env_var) for env_var in env_vars] + if include_dotenv: + env_values = [*[_dotenv_values.get(env_var) for env_var in env_vars], *env_values] + + return first_not_none(*env_values) + + def overwrite_defaults_from_env(model: ModelT, prefix="", separator="__") -> ModelT: """ Overwrite string fields that currently have their default values. Values are @@ -95,12 +112,6 @@ def overwrite_defaults_from_env(model: ModelT, prefix="", separator="__") -> Mod is a BaseModel, it will be recursively updated. """ - def first_not_none(*vals: str | None) -> str | None: - for val in vals: - if val is not None: - return val - return None - non_defaults = model.model_dump(exclude_defaults=True).keys() updates: dict[str, str | BaseModel] = {} @@ -136,17 +147,9 @@ def first_not_none(*vals: str | None) -> str | None: alias_env_vars.append(f"{prefix}{separator}{alias[0]}".upper()) alias_env_vars.append(str(alias[0]).upper()) - for env_var in [env_var, *alias_env_vars]: - env_key_lower = env_var.lower() - env_value = first_not_none( - os.environ.get(env_var), - os.environ.get(env_key_lower), - _dotenv_values.get(env_var), - _dotenv_values.get(env_key_lower), - ) - if env_value is not None: - updates[field] = env_value - break + env_value = first_env_var(env_var, *alias_env_vars) + if env_value is not None: + updates[field] = env_value case _: continue diff --git a/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/logging_config.py b/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/logging_config.py index ee923352..11665a84 100644 --- a/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/logging_config.py +++ b/semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/logging_config.py @@ -9,23 +9,53 @@ class LoggingSettings(BaseSettings): json_format: bool = False + # The maximum length of the message field in the JSON log output. + # Azure app services have a limit of 16,368 characters for the entire log entry. + # Longer entries will be split into multiple log entries, making it impossible + # to parse the JSON when reading logs. + json_format_maximum_message_length: int = 15_000 log_level: str = "INFO" +class CustomJSONFormatter(jsonlogger.JsonFormatter): + + def __init__(self, *args, **kwargs): + self.max_message_length = kwargs.pop("max_message_length", 15_000) + super().__init__(*args, **kwargs) + + def process_log_record(self, log_record): + """ + Truncate the message if it is too long to ensure that the downstream processors, such as log shipping + and/or logging storage, do not chop it into multiple log entries. + """ + if "message" not in log_record: + return log_record + + message = log_record["message"] + if len(message) <= self.max_message_length: + return log_record + + log_record["message"] = ( + f"{message[:self.max_message_length // 2]}... truncated ...{message[-self.max_message_length // 2:]}" + ) + return log_record + + class JSONHandler(logging.StreamHandler): - def __init__(self): + def __init__(self, max_message_length: int): super().__init__() self.setFormatter( - jsonlogger.JsonFormatter( + CustomJSONFormatter( "%(name)s %(filename)s %(module)s %(lineno)s %(levelname)s %(correlation_id)s %(message)s", timestamp=True, + max_message_length=max_message_length, ) ) class DebugLevelForNoisyLogFilter(logging.Filter): - """Lowers logs for specific routes to DEBUG level.""" + """Lowers log level to DEBUG for logs that match specific logger names and message patterns.""" def __init__(self, log_level: int, *names_and_patterns: tuple[str, re.Pattern]): self._log_level = log_level @@ -48,7 +78,7 @@ def config(settings: LoggingSettings): handler = RichHandler(rich_tracebacks=True) if settings.json_format: - handler = JSONHandler() + handler = JSONHandler(max_message_length=settings.json_format_maximum_message_length) handler.addFilter(asgi_correlation_id.CorrelationIdFilter(uuid_length=8, default_value="-")) handler.addFilter( diff --git a/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/assistant_api_key.py b/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/assistant_api_key.py index 3c6d6d22..96cd98e7 100644 --- a/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/assistant_api_key.py +++ b/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/assistant_api_key.py @@ -2,15 +2,14 @@ import logging import re import secrets as python_secrets -import uuid from typing import Protocol import cachetools import cachetools.keys -from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ResourceNotFoundError -from azure.identity import DefaultAzureCredential -from azure.keyvault.secrets import SecretClient +from azure.identity.aio import DefaultAzureCredential +from azure.keyvault.secrets.aio import SecretClient from . import settings @@ -21,11 +20,11 @@ class ApiKeyStore(Protocol): def generate_key_name(self, identifier: str) -> str: ... - def get(self, key_name: str) -> str | None: ... + async def get(self, key_name: str) -> str | None: ... - def reset(self, key_name: str) -> str: ... + async def reset(self, key_name: str) -> str: ... - def delete(self, key_name: str) -> None: ... + async def delete(self, key_name: str) -> None: ... class KeyVaultApiKeyStore(ApiKeyStore): @@ -36,7 +35,7 @@ class KeyVaultApiKeyStore(ApiKeyStore): def __init__( self, key_vault_url: str, - identity: TokenCredential, + identity: AsyncTokenCredential, ) -> None: self._secret_client = SecretClient(vault_url=key_vault_url, credential=identity) @@ -56,20 +55,21 @@ def generate_key_name(self, identifier: str) -> str: assert re.match(r"^[a-z0-9-]{1,127}$", secret_name) return secret_name - def get(self, key_name: str) -> str | None: + async def get(self, key_name: str) -> str | None: try: - return self._secret_client.get_secret(name=key_name).value + secret = await self._secret_client.get_secret(name=key_name) + return secret.value except ResourceNotFoundError: return None - def reset(self, key_name: str, tags: dict[str, str] = {}) -> str: + async def reset(self, key_name: str) -> str: new_api_key = generate_api_key() - self._secret_client.set_secret(name=key_name, value=new_api_key, tags=tags) + await self._secret_client.set_secret(name=key_name, value=new_api_key) return new_api_key - def delete(self, key_name: str) -> None: + async def delete(self, key_name: str) -> None: try: - self._secret_client.begin_delete_secret(name=key_name).wait() + await self._secret_client.delete_secret(name=key_name) except ResourceNotFoundError: pass @@ -85,33 +85,46 @@ def __init__(self, api_key: str = "") -> None: def generate_key_name(self, identifier: str) -> str: return identifier - def get(self, key_name: str) -> str | None: + async def get(self, key_name: str) -> str | None: return self._api_key - def reset(self, key_name: str) -> str: + async def reset(self, key_name: str) -> str: return self._api_key - def delete(self, key_name: str) -> None: + async def delete(self, key_name: str) -> None: pass def cached(api_key_store: ApiKeyStore, max_cache_size: int, ttl_seconds: float) -> ApiKeyStore: - cache_key = cachetools.keys.hashkey + hash_key = cachetools.keys.hashkey cache = cachetools.TTLCache(maxsize=max_cache_size, ttl=ttl_seconds) - api_key_store.get = cachetools.cached(cache=cache, key=cache_key)(api_key_store.get) - + original_get = api_key_store.get original_reset = api_key_store.reset original_delete = api_key_store.delete - def reset(*args, **kwargs) -> str: - cache.pop(cache_key(*args, **kwargs), None) - return original_reset(*args, **kwargs) + async def get(key_name: str) -> str | None: + cache_key = hash_key(key_name) + if secret := cache.get(cache_key): + return secret + + secret = await original_get(key_name) + if secret is not None: + cache[cache_key] = secret + return secret - def delete(*args, **kwargs) -> None: - cache.pop(cache_key(*args, **kwargs), None) - return original_delete(*args, **kwargs) + async def reset(key_name: str) -> str: + secret = await original_reset(key_name) + cache_key = hash_key(key_name) + cache[cache_key] = secret + return secret + async def delete(key_name: str) -> None: + cache_key = hash_key(key_name) + cache.pop(cache_key, None) + return await original_delete(key_name) + + api_key_store.get = get api_key_store.reset = reset api_key_store.delete = delete @@ -126,9 +139,6 @@ def get_store() -> ApiKeyStore: identity=DefaultAzureCredential(), ) - # ensure that the key vault is accessible - assert key_vault_store.get(f"non-existing-key-{uuid.uuid4().hex}") is None - return cached(api_key_store=key_vault_store, max_cache_size=200, ttl_seconds=10 * 60) logger.info("creating FixedApiKeyStore for local development and testing") diff --git a/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant.py b/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant.py index 837a2509..a5bdbcff 100644 --- a/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant.py +++ b/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant.py @@ -64,8 +64,10 @@ def __init__( self._api_key_store = api_key_store self._httpx_client_factory = httpx_client_factory - def _assistant_client_builder(self, registration: db.AssistantServiceRegistration) -> AssistantServiceClientBuilder: - return assistant_service_client( + async def _assistant_client_builder( + self, registration: db.AssistantServiceRegistration + ) -> AssistantServiceClientBuilder: + return await assistant_service_client( registration=registration, api_key_store=self._api_key_store, httpx_client_factory=self._httpx_client_factory, @@ -106,8 +108,10 @@ async def _ensure_assistant_conversation( return conversation async def _put_assistant(self, assistant: db.Assistant, from_export: IO[bytes] | None) -> None: - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ).for_service().put_assistant_instance( assistant_id=assistant.assistant_id, request=AssistantPutRequestModel(assistant_name=assistant.name), @@ -124,8 +128,10 @@ async def _forward_event_to_assistant(self, assistant: db.Assistant, event: Conv return try: - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ).for_assistant_instance(assistant_id=assistant.assistant_id).post_conversation_event(event=event) except AssistantError: logger.error( @@ -137,8 +143,10 @@ async def _forward_event_to_assistant(self, assistant: db.Assistant, event: Conv ) async def _disconnect_assistant(self, assistant: db.Assistant) -> None: - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ).for_service().delete_assistant_instance(assistant_id=assistant.assistant_id) async def _remove_assistant_from_conversation( @@ -182,8 +190,10 @@ async def _remove_assistant_from_conversation( await session.flush() async def disconnect_assistant_from_conversation(self, conversation_id: uuid.UUID, assistant: db.Assistant) -> None: - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ).for_assistant_instance(assistant_id=assistant.assistant_id).delete_conversation( conversation_id=conversation_id ) @@ -191,10 +201,13 @@ async def disconnect_assistant_from_conversation(self, conversation_id: uuid.UUI async def connect_assistant_to_conversation( self, conversation: db.Conversation, assistant: db.Assistant, from_export: IO[bytes] | None ) -> None: - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ).for_assistant_instance(assistant_id=assistant.assistant_id).put_conversation( - ConversationPutRequestModel(id=str(conversation.conversation_id)), from_export=from_export + ConversationPutRequestModel(id=str(conversation.conversation_id), title=conversation.title), + from_export=from_export, ) async def forward_event_to_assistants(self, event: ConversationEvent) -> None: @@ -391,8 +404,10 @@ async def get_assistant_config( assistant = await self._ensure_assistant(assistant_id=assistant_id, session=session) return ( - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ) .for_assistant_instance(assistant_id=assistant.assistant_id) .get_config() @@ -407,8 +422,10 @@ async def update_assistant_config( assistant = await self._ensure_assistant(assistant_id=assistant_id, session=session) return ( - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ) .for_assistant_instance(assistant_id=assistant.assistant_id) .put_config(updated_config) @@ -426,8 +443,10 @@ async def get_assistant_conversation_state_descriptions( ) return ( - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ) .for_assistant_instance(assistant_id=assistant.assistant_id) .get_state_descriptions(conversation_id=conversation_id) @@ -446,8 +465,10 @@ async def get_assistant_conversation_state( ) return ( - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ) .for_assistant_instance(assistant_id=assistant.assistant_id) .get_state(conversation_id=conversation_id, state_id=state_id) @@ -467,8 +488,10 @@ async def update_assistant_conversation_state( ) return ( - await self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + await ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ) .for_assistant_instance(assistant_id=assistant.assistant_id) .put_state(conversation_id=conversation_id, state_id=state_id, updated_state=updated_state) @@ -493,6 +516,8 @@ async def post_assistant_state_event( conversation_ids.append(participant.conversation_id) match state_event.event: + case "focus": + conversation_event_type = ConversationEventType.assistant_state_focus case "created": conversation_event_type = ConversationEventType.assistant_state_created case "deleted": @@ -581,8 +606,8 @@ async def export_assistant( ): tempfile_workbench.write(file_bytes) - assistant_client = self._assistant_client_builder( - registration=assistant.related_assistant_service_registration + assistant_client = ( + await self._assistant_client_builder(registration=assistant.related_assistant_service_registration) ).for_assistant_instance(assistant_id=assistant.assistant_id) async with assistant_client.get_exported_instance_data() as response: async for chunk in response: @@ -771,8 +796,10 @@ async def export_conversations( ).unique() for assistant in assistants: - assistant_client = self._assistant_client_builder( - registration=assistant.related_assistant_service_registration, + assistant_client = ( + await self._assistant_client_builder( + registration=assistant.related_assistant_service_registration, + ) ).for_assistant_instance(assistant_id=assistant.assistant_id) with tempfile.NamedTemporaryFile(delete=False) as tempfile_assistant: diff --git a/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant_service_registration.py b/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant_service_registration.py index bf5204ad..24cba1cc 100644 --- a/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant_service_registration.py +++ b/semantic-workbench/v1/service/semantic-workbench-service/semantic_workbench_service/controller/assistant_service_registration.py @@ -4,7 +4,6 @@ import httpx from semantic_workbench_api_model.assistant_model import ServiceInfoModel from semantic_workbench_api_model.assistant_service_client import ( - AssistantConnectionError, AssistantServiceClientBuilder, ) from semantic_workbench_api_model.workbench_model import ( @@ -47,7 +46,7 @@ def _registration_is_secured(self) -> bool: async def api_key_source(self, assistant_service_id: str) -> str | None: generated_key_name = self._api_key_store.generate_key_name(assistant_service_id) if assistant_service_id == generated_key_name: - return self._api_key_store.get(generated_key_name) + return await self._api_key_store.get(generated_key_name) async with self._get_session() as session: api_key_name = ( @@ -59,10 +58,12 @@ async def api_key_source(self, assistant_service_id: str) -> str | None: ).first() if api_key_name is None: return None - return self._api_key_store.get(api_key_name) + return await self._api_key_store.get(api_key_name) - def _assistant_client_builder(self, registration: db.AssistantServiceRegistration) -> AssistantServiceClientBuilder: - return assistant_service_client( + async def _assistant_client_builder( + self, registration: db.AssistantServiceRegistration + ) -> AssistantServiceClientBuilder: + return await assistant_service_client( registration=registration, api_key_store=self._api_key_store, httpx_client_factory=self._httpx_client_factory, @@ -101,7 +102,7 @@ async def create_registration( await session.flush() await session.refresh(registration) - api_key = self._api_key_store.reset(registration.api_key_name) + api_key = await self._api_key_store.reset(registration.api_key_name) await session.commit() @@ -143,7 +144,7 @@ async def get_registration(self, assistant_service_id: str) -> AssistantServiceR if registration is None: raise exceptions.NotFoundError() - api_key = self._api_key_store.get(registration.api_key_name) + api_key = await self._api_key_store.get(registration.api_key_name) masked_api_key = self.mask_api_key(api_key) return convert.assistant_service_registration_from_db(registration, api_key=masked_api_key) @@ -301,7 +302,7 @@ async def reset_api_key( if registration is None: raise exceptions.NotFoundError() - api_key = self._api_key_store.reset(registration.api_key_name) + api_key = await self._api_key_store.reset(registration.api_key_name) return convert.assistant_service_registration_from_db(registration, api_key=api_key) @@ -351,7 +352,7 @@ async def delete_registration( await session.delete(registration) await session.commit() - self._api_key_store.delete(registration.api_key_name) + await self._api_key_store.delete(registration.api_key_name) async def get_service_info(self, assistant_service_id: str) -> ServiceInfoModel: async with self._get_session() as session: @@ -366,18 +367,15 @@ async def get_service_info(self, assistant_service_id: str) -> ServiceInfoModel: if registration is None: raise exceptions.NotFoundError() - return await self._assistant_client_builder(registration=registration).for_service().get_service_info() + return await (await self._assistant_client_builder(registration=registration)).for_service().get_service_info() -def assistant_service_client( +async def assistant_service_client( registration: db.AssistantServiceRegistration, api_key_store: assistant_api_key.ApiKeyStore, httpx_client_factory: Callable[[], httpx.AsyncClient], ) -> AssistantServiceClientBuilder: - if registration.assistant_service_url is None: - raise AssistantConnectionError(f"assistant service {registration.assistant_service_id} is offline") - - api_key = api_key_store.get(registration.api_key_name) + api_key = await api_key_store.get(registration.api_key_name) if api_key is None: raise RuntimeError(f"assistant service {registration.assistant_service_id} does not have API key set")