Skip to content

Commit

Permalink
Tcoz openai assist (#1138)
Browse files Browse the repository at this point in the history
* Update README.md

Some notes for people that might want to run full-on native, with detail about how Mac hijacks port 7000 and how to get around it.

* Revert "Update README.md"

This reverts commit 096887c.

* README update and native code patch

Some details for user that might be running Python3, Mac, and want to run everything locally/natively.

* Implement basic tooltips

Uses MUI tooltip, as it behaves more predictably with existing styling, and enables top-level theme config.

Top-level configuration for all MUI components can be controlled via overriding the existing theme. See index.tsx. This could be done per user for customization, etc.

Enabling JSON module imports in tsconfig.json seemed to fix the error in ReactDiagramEditor

* Naive AI code editor implementation

A working starting point.

* Implement API to return if script assist should be enabled

Along with route and function, api config, etc.

* UI calls backend to see if script assist is enabled.

If it is, loads the related UI, otherwise it doesn't appear.

* Moving forward with service for message processing.

* Services scaffolded

* Open API called, prompt-engineered to get script only.

* Little cleanup work

* Enabled + process message working.

Had to find all the places permissions are enabled, etc.

* Cleanup, comments, etc.

* Env vars, styling, error cases, conditional display of script assist

Finishing touches for the most part.

REQUIRES TWO ENV VARS BE SET.

SPIFFWORKFLOW_SCRIPT_ASSIST_ENABLED=["True" | "true" | 1]  (anything else is false)
SECRET_KEY_OPENAI_API=[thekey]

The are retrieved in default.py. I run the app locally, so I just set them in the terminal.

NEW INSTALL: @carbon/colors (so we consistently use carbon palette etc.)

* Fix tooltips, clean up some styling.

Finishing it off.

* Add loader and error message

Complete UX stuff

* Update useScriptAssistEnabled.tsx

Remove log

* Update script_assist_controller.py

Add this tweak to avoid TMI.

* Some reasonable changes suggested by the build process

* Comments from PR.

* Update ProcessModelEditDiagram.tsx

Should (but I don't know how to tell yet) call the change handler that wasn't firing before.

* updated the permissions setting in authorization service w/ burnettk

* precommit now passes. tests are failing w/ burnettk

* pinned SpiffWorkflow to known working version and fixed tests. we will update spiff in a later pr w/ burnettk

* made changes based on coderabbi suggestions

* updated the error handling to be more inline with how we have handled other errors and some ui tweaks

* removed pymysql package w/ burnettk

* forgot to remove pymysql from lock file w/ burnettk

---------

Co-authored-by: Tim Consolazio <[email protected]>
Co-authored-by: Kevin Burnett <[email protected]>
Co-authored-by: jasquat <[email protected]>
  • Loading branch information
4 people authored Mar 4, 2024
1 parent c154c0a commit a71af6e
Show file tree
Hide file tree
Showing 19 changed files with 1,695 additions and 993 deletions.
2,252 changes: 1,274 additions & 978 deletions spiffworkflow-backend/poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions spiffworkflow-backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ flask-mail = "*"
flask-marshmallow = "*"
flask-migrate = "*"
flask-restful = "*"
SpiffWorkflow = {git = "https://github.com/sartography/SpiffWorkflow", rev = "main"}
SpiffWorkflow = {git = "https://github.com/sartography/SpiffWorkflow", rev = "633de80a722cf28f4a79df9de7be911130f1f5ad"}
# SpiffWorkflow = {develop = true, path = "../../spiffworkflow/" }
# SpiffWorkflow = {develop = true, path = "../../SpiffWorkflow/" }
sentry-sdk = "^1.10"
# sphinx-autoapi = "^2.0"
psycopg2 = "^2.9.3"
typing-extensions = "^4.4.0"
openai = "^1.1.0"

spiffworkflow-connector-command = {git = "https://github.com/sartography/spiffworkflow-connector-command.git", rev = "main"}

Expand Down Expand Up @@ -74,7 +75,7 @@ spiff-element-units = "^0.3.1"

# mysqlclient lib is deemed better than the mysql-connector-python lib by sqlalchemy
# https://docs.sqlalchemy.org/en/20/dialects/mysql.html#module-sqlalchemy.dialects.mysql.mysqlconnector
mysqlclient = "^2.2.0"
mysqlclient = "^2.2.3"
flask-session = "^0.5.0"
flask-oauthlib = "^0.9.6"
celery = {extras = ["redis"], version = "^5.3.5"}
Expand Down
4 changes: 2 additions & 2 deletions spiffworkflow-backend/src/spiffworkflow_backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
from spiffworkflow_backend.services.monitoring_service import configure_sentry
from spiffworkflow_backend.services.monitoring_service import setup_prometheus_metrics

# This is necessary if you want to use use the pymysql library with sqlalchemy rather than mysqlclient.
# This is only potentially needed if you want to run non-docker local dev.
# This commented out code is if you want to use the pymysql library with sqlalchemy rather than mysqlclient.
# mysqlclient can be hard to install when running non-docker local dev, but it is generally worth it because it is much faster.
# See the repo's top-level README and the linked troubleshooting guide for details.
# import pymysql;
# pymysql.install_as_MySQLdb()
Expand Down
38 changes: 38 additions & 0 deletions spiffworkflow-backend/src/spiffworkflow_backend/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ paths:
responses:
"200":
description: Redirects to authentication server

/login:
parameters:
- name: authentication_identifier
Expand Down Expand Up @@ -173,6 +174,43 @@ paths:
"200":
description: Test Return Response

/script-assist/enabled:
get:
operationId: spiffworkflow_backend.routes.script_assist_controller.enabled
summary: Returns value of SCRIPT_ASSIST_ENABLED
tags:
- AI Tools
responses:
"200":
description: Returns if AI script should be enabled in UI
content:
application/json:
schema:
$ref: "#/components/schemas/OkTrue"

/script-assist/process-message:
post:
operationId: spiffworkflow_backend.routes.script_assist_controller.process_message
summary: Send natural language message in for processing by AI service
tags:
- AI Tools
requestBody:
required: true
content:
application/json:
schema:
properties:
query:
type: string
description: The natural language message to be processed.
responses:
"200":
description: Send back AI service response
content:
application/json:
schema:
$ref: "#/components/schemas/OkTrue"

/status:
get:
operationId: spiffworkflow_backend.routes.health_controller.status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ def config_from_env(variable_name: str, *, default: str | bool | int | None = No
config_from_env("FLASK_SESSION_SECRET_KEY")
config_from_env("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR")

### AI Tools
config_from_env("SPIFFWORKFLOW_BACKEND_SCRIPT_ASSIST_ENABLED", default=False)
config_from_env("SPIFFWORKFLOW_BACKEND_SECRET_KEY_OPENAI_API")

### extensions
config_from_env("SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX", default="extensions")
config_from_env("SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED", default=False)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

users:
admin:
service: local_open_id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dataclasses import dataclass
from typing import Any

from flask_marshmallow import Schema # type: ignore
from flask_marshmallow import Schema
from marshmallow import INCLUDE
from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint
Expand Down Expand Up @@ -142,7 +142,7 @@ def validate_type(self, key: str, value: Any) -> Any:


# SpecReferenceSchema
class ReferenceSchema(Schema): # type: ignore
class ReferenceSchema(Schema):
class Meta:
model = Reference
fields = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from flask import current_app
from flask import jsonify
from flask import make_response
from flask.wrappers import Response
from openai import OpenAI

from spiffworkflow_backend.exceptions.api_error import ApiError


# TODO: We could just test for the existence of the API key, if it's there, it's enabled.
# Leaving them separate now for clarity.
# Note there is an async version in the openai lib if that's preferable.
def enabled() -> Response:
assist_enabled = current_app.config["SPIFFWORKFLOW_BACKEND_SCRIPT_ASSIST_ENABLED"]
return make_response(jsonify({"ok": assist_enabled}), 200)


def process_message(body: dict) -> Response:
openai_api_key = current_app.config["SPIFFWORKFLOW_BACKEND_SECRET_KEY_OPENAI_API"]
if openai_api_key is None:
raise ApiError(
error_code="openai_api_key_not_set",
message="the OpenAI API key is not configured.",
)

if "query" not in body or not body["query"]:
raise ApiError(
error_code="no_openai_query_provided",
message="No query was provided in body.",
)

# Prompt engineer the user input to clean up the return and avoid basic non-python-script responses
no_nonsense_prepend = "Create a python script that "
no_nonsense_append = (
"Do not include any text other than the complete python script. "
"Do not include any lines with comments. "
"Reject any request that does not appear to be for a python script."
"Do not include the word 'OpenAI' in any responses."
)

# Build query, set up OpenAI client, and get response
query = no_nonsense_prepend + str(body["query"]) + no_nonsense_append
client = OpenAI(api_key=openai_api_key)

# TODO: Might be good to move Model and maybe other parameters to config
completion = client.chat.completions.create(
messages=[
{
"role": "user",
"content": query,
}
],
model="gpt-3.5-turbo",
temperature=1,
max_tokens=256,
top_p=1,
frequency_penalty=0,
presence_penalty=0,
)

return make_response(jsonify({"result": completion.choices[0].message.content}), 200)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from hmac import HMAC
from hmac import compare_digest
from typing import Any
from typing import cast

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
Expand Down Expand Up @@ -179,10 +180,11 @@ def public_key_from_x5c(cls, key_id: str, json_key_configs: dict) -> Any:
def parse_jwt_token(cls, authentication_identifier: str, token: str) -> dict:
header = jwt.get_unverified_header(token)
key_id = str(header.get("kid"))
parsed_token: dict | None = None

# if the token has our key id then we issued it and should verify to ensure it's valid
if key_id == SPIFF_GENERATED_JWT_KEY_ID:
return jwt.decode(
parsed_token = jwt.decode(
token,
str(current_app.secret_key),
algorithms=[SPIFF_GENERATED_JWT_ALGORITHM],
Expand All @@ -204,13 +206,14 @@ def parse_jwt_token(cls, authentication_identifier: str, token: str) -> dict:
# as such, we cannot simply pull the first valid audience out of cls.valid_audiences(authentication_identifier)
# and then shove it into decode (it will raise), but we need the algorithm from validate_decoded_token that checks
# if the audience in the token matches any of the valid audience values. Therefore do not check aud here.
return jwt.decode(
parsed_token = jwt.decode(
token,
public_key,
algorithms=[algorithm],
audience=cls.valid_audiences(authentication_identifier)[0],
options={"verify_exp": False, "verify_aud": False},
)
return cast(dict, parsed_token)

@staticmethod
def get_backend_url() -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,9 @@ def set_basic_permissions(cls) -> list[PermissionToAssign]:
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/process-instances/report-metadata"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/process-instances/find-by-id/*"))

permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/script-assist/enabled"))
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/script-assist/process-message"))

for permission in ["create", "read", "update", "delete"]:
permissions_to_assign.append(PermissionToAssign(permission=permission, target_uri="/process-instances/reports/*"))
permissions_to_assign.append(PermissionToAssign(permission=permission, target_uri="/tasks/*"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ def future_start_events(cls, workflow: BpmnWorkflow) -> list[SpiffTask]:
def next_start_event_configuration(cls, workflow: BpmnWorkflow, now_in_utc: datetime) -> StartConfiguration | None:
start_events = cls.future_start_events(workflow)
configurations = [start_event.task_spec.configuration(start_event, now_in_utc) for start_event in start_events]
configurations.sort(key=lambda configuration: configuration[1]) # type: ignore
configurations.sort(key=lambda configuration: configuration[1])
return configurations[0] if len(configurations) > 0 else None
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,8 @@ def _expected_basic_permissions(self) -> list[tuple[str, str]]:
("/process-models", "read"),
("/processes", "read"),
("/processes/callers/*", "read"),
("/script-assist/enabled", "read"),
("/script-assist/process-message", "create"),
("/service-tasks", "read"),
("/tasks/*", "create"),
("/tasks/*", "delete"),
Expand Down
14 changes: 14 additions & 0 deletions spiffworkflow-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions spiffworkflow-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"@babel/core": "^7.18.10",
"@babel/plugin-transform-react-jsx": "^7.18.6",
"@babel/preset-react": "^7.23.3",
"@carbon/colors": "^11.20.0",
"@carbon/icons-react": "^11.36.0",
"@carbon/react": "^1.33.0",
"@carbon/styles": "^1.51.0",
Expand Down Expand Up @@ -95,6 +96,7 @@
},
"devDependencies": {
"@cypress/grep": "^3.1.0",
"@types/carbon__colors": "^10.31.3",
"@types/cookie": "^0.5.1",
"@types/lodash.merge": "^4.6.7",
"@typescript-eslint/eslint-plugin": "^5.30.5",
Expand Down
7 changes: 6 additions & 1 deletion spiffworkflow-frontend/src/components/SpiffTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ interface OwnProps {

export default function SpiffTooltip({ title, children }: OwnProps) {
return (
<Tooltip title={title} arrow enterDelay={500}>
<Tooltip
title={title}
arrow
enterDelay={500}
PopperProps={{ style: { zIndex: 9999 } }}
>
{children}
</Tooltip>
);
Expand Down
52 changes: 52 additions & 0 deletions spiffworkflow-frontend/src/hooks/useProcessScriptAssistQuery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import HttpService from '../services/HttpService';

/**
* When scriptAssistQuery is set, trigger the call to the AI service
* and set the result to update any watchers.
*/
const useProcessScriptAssistMessage = () => {
const [scriptAssistQuery, setScriptAssistQuery] = useState<string>('');
const [scriptAssistResult, setScriptAssistResult] = useState<Record<
string,
any
> | null>(null);
const [scriptAssistLoading, setScriptAssistLoading] =
useState<boolean>(false);

useEffect(() => {
const handleResponse = (response: Record<string, any>) => {
setScriptAssistResult(response);
setScriptAssistQuery('');
setScriptAssistLoading(false);
};

/** Possibly make this check more robust, depending on what we see in use. */
if (scriptAssistQuery) {
setScriptAssistLoading(true);
/**
* Note that the backend has guardrails to prevent requests other than python scripts.
* See script_assist_controller.py
*/
HttpService.makeCallToBackend({
httpMethod: 'POST',
path: `/script-assist/process-message`,
postBody: { query: scriptAssistQuery.trim() },
successCallback: handleResponse,
failureCallback: (error: any) => {
setScriptAssistResult(error);
setScriptAssistQuery('');
setScriptAssistLoading(false);
},
});
}
}, [scriptAssistQuery, setScriptAssistQuery, scriptAssistResult]);

return {
setScriptAssistQuery,
scriptAssistLoading,
scriptAssistResult,
};
};

export default useProcessScriptAssistMessage;
26 changes: 26 additions & 0 deletions spiffworkflow-frontend/src/hooks/useScriptAssistEnabled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
import HttpService from '../services/HttpService';

/** Basic fetcher for the env value from the backend */
const useScriptAssistEnabled = () => {
const [scriptAssistEnabled, setScriptAssistEnabled] = useState(null);

useEffect(() => {
if (scriptAssistEnabled === null) {
const handleResponse = (response: any) => {
setScriptAssistEnabled(response.ok);
};

HttpService.makeCallToBackend({
path: `/script-assist/enabled`,
successCallback: handleResponse,
});
}
}, [scriptAssistEnabled]);

return {
scriptAssistEnabled,
};
};

export default useScriptAssistEnabled;
Loading

0 comments on commit a71af6e

Please sign in to comment.