From 84e292ee56282efc7dc924c526833480ddb27636 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 14 Aug 2024 15:52:54 -0700 Subject: [PATCH] adds python example assistant and recent workbench app/service improvements (#21) --- .devcontainer/README.md | 7 + .devcontainer/devcontainer.json | 3 +- examples/python-example01/.black.toml | 5 + examples/python-example01/.env.example | 5 + examples/python-example01/.flake8 | 3 + examples/python-example01/.gitignore | 9 + examples/python-example01/.vscode/launch.json | 49 ++ .../python-example01/.vscode/settings.json | 50 ++ examples/python-example01/Makefile | 5 + examples/python-example01/README.md | 17 + .../python-example01/assistant/__init__.py | 0 examples/python-example01/assistant/chat.py | 133 ++++++ examples/python-example01/assistant/config.py | 39 ++ examples/python-example01/pyproject.toml | 35 ++ .../python-examples01.code-workspace | 8 + .../components/Assistants/AssistantCreate.tsx | 28 +- .../workbench_model.py | 2 + .../semantic_workbench_assistant/__init__.py | 3 +- .../assistant_base.py | 440 ++++++++++++++++++ .../semantic_workbench_assistant/config.py | 6 +- .../semantic_workbench_assistant/storage.py | 28 ++ 21 files changed, 857 insertions(+), 18 deletions(-) create mode 100644 examples/python-example01/.black.toml create mode 100644 examples/python-example01/.env.example create mode 100644 examples/python-example01/.flake8 create mode 100644 examples/python-example01/.gitignore create mode 100644 examples/python-example01/.vscode/launch.json create mode 100644 examples/python-example01/.vscode/settings.json create mode 100644 examples/python-example01/Makefile create mode 100644 examples/python-example01/README.md create mode 100644 examples/python-example01/assistant/__init__.py create mode 100644 examples/python-example01/assistant/chat.py create mode 100644 examples/python-example01/assistant/config.py create mode 100644 examples/python-example01/pyproject.toml create mode 100644 examples/python-example01/python-examples01.code-workspace create mode 100644 semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/assistant_base.py diff --git a/.devcontainer/README.md b/.devcontainer/README.md index d93b5e9e..3f8936f5 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -9,6 +9,13 @@ - 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 + +## Python assistant example + +We have included an example Python assistant that echos the user's input and can serve as a starting point for your own assistant. + +See the [Python assistant example README](../examples/python-example01/README.md) for more details. ## TODO diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b5398b11..0ae17a36 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,7 +43,8 @@ "postCreateCommand": { // 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_workbench_service": "make -C /workspaces/semanticworkbench/semantic-workbench/v1/service", + "make_python_examples01": "make -C /workspaces/semanticworkbench/examples/python-examples01" }, // Configure tool-specific properties. diff --git a/examples/python-example01/.black.toml b/examples/python-example01/.black.toml new file mode 100644 index 00000000..8c9c0e48 --- /dev/null +++ b/examples/python-example01/.black.toml @@ -0,0 +1,5 @@ +[tool.black] +line-length = 120 +target-version = ["py310", "py311"] +preview = true +unstable = true diff --git a/examples/python-example01/.env.example b/examples/python-example01/.env.example new file mode 100644 index 00000000..e8b020ba --- /dev/null +++ b/examples/python-example01/.env.example @@ -0,0 +1,5 @@ +# Description: Example of .env file +# Usage: Copy this file to .env and set the values + +# The ASSISTANT__ prefix is used to group all the environment variables related to the assistant service. +ASSISTANT__ENABLE_DEBUG_OUTPUT=True diff --git a/examples/python-example01/.flake8 b/examples/python-example01/.flake8 new file mode 100644 index 00000000..a9f4b6d6 --- /dev/null +++ b/examples/python-example01/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +exclude = .git,__pycache__,build,dist,venv,.venv,.env,*.egg,*.egg-info,*.egg-info/* diff --git a/examples/python-example01/.gitignore b/examples/python-example01/.gitignore new file mode 100644 index 00000000..2317cebf --- /dev/null +++ b/examples/python-example01/.gitignore @@ -0,0 +1,9 @@ +__pycache__ +.pytest_cache +*.egg* +.data +.venv +venv +.env + +poetry.lock diff --git a/examples/python-example01/.vscode/launch.json b/examples/python-example01/.vscode/launch.json new file mode 100644 index 00000000..ffd1cc57 --- /dev/null +++ b/examples/python-example01/.vscode/launch.json @@ -0,0 +1,49 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "semantic-workbench-app", + "cwd": "${workspaceFolder}/../../semantic-workbench/v1/app", + "skipFiles": ["/**"], + "console": "integratedTerminal", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"] + }, + { + "type": "debugpy", + "request": "launch", + "name": "semantic-workbench-service", + "cwd": "${workspaceFolder}/../../semantic-workbench/v1/service", + "module": "semantic_workbench_service.start", + "justMyCode": false, + "consoleTitle": "semantic-workbench-service", + "args": ["--host", "0.0.0.0", "--port", "3000"] + }, + { + "type": "debugpy", + "request": "launch", + "name": "python-example01 (assistant)", + "cwd": "${workspaceFolder}", + "module": "semantic_workbench_assistant.start", + "args": ["assistant.chat:app", "--port", "3003"], + "consoleTitle": "${workspaceFolderBasename}", + "envFile": "${workspaceFolder}/.env" + } + ], + "compounds": [ + { + "name": "semantic-workbench", + "configurations": ["semantic-workbench-app", "semantic-workbench-service"] + }, + { + "name": "example assistant and semantic-workbench", + "configurations": [ + "semantic-workbench-app", + "semantic-workbench-service", + "python-example01 (assistant)" + ] + } + ] +} diff --git a/examples/python-example01/.vscode/settings.json b/examples/python-example01/.vscode/settings.json new file mode 100644 index 00000000..f9cc7b57 --- /dev/null +++ b/examples/python-example01/.vscode/settings.json @@ -0,0 +1,50 @@ +{ + "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", + "source.formatDocument": "explicit" + } + }, + "search.exclude": { + "**/.venv": true, + "**/.data": true, + "**/__pycache__": true + }, + } diff --git a/examples/python-example01/Makefile b/examples/python-example01/Makefile new file mode 100644 index 00000000..4e1835cb --- /dev/null +++ b/examples/python-example01/Makefile @@ -0,0 +1,5 @@ +repo_root = $(shell git rev-parse --show-toplevel) + +.DEFAULT_GOAL := venv + +include $(repo_root)/build-tools/makefiles/poetry.mk diff --git a/examples/python-example01/README.md b/examples/python-example01/README.md new file mode 100644 index 00000000..03fb47e8 --- /dev/null +++ b/examples/python-example01/README.md @@ -0,0 +1,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 and verify that the workbench app and service are running +- Stop the services and open the `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. +- 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 diff --git a/examples/python-example01/assistant/__init__.py b/examples/python-example01/assistant/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/python-example01/assistant/chat.py b/examples/python-example01/assistant/chat.py new file mode 100644 index 00000000..ac2e9f9c --- /dev/null +++ b/examples/python-example01/assistant/chat.py @@ -0,0 +1,133 @@ +import logging +from typing import AsyncContextManager, Callable, TypeVar +from uuid import UUID + +from semantic_workbench_api_model.workbench_model import ( + ConversationEvent, + ConversationEventType, + NewConversationMessage, +) +from semantic_workbench_assistant import assistant_service +from semantic_workbench_assistant.assistant_base import ( + AssistantBase, + AssistantInstance, + SimpleAssistantConfigStorage, +) + +from assistant.config import AssistantConfigModel + +from . import config + +logger = logging.getLogger(__name__) + +# Example built on top of the Assistant base +# This example demonstrates how to extend the Assistant base +# to add additional configuration fields and UI schema for the configuration fields +# and how to create a new Assistant that uses the extended configuration model + +# Comments marked with "Required", "Optional", and "Custom" indicate the type of code that follows +# Required: code that is required to be implemented for any Assistant +# Optional: code that is optional to implement for any Assistant, allowing for customization +# Custom: code that was added specifically for this example + +# Modify the config.py file to add any additional configuration fields +ConfigT = TypeVar("ConfigT", bound=AssistantConfigModel) + +service_id = "python-example01-assistant.python-example" +service_name = "Python Example 01 Assistant" +service_description = "A starter for building a chat assistant using the Semantic Workbench Assistant SDK." + + +class ChatAssistant(AssistantBase): + + # Optional: override the __init__ method to add any additional initialization logic + def __init__( + self, + register_lifespan_handler: Callable[[Callable[[], AsyncContextManager[None]]], None], + service_id=service_id, + service_name=service_name, + service_description=service_description, + ) -> None: + + super().__init__( + register_lifespan_handler=register_lifespan_handler, + service_id=service_id, + service_name=service_name, + service_description=service_description, + config_storage=SimpleAssistantConfigStorage[config.AssistantConfigModel]( + cls=config.AssistantConfigModel, + default_config=config.AssistantConfigModel(), + ui_schema=config.ui_schema, + ), + ) + + # Optional: Override the on_workbench_event method to provide custom handling of conversation events for this + # assistant + async def on_workbench_event( + self, + assistant_instance: AssistantInstance, + event: ConversationEvent, + ) -> None: + # add any additional event processing logic here + match event.event: + + case ConversationEventType.message_created | ConversationEventType.conversation_created: + # replace the following with your own logic for processing a message created event + return await self.respond_to_conversation(assistant_instance.id, event.conversation_id) + + case _: + # add any additional event processing logic here + pass + + # Custom: Implement a custom method to respond to a conversation + async def respond_to_conversation(self, assistant_id: str, conversation_id: UUID) -> None: + # get the conversation client + conversation_client = self.workbench_client.for_conversation(assistant_id, str(conversation_id)) + + # get the assistant's messages + messages_response = await conversation_client.get_messages() + if len(messages_response.messages) == 0: + # unexpected, no messages in the conversation + return None + + # get the last message + last_message = messages_response.messages[-1] + + # check if the last message was sent by this assistant + if last_message.sender.participant_id == assistant_id: + # ignore messages from this assistant + return + + # get the assistant's configuration, supports overwriting defaults from environment variables + assistant_config = self._config_storage.get_with_defaults_overwritten_from_env(assistant_id) + + # add your custom logic here for generating a response to the last message + # example: for now, just echo the last message back to the conversation + + # send a new message with the echo response + await conversation_client.send_messages( + NewConversationMessage( + content=f"echo: {last_message.content}", + metadata=( + { + "debug": { + "source": "echo", + "original_message": last_message, + } + } + if assistant_config.enable_debug_output + else None + ), + ) + ) + + # add any additional methods or overrides here + # see assistant_base.AssistantBase for examples + + +# Required: Create an instance of the Assistant class +app = assistant_service.create_app( + lambda lifespan: ChatAssistant( + register_lifespan_handler=lifespan.register_handler, + ) +) diff --git a/examples/python-example01/assistant/config.py b/examples/python-example01/assistant/config.py new file mode 100644 index 00000000..9fe27639 --- /dev/null +++ b/examples/python-example01/assistant/config.py @@ -0,0 +1,39 @@ +from typing import Annotated, Self +from pydantic import Field +from semantic_workbench_assistant import config, assistant_base + +# The semantic workbench app uses react-jsonschema-form for rendering +# dyanmic configuration forms based on the configuration model and UI schema +# See: https://rjsf-team.github.io/react-jsonschema-form/docs/ +# Playground / examples: https://rjsf-team.github.io/react-jsonschema-form/ + + +# the workbench app builds dynamic forms based on the configuration model and UI schema +class AssistantConfigModel(assistant_base.AssistantConfigModel): + enable_debug_output: Annotated[ + bool, + Field( + title="Include Debug Output", + description="Include debug output on conversation messages.", + ), + ] = False + + def overwrite_defaults_from_env(self) -> Self: + """ + Overwrite string fields that currently have their default values. Values are + overwritten with values from environment variables or .env file. If a field + is a BaseModel, it will be recursively updated. + """ + updated = config.overwrite_defaults_from_env(self, prefix="assistant", separator="__") + + # Custom: add any additional handling for nested models + + return updated + + # add any additional configuration fields + + +ui_schema = { + # add UI schema for the additional configuration fields + # see: https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema +} diff --git a/examples/python-example01/pyproject.toml b/examples/python-example01/pyproject.toml new file mode 100644 index 00000000..453f4f38 --- /dev/null +++ b/examples/python-example01/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "assistant" +version = "0.1.0" +description = "Example of a python Semantic Workbench assistant." +authors = ["Semantic Workbench Team"] +readme = "README.md" +packages = [{ include = "assistant" }] + +[tool.poetry.dependencies] +python = "~3.11" +openai = "^1.3.9" + +semantic-workbench-api-model = { path = "../../semantic-workbench/v1/service/semantic-workbench-api-model", develop = true, extras=["dev"] } +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 } + +[tool.poetry.extras] +dev = ["black", "flake8"] + +[build-system] +requires = ["poetry-core>=1.2.0"] +build-backend = "poetry.core.masonry.api" + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +line_length = 120 +profile = "black" + +[tool.pyright] +exclude = ["venv", ".venv"] diff --git a/examples/python-example01/python-examples01.code-workspace b/examples/python-example01/python-examples01.code-workspace new file mode 100644 index 00000000..287a8927 --- /dev/null +++ b/examples/python-example01/python-examples01.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": ".", + "name": "examples/python-examples01" + } + ] +} diff --git a/semantic-workbench/v1/app/src/components/Assistants/AssistantCreate.tsx b/semantic-workbench/v1/app/src/components/Assistants/AssistantCreate.tsx index 8aabbbf8..d127b8ad 100644 --- a/semantic-workbench/v1/app/src/components/Assistants/AssistantCreate.tsx +++ b/semantic-workbench/v1/app/src/components/Assistants/AssistantCreate.tsx @@ -222,22 +222,20 @@ export const AssistantCreate: React.FC = (props) => { handleSave(); }} > - - setName(data?.value)} - aria-autocomplete="none" - /> - {!manualEntry && ( - data.optionValue && setAssistantServiceId(data.optionValue as string) - } + onOptionSelect={(_event, data) => { + if (data.optionValue) { + setAssistantServiceId(data.optionValue as string); + } + + if (data.optionText && name === '') { + setName(data.optionText); + } + }} > {options} @@ -253,6 +251,14 @@ export const AssistantCreate: React.FC = (props) => { /> )} + + setName(data?.value)} + aria-autocomplete="none" + /> +