From 5dfe2729b21f37a579457cb71c35eb71b94708db Mon Sep 17 00:00:00 2001 From: Daniel Aanensen <60237496+LVGrinder@users.noreply.github.com> Date: Wed, 17 Apr 2024 07:41:05 +0200 Subject: [PATCH 01/13] Add more tools (#195) * add brave search * add stackapi tool --- apps/api/poetry.lock | 17 +++++- apps/api/pyproject.toml | 1 + apps/api/src/mock.py | 80 +++++++++++++++-------------- apps/api/src/tools/__init__.py | 29 +++++++---- apps/api/src/tools/brave_search.py | 29 +++++++++++ apps/api/src/tools/stackapi_tool.py | 30 +++++++++++ 6 files changed, 137 insertions(+), 49 deletions(-) create mode 100644 apps/api/src/tools/brave_search.py create mode 100644 apps/api/src/tools/stackapi_tool.py diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index 65205dd0..ef97f18c 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -3690,6 +3690,21 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "stackapi" +version = "0.3.0" +description = "Library for interacting with the Stack Exchange API" +optional = false +python-versions = "*" +files = [ + {file = "StackAPI-0.3.0-py3-none-any.whl", hash = "sha256:217f494aae3b4f267a0e4f8565e1761c4e55ec30f0c5a50a205632a52ca28481"}, + {file = "StackAPI-0.3.0.tar.gz", hash = "sha256:4147c9587f1c719d1ff9e01a70216290766821f9f7c1401e47b60ee89c329288"}, +] + +[package.dependencies] +requests = "*" +six = "*" + [[package]] name = "starlette" version = "0.37.2" @@ -4621,4 +4636,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.13" -content-hash = "dfa697f06d9fd1971d71249073d3aa33a62d44eac25a4840697c375f6dc1bfef" +content-hash = "0162a9621b5487162b64103aeb609b65901aecb2ee79b06d6f824b5a49c3cd62" diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 35c753e4..de01e355 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -39,6 +39,7 @@ selenium = "^4.19.0" mail = "^2.1.0" duckduckgo-search = "^5.2.2" langchain-exa = "^0.0.1" +stackapi = "^0.3.0" [tool.poetry.group.dev.dependencies] mypy = "^1.7.0" diff --git a/apps/api/src/mock.py b/apps/api/src/mock.py index be09b92a..37432c51 100644 --- a/apps/api/src/mock.py +++ b/apps/api/src/mock.py @@ -38,50 +38,50 @@ "created_at": "2024-01-01T00:00:00.000Z", } -read_file: dict = { - "id": "00000000-0000-0000-0000-000000000001", - "profile_id": "6fcde4e6-6592-471b-9d33-dbf7e2ecfab4", - "title": "Output file content", - "description": "Read content of file and output it in a nice format", - "receiver_id": "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", - "published": False, - "nodes": [ - "8e26f947-a0e9-4e47-b86f-22930ea948fa", - # "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", - "6e541720-b4ac-4c47-abf3-f17147c9a32a", - ], - "prompt": { - "id": "", - "title": "", - "content": f"Get the file content of the file '{get_file_path_of_example()}', the 'agent python software' can call what function it has been", - }, - "created_at": "2024-01-01T00:00:00.000Z", -} +# read_file: dict = { +# "id": "00000000-0000-0000-0000-000000000001", +# "profile_id": "6fcde4e6-6592-471b-9d33-dbf7e2ecfab4", +# "title": "Output file content", +# "description": "Read content of file and output it in a nice format", +# "receiver_id": "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", +# "published": False, +# "nodes": [ +# "8e26f947-a0e9-4e47-b86f-22930ea948fa", +# # "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", +# "6e541720-b4ac-4c47-abf3-f17147c9a32a", +# ], +# "prompt": { +# "id": "", +# "title": "", +# "content": f"Get the file content of the file '{get_file_path_of_example()}', the 'agent python software' can call what function it has been", +# }, +# "created_at": "2024-01-01T00:00:00.000Z", +# } # "6e541720-b4ac-4c47-abf3-f17147c9a32a", agent for code reviewing # "2ce0b7db-84f7-4d59-8c38-3fcc3fd7da98", agent for writing tables in markdown -move_file: dict = { - "id": "00000000-0000-0000-0000-000000000001", - "profile_id": "6fcde4e6-6592-471b-9d33-dbf7e2ecfab4", - "title": "Output file content", - "description": "Read content of file and output it in a nice format", - "receiver_id": "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", - "published": False, - "nodes": [ - "8e26f947-a0e9-4e47-b86f-22930ea948fa", - # "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", - "6e541720-b4ac-4c47-abf3-f17147c9a32a", - ], - "prompt": { - "id": "", - "title": "", - "content": f"Move the file: '{get_file_path_of_example()}' to the destination: {get_file_path_of_example().replace('.txt', '_2.txt')} the 'agent python software' can call what function it has been", - }, - "created_at": "2024-01-01T00:00:00.000Z", -} +# move_file: dict = { +# "id": "00000000-0000-0000-0000-000000000001", +# "profile_id": "6fcde4e6-6592-471b-9d33-dbf7e2ecfab4", +# "title": "Output file content", +# "description": "Read content of file and output it in a nice format", +# "receiver_id": "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", +# "published": False, +# "nodes": [ +# "8e26f947-a0e9-4e47-b86f-22930ea948fa", +# # "0c0f0b05-e4ff-4d9a-a103-96a9702248f4", +# "6e541720-b4ac-4c47-abf3-f17147c9a32a", +# ], +# "prompt": { +# "id": "", +# "title": "", +# "content": f"Move the file: '{get_file_path_of_example()}' to the destination: {get_file_path_of_example().replace('.txt', '_2.txt')} the 'agent python software' can call what function it has been", +# }, +# "created_at": "2024-01-01T00:00:00.000Z", +# } -tool, prompt = "bing search tool", "what is openai? restrict the number of results to 3" +tool, prompt = "stack api tool", "what is openai?" test_tool: dict = { "id": "00000000-0000-0000-0000-000000000001", @@ -99,5 +99,7 @@ "content": f"This is a tool testing environment, use the tool: {tool}, {prompt}. Suggest this function call", }, "created_at": "2024-01-01T00:00:00.000Z", + "edges": [], + "updated_at": "2024-01-01T00:00:00.000Z", } crew_model = test_tool diff --git a/apps/api/src/tools/__init__.py b/apps/api/src/tools/__init__.py index b5eab4fb..caf7193c 100644 --- a/apps/api/src/tools/__init__.py +++ b/apps/api/src/tools/__init__.py @@ -27,6 +27,10 @@ from src.tools.google_serper import GoogleSerperRunTool from src.tools.google_serper import RESULTS_ID as GOOGLE_SERPER_RESULTS_TOOL_ID from src.tools.google_serper import GoogleSerperResultsTool +from src.tools.brave_search import ID as BRAVE_TOOL_ID +from src.tools.brave_search import BraveSearchTool +from src.tools.stackapi_tool import ID as STACKAPI_ID +from src.tools.stackapi_tool import StackAPISearchTool tools: dict = { ARXIV_TOOL_ID: ArxivTool, @@ -39,11 +43,14 @@ DDGS_TOOL_ID: DuckDuckGoSearchTool, GOOGLE_SERPER_RUN_TOOL_ID: GoogleSerperRunTool, GOOGLE_SERPER_RESULTS_TOOL_ID: GoogleSerperResultsTool, + BRAVE_TOOL_ID: BraveSearchTool, + STACKAPI_ID: StackAPISearchTool, } logger = logging.getLogger("root") load_dotenv() + def get_file_path_of_example(): current_dir = os.getcwd() target_folder = os.path.join(current_dir, "src/tools/test_files") @@ -92,10 +99,9 @@ def generate_tool_from_uuid( if tool in api_key_types.keys(): # set the api_key_type to the current tools api_key_type (the api_key_types dict has key "tool_id" and value "api_key_type_id") tool_key_type = api_key_types[tool] - - if tool_key_type in api_keys.keys(): - # set current api key that will be given to current tool (the api_keys dict has key "api_key_type_íd" and value "api_key") - api_key = api_keys[tool_key_type] + if tool_key_type in api_keys.keys(): + # set current api key that will be given to current tool (the api_keys dict has key "api_key_type_íd" and value "api_key") + api_key = api_keys[tool_key_type] if has_param(tool_cls, "api_key"): logger.info(f"has parameter 'api_key'") @@ -118,15 +124,17 @@ def generate_tool_from_uuid( bing_key = os.environ.get("BING_SUBSCRIPTION_KEY") alphavantage_key = os.environ.get("ALPHAVANTAGE_API_KEY") google_search_key = os.environ.get("GOOGLE_SEARCH_API_KEY") + brave_search_key = os.environ.get("BRAVE_API_KEY") print(serpapi_key, bing_key, alphavantage_key, google_search_key) if not all([serpapi_key, bing_key, alphavantage_key, google_search_key]): raise TypeError("a key was not found in env variables") api_keys = { - '3b64fe26-20b9-4064-907e-f2708b5f1656': serpapi_key, - "5281bbc4-45ea-4f4b-b790-e92c62bbc019": bing_key, - "8a29840f-4748-4ce4-88e6-44e1ef5b7637": alphavantage_key, - "4d950712-8b4c-4cc0-a24d-7599638119f2": google_search_key, + "3b64fe26-20b9-4064-907e-f2708b5f1656": serpapi_key, + "5281bbc4-45ea-4f4b-b790-e92c62bbc019": bing_key, + "8a29840f-4748-4ce4-88e6-44e1ef5b7637": alphavantage_key, + "4d950712-8b4c-4cc0-a24d-7599638119f2": google_search_key, + "58dc6249-3a0c-496b-91f3-27cf0054bfb0": brave_search_key, } api_key_types = { "fa4c2568-00d9-4e3c-9ab7-44f76f3a0e3f": "8a29840f-4748-4ce4-88e6-44e1ef5b7637", # alpha vantage @@ -134,6 +142,7 @@ def generate_tool_from_uuid( "71e4ddcc-4475-46f2-9816-894173b1292e": "5281bbc4-45ea-4f4b-b790-e92c62bbc019", # bing search "3e2665a8-6d73-42ee-a64f-50ddcc0621c6": "4d950712-8b4c-4cc0-a24d-7599638119f2", # google search (run) "1046fefb-a540-498f-8b96-7292523559e0": "4d950712-8b4c-4cc0-a24d-7599638119f2", # google search (results) + "3c0d3635-80f4-4286-aab6-c359795e1ac4": "58dc6249-3a0c-496b-91f3-27cf0054bfb0", # brave search } agents_tools = [ "f57d47fd-5783-4aac-be34-17ba36bb6242", # Move File Tool @@ -145,10 +154,12 @@ def generate_tool_from_uuid( "7dc53d81-cdac-4320-8077-1a7ab9497551", # DuckDuckGoSearch Tool "3e2665a8-6d73-42ee-a64f-50ddcc0621c6", # Google Serper Run "1046fefb-a540-498f-8b96-7292523559e0", # Google Serper Results + "3c0d3635-80f4-4286-aab6-c359795e1ac4", # Brave search + "612ddae6-ecdd-4900-9314-1a2c9de6003d", # StackAPI ] generated_tools = [] for tool in agents_tools: - tool = generate_tool_from_uuid(tool, api_key_types, api_keys) # type: ignore + tool = generate_tool_from_uuid(tool, api_key_types, api_keys) # type: ignore if tool is None: print("fail") else: diff --git a/apps/api/src/tools/brave_search.py b/apps/api/src/tools/brave_search.py new file mode 100644 index 00000000..7cbad0dd --- /dev/null +++ b/apps/api/src/tools/brave_search.py @@ -0,0 +1,29 @@ +import logging +from typing import Callable, Optional, Type + +from langchain.agents import Tool +from langchain.pydantic_v1 import BaseModel, Field +from langchain.tools import BaseTool +from langchain_community.tools import BraveSearch + +ID = "3c0d3635-80f4-4286-aab6-c359795e1ac4" + +logger = logging.getLogger("root") + + +class BraveSearchToolInput(BaseModel): + tool_input: str = Field( + title="query", description="Search query input to look up on brave" + ) + + +class BraveSearchTool(Tool, BaseTool): + args_schema: Type[BaseModel] = BraveSearchToolInput + + def __init__(self, api_key): + tool = BraveSearch.from_api_key(api_key=api_key, search_kwargs={"count": 3}) + super().__init__( + name="brave_search", + func=tool.run, + description="""search the internet through the search engine brave""", + ) diff --git a/apps/api/src/tools/stackapi_tool.py b/apps/api/src/tools/stackapi_tool.py new file mode 100644 index 00000000..858a70c7 --- /dev/null +++ b/apps/api/src/tools/stackapi_tool.py @@ -0,0 +1,30 @@ +import logging +from typing import Callable, Optional, Type + +from langchain.agents import Tool +from langchain.pydantic_v1 import BaseModel, Field +from langchain.tools import BaseTool +from langchain_community.utilities import StackExchangeAPIWrapper +from langchain_community.tools.stackexchange.tool import StackExchangeTool + +ID = "612ddae6-ecdd-4900-9314-1a2c9de6003d" + +logger = logging.getLogger("root") + + +class StackAPIToolInput(BaseModel): + query: str = Field( + title="query", description="Search query input to look up on Stack Exchange" + ) + + +class StackAPISearchTool(Tool, BaseTool): + args_schema: Type[BaseModel] = StackAPIToolInput + + def __init__(self): + tool = StackExchangeTool(api_wrapper=StackExchangeAPIWrapper()) + super().__init__( + name="stack_api_tool", + func=tool._run, + description="""StackAPI searches through a network of question-and-answer (Q&A) websites""", + ) From 1ebaee166041c19c6bcb0093c40c56d02b856ccc Mon Sep 17 00:00:00 2001 From: Daniel Aanensen <60237496+LVGrinder@users.noreply.github.com> Date: Wed, 17 Apr 2024 11:15:22 +0200 Subject: [PATCH 02/13] Add subscription endpoints (#196) * subscriptions * subscriptions endpoint --- apps/api/src/__init__.py | 18 ++- apps/api/src/interfaces/db.py | 215 ++++++++++++++++++++------ apps/api/src/models/__init__.py | 17 +- apps/api/src/models/subscription.py | 25 +++ apps/api/src/routers/subscriptions.py | 59 +++++++ apps/api/src/routers/tools.py | 25 +++ 6 files changed, 308 insertions(+), 51 deletions(-) create mode 100644 apps/api/src/models/subscription.py create mode 100644 apps/api/src/routers/subscriptions.py create mode 100644 apps/api/src/routers/tools.py diff --git a/apps/api/src/__init__.py b/apps/api/src/__init__.py index 13c7bf29..8b2d630b 100644 --- a/apps/api/src/__init__.py +++ b/apps/api/src/__init__.py @@ -2,7 +2,7 @@ from uuid import UUID import autogen -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse @@ -20,7 +20,18 @@ from .interfaces import db from .models import CrewProcessed from .routers import auth as auth_router -from .routers import agents, crews, messages, sessions, profiles, api_key_types, rest, api_keys +from .routers import ( + agents, + crews, + messages, + sessions, + profiles, + api_key_types, + rest, + api_keys, + tools, + subscriptions, +) logger = logging.getLogger("root") @@ -35,6 +46,8 @@ app.include_router(auth_router.router) app.include_router(api_key_types.router) app.include_router(rest.router) +app.include_router(tools.router) +app.include_router(subscriptions.router) app.add_middleware( CORSMiddleware, @@ -99,4 +112,3 @@ def auto_build_crew(general_task: str) -> str: @app.get("/me") def get_profile_from_header(current_user=Depends(get_current_user)): return current_user - diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index 451e34f0..d0e90a0d 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -33,6 +33,10 @@ MessageInsertRequest, Message, MessageUpdateRequest, + Subscription, + SubscriptionInsertRequest, + SubscriptionUpdateRequest, + SubscriptionGetRequest, ) load_dotenv() @@ -46,7 +50,7 @@ logger = logging.getLogger("root") -# keeping this function for now, since typing gets crazy with the sessions/run endpoint +# keeping this function for now, since typing gets crazy with the sessions/run endpoint # if it uses the "get_session_by_param" function def get_session(session_id: UUID) -> Session | None: """Get a session from the database.""" @@ -59,10 +63,10 @@ def get_session(session_id: UUID) -> Session | None: def get_sessions( - profile_id: UUID | None = None, + profile_id: UUID | None = None, crew_id: UUID | None = None, title: str | None = None, - status: str | None = None + status: str | None = None, ) -> list[Session]: """Gets session(s), filtered by what parameters are given""" supabase: Client = create_client(url, key) @@ -127,7 +131,7 @@ def get_messages( session_id: UUID | None = None, profile_id: UUID | None = None, recipient_id: UUID | None = None, - sender_id: UUID | None = None + sender_id: UUID | None = None, ) -> list[Message]: """Gets messages, filtered by what parameters are given""" supabase: Client = create_client(url, key) @@ -146,20 +150,20 @@ def get_messages( if sender_id: query = query.eq("sender_id", sender_id) - response = query.execute() return [Message(**data) for data in response.data] -def get_message(message_id: UUID) -> Message | None: + +def get_message(message_id: UUID) -> Message: """Get a message by its id""" supabase: Client = create_client(url, key) - response = supabase.table("messages").select("*").eq("id", message_id).execute() - if len(response.data) == 0: - return None + response = ( + supabase.table("messages").select("*").eq("id", message_id).single().execute() + ) + return Message(**response.data) + - return Message(**response.data[0]) - # TODO: combine this function with the insert_message one, or use this post_message for both the endpoint and internal operations def post_message(message: Message) -> None: """Post a message to the database.""" @@ -173,7 +177,11 @@ def post_message(message: Message) -> None: def insert_message(message: MessageInsertRequest) -> Message: """Posts a message like the post_message function, but uses a request model""" supabase: Client = create_client(url, key) - response = supabase.table("messages").insert(json.loads(message.model_dump_json(exclude_none=True))).execute() + response = ( + supabase.table("messages") + .insert(json.loads(message.model_dump_json(exclude_none=True))) + .execute() + ) return Message(**response.data[0]) @@ -202,6 +210,66 @@ def update_message(message_id: UUID, content: MessageUpdateRequest) -> Message | return Message(**response.data[0]) +def get_subscriptions( + profile_id: UUID | None = None, + stripe_subscription_id: str | None = None, +) -> list[Subscription]: + """Gets messages, filtered by what parameters are given""" + supabase: Client = create_client(url, key) + logger.debug(f"Getting subscriptions") + query = supabase.table("subscriptions").select("*") + + if profile_id: + query = query.eq("profile_id", profile_id) + + if stripe_subscription_id: + query = query.eq("stripe_subscription_id", stripe_subscription_id) + + response = query.execute() + + return [Subscription(**data) for data in response.data] + + +def insert_subscription(subscription: SubscriptionInsertRequest) -> Subscription: + """Posts a Subscription to the db""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("subscriptions") + .insert(json.loads(subscription.model_dump_json(exclude_none=True))) + .execute() + ) + return Subscription(**response.data[0]) + + +def delete_subscription(profile_id: UUID) -> Subscription | None: + """Deletes a subscription by an id (the primary key)""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("subscriptions").delete().eq("profile_id", profile_id).execute() + ) + if len(response.data) == 0: + return None + + return Subscription(**response.data[0]) + + +def update_subscription( + profile_id: UUID, content: SubscriptionUpdateRequest +) -> Subscription | None: + """Updates a subscription by an id""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("subscriptions") + .update(json.loads(content.model_dump_json(exclude_none=True))) + .eq("profile_id", profile_id) + .execute() + ) + if len(response.data) == 0: + return None + + return Subscription(**response.data[0]) + + def get_descriptions(agent_ids: list[UUID]) -> dict[UUID, list[str]] | None: """Get the description list for the given agent.""" supabase: Client = create_client(url, key) @@ -301,6 +369,20 @@ def delete_crew(crew_id: UUID) -> Crew: return Crew(**response.data[0]) +def get_api_key(api_key_id: UUID) -> APIKey: + supabase: Client = create_client(url, key) + response = ( + supabase.table("users_api_keys") + .select("*, api_key_types(*)") + .eq("id", api_key_id) + .single() + .execute() + ) + + api_key_type = APIKeyType(**response.data["api_key_types"]) + return APIKey(**response.data, api_key_type=api_key_type) + + def get_tool_api_keys( profile_id: UUID, api_key_type_ids: list[str] | None = None ) -> dict[str, str]: @@ -358,17 +440,26 @@ def get_api_keys( for data in response.data: api_key_type = APIKeyType(**data["api_key_types"]) api_keys.append(APIKey(**data, api_key_type=api_key_type)) - + return api_keys def insert_api_key(api_key: APIKeyInsertRequest) -> APIKey | None: supabase: Client = create_client(url, key) - type_response = supabase.table("api_key_types").select("*").eq("id", api_key.api_key_type_id).execute() + type_response = ( + supabase.table("api_key_types") + .select("*") + .eq("id", api_key.api_key_type_id) + .execute() + ) if len(type_response.data) == 0: return None - response = supabase.table("users_api_keys").insert(json.loads(api_key.model_dump_json())).execute() + response = ( + supabase.table("users_api_keys") + .insert(json.loads(api_key.model_dump_json())) + .execute() + ) api_key_type = APIKeyType(**type_response.data[0]) return APIKey(**response.data[0], api_key_type=api_key_type) @@ -380,15 +471,30 @@ def delete_api_key(api_key_id: UUID) -> APIKey | None: if not len(response.data): return None - type_response = supabase.table("api_key_types").select("*").eq("id", response.data[0]["api_key_type_id"]).execute() + type_response = ( + supabase.table("api_key_types") + .select("*") + .eq("id", response.data[0]["api_key_type_id"]) + .execute() + ) api_key_type = APIKeyType(**type_response.data[0]) return APIKey(**response.data[0], api_key_type=api_key_type) def update_api_key(api_key_id: UUID, api_key_update: APIKeyUpdateRequest) -> APIKey: supabase: Client = create_client(url, key) - response = supabase.table("users_api_keys").update(json.loads(api_key_update.model_dump_json())).eq("id", api_key_id).execute() - type_response = supabase.table("api_key_types").select("*").eq("id", response.data[0]["api_key_type_id"]).execute() + response = ( + supabase.table("users_api_keys") + .update(json.loads(api_key_update.model_dump_json())) + .eq("id", api_key_id) + .execute() + ) + type_response = ( + supabase.table("api_key_types") + .select("*") + .eq("id", response.data[0]["api_key_type_id"]) + .execute() + ) api_key_type = APIKeyType(**type_response.data[0]) return APIKey(**response.data[0], api_key_type=api_key_type) @@ -398,7 +504,7 @@ def get_api_key_types() -> list[APIKeyType]: supabase: Client = create_client(url, key) logger.debug("Getting all api key types") response = supabase.table("api_key_types").select("*").execute() - return [APIKeyType(**data) for data in response.data] + return [APIKeyType(**data) for data in response.data] def update_status(session_id: UUID, status: SessionStatus) -> None: @@ -410,7 +516,7 @@ def update_status(session_id: UUID, status: SessionStatus) -> None: def get_agents( profile_id: UUID | None = None, crew_id: UUID | None = None, - published: bool | None = None + published: bool | None = None, ) -> list[Agent] | None: """Gets agents, filtered by what parameters are given""" supabase: Client = create_client(url, key) @@ -427,9 +533,9 @@ def get_agents( response = get_agents_from_crew(crew_id) if not response: return None - + return response - + if published is not None: query = query.eq("published", published) @@ -484,18 +590,34 @@ def delete_agent(agent_id: UUID) -> Agent: return Agent(**response.data[0]) +def update_agent_tool(agent_id: UUID, tool_id: UUID) -> Agent: + supabase: Client = create_client(url, key) + agent_tools = supabase.table("agents").select("tools").eq("id", agent_id).execute() + tool: dict = {"id": tool_id, "parameter": {}} + + agent_tools.data[0]["tools"].append(tool) + formatted_tools = agent_tools.data[0]["tools"] + response = ( + supabase.table("agents") + .update(json.loads(json.dumps(formatted_tools, default=str))) + .eq("id", agent_id) + .execute() + ) + return Agent(**response.data[0]) + + def get_profiles( tier_id: UUID | None = None, display_name: str | None = None, - stripe_customer_id: str | None = None + stripe_customer_id: str | None = None, ) -> list[Profile]: - """Gets profiles, filtered by what parameters are given""" + """Gets profiles, filtered by what parameters are given""" supabase: Client = create_client(url, key) query = supabase.table("profiles").select("*") if tier_id: query = query.eq("tier_id", tier_id) - + if display_name: query = query.eq("display_name", display_name) @@ -515,9 +637,7 @@ def get_profile(profile_id: UUID) -> Profile | None: return Profile(**response.data[0]) -def update_profile( - profile_id: UUID, content: ProfileUpdateRequest -) -> Profile: +def update_profile(profile_id: UUID, content: ProfileUpdateRequest) -> Profile: supabase: Client = create_client(url, key) response = ( supabase.table("profiles") @@ -530,7 +650,11 @@ def update_profile( def insert_profile(profile: ProfileInsertRequest) -> Profile: supabase: Client = create_client(url, key) - response = supabase.table("profiles").insert(json.loads(profile.model_dump_json(exclude_none=True))).execute() + response = ( + supabase.table("profiles") + .insert(json.loads(profile.model_dump_json(exclude_none=True))) + .execute() + ) return Profile(**response.data[0]) @@ -540,26 +664,27 @@ def delete_profile(profile_id: UUID) -> Profile: return Profile(**response.data[0]) -if __name__ == "__main__": +if __name__ == "__main__": from src.models import Session -# print( -# insert_session( -# SessionRequest( -# crew_id=UUID("1c11a9bf-748f-482b-9746-6196f136401a"), -# profile_id=UUID("070c1d2e-9d72-4854-a55e-52ade5a42071"), -# title="hello", -# ) -# ) -# ) -# - #print(get_crew(UUID("bf9f1cdc-fb63-45e1-b1ff-9a1989373ce3"))) + + # print( + # insert_session( + # SessionRequest( + # crew_id=UUID("1c11a9bf-748f-482b-9746-6196f136401a"), + # profile_id=UUID("070c1d2e-9d72-4854-a55e-52ade5a42071"), + # title="hello", + # ) + # ) + # ) + # + # print(get_crew(UUID("bf9f1cdc-fb63-45e1-b1ff-9a1989373ce3"))) ##print(insert_message(MessageRequestModel( # session_id=UUID("ec4a9ae1-f4de-46cf-946d-956b3081c432"), # profile_id=UUID("070c1d2e-9d72-4854-a55e-52ade5a42071"), # content="hello test message", # recipient_id=UUID("7c707c30-2cfe-46a0-afa7-8bcc38f9687e"), - #))) + # ))) - #print(update_message(UUID("c3e4755b-141d-4f77-8ea8-924961ccf36d"), content=MessageUpdateRequest(content="wowzer"))) - #print(get_api_keys(api_key_type_id=UUID("3b64fe26-20b9-4064-907e-f2708b5f1656"))) - print(get_api_key_type_ids(["612ddae6-ecdd-4900-9314-1a2c9de6003d"])) \ No newline at end of file + # print(update_message(UUID("c3e4755b-141d-4f77-8ea8-924961ccf36d"), content=MessageUpdateRequest(content="wowzer"))) + # print(get_api_keys(api_key_type_id=UUID("3b64fe26-20b9-4064-907e-f2708b5f1656"))) + print(get_api_key_type_ids(["612ddae6-ecdd-4900-9314-1a2c9de6003d"])) diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 17425541..d429627f 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -15,11 +15,17 @@ ) from .llm_config import LLMConfig from .message import ( - Message, - MessageInsertRequest, + Message, + MessageInsertRequest, MessageUpdateRequest, MessageGetRequest, ) +from .subscription import ( + Subscription, + SubscriptionInsertRequest, + SubscriptionUpdateRequest, + SubscriptionGetRequest, +) from .profile import ( ProfileInsertRequest, Profile, @@ -35,7 +41,7 @@ SessionUpdateRequest, SessionGetRequest, ) -from .api_key import( +from .api_key import ( APIKeyInsertRequest, APIKey, APIKeyType, @@ -43,6 +49,7 @@ APIKeyGetRequest, ) from .user import User + __all__ = [ "AgentConfig", "CodeExecutionConfig", @@ -77,4 +84,8 @@ "AgentGetRequest", "ProfileGetRequest", "APIKeyGetRequest", + "Subscription", + "SubscriptionInsertRequest", + "SubscriptionUpdateRequest", + "SubscriptionGetRequest", ] diff --git a/apps/api/src/models/subscription.py b/apps/api/src/models/subscription.py new file mode 100644 index 00000000..53451ece --- /dev/null +++ b/apps/api/src/models/subscription.py @@ -0,0 +1,25 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + + +class Subscription(BaseModel): + profile_id: UUID + stripe_subscription_id: str | None = None + created_at: datetime + + +class SubscriptionInsertRequest(BaseModel): + profile_id: UUID + stripe_subscription_id: str | None = None + + +class SubscriptionUpdateRequest(BaseModel): + stripe_subscription_id: str | None = None + + +class SubscriptionGetRequest(BaseModel): + profile_id: UUID | None = None + stripe_subscription_id: str | None = None + created_at: datetime diff --git a/apps/api/src/routers/subscriptions.py b/apps/api/src/routers/subscriptions.py new file mode 100644 index 00000000..88e155ed --- /dev/null +++ b/apps/api/src/routers/subscriptions.py @@ -0,0 +1,59 @@ +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException + +from src.dependencies import ( + RateLimitResponse, + rate_limit, + rate_limit_profile, + rate_limit_tiered, +) +from src.interfaces import db +from src.models import ( + Subscription, + SubscriptionInsertRequest, + SubscriptionUpdateRequest, + SubscriptionGetRequest, +) + +router = APIRouter(prefix="/subscriptions", tags=["subscriptions"]) + +logger = logging.getLogger("root") + + +@router.get("/") +def get_subscriptions(q: SubscriptionGetRequest = Depends()) -> list[Subscription]: + return db.get_subscriptions(q.profile_id, q.stripe_subscription_id) + + +@router.post("/") +def insert_subscription(subscription: SubscriptionInsertRequest) -> Subscription: + return db.insert_subscription(subscription) + + +@router.delete("/{profile_id}") +def delete_subscription(profile_id: UUID) -> Subscription: + response = db.delete_subscription(profile_id) + if not response: + raise HTTPException(404, "stripe subscription id not found") + + return response + + +@router.patch("/{profile_id}") +def update_subscription( + profile_id: UUID, content: SubscriptionUpdateRequest +) -> Subscription: + response = db.update_subscription(profile_id, content) + if not response: + raise HTTPException(404, "message not found") + + return response + + +# +# +# @router.get("/{message_id}") +# def get_message(message_id: UUID) -> Message: +# return db.get_message(message_id) diff --git a/apps/api/src/routers/tools.py b/apps/api/src/routers/tools.py new file mode 100644 index 00000000..6ae66632 --- /dev/null +++ b/apps/api/src/routers/tools.py @@ -0,0 +1,25 @@ +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException + +from src.interfaces import db +from src.models import ( + AgentInsertRequest, + AgentUpdateModel, + Agent, + AgentGetRequest, +) + +router = APIRouter( + prefix="/tools", + tags=["tools"], +) + + +@router.patch("/{agent_id}") +def add_tool(agent_id: UUID, tool_id: UUID) -> Agent: + if not db.get_agent(agent_id): + raise HTTPException(404, "agent not found") + + return db.update_agent_tool(agent_id, tool_id) From 0e3d874409caa92ec631680916fffb47f780a0f2 Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 11:24:55 +0200 Subject: [PATCH 03/13] Fix the types of session and message models for the openapi code generator (#197) * fix the type on session * fix type on message * fix warning from edge class * clean up, also fix message model --- apps/api/src/mock.py | 2 +- apps/api/src/models/edge.py | 2 +- apps/api/src/models/message.py | 6 +++--- apps/api/src/models/session.py | 13 ++++++------- apps/api/src/routers/sessions.py | 11 ++++++++++- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/api/src/mock.py b/apps/api/src/mock.py index 37432c51..b51e1e0b 100644 --- a/apps/api/src/mock.py +++ b/apps/api/src/mock.py @@ -81,7 +81,7 @@ # "created_at": "2024-01-01T00:00:00.000Z", # } -tool, prompt = "stack api tool", "what is openai?" +tool, prompt = "brave search tool", "what is openai?" test_tool: dict = { "id": "00000000-0000-0000-0000-000000000001", diff --git a/apps/api/src/models/edge.py b/apps/api/src/models/edge.py index 5ec54da4..7e82a997 100644 --- a/apps/api/src/models/edge.py +++ b/apps/api/src/models/edge.py @@ -17,7 +17,7 @@ class PathOptions(BaseModel): borderRadius: Optional[float] = None curvature: Optional[float] = None -class Edge(Generic[T], BaseModel): +class Edge(BaseModel, Generic[T]): id: str type: Optional[str] = None source: str diff --git a/apps/api/src/models/message.py b/apps/api/src/models/message.py index deceb4c3..0d3d8843 100644 --- a/apps/api/src/models/message.py +++ b/apps/api/src/models/message.py @@ -5,14 +5,14 @@ class Message(BaseModel): - id: UUID = Field(default_factory=lambda: uuid4()) + id: UUID session_id: UUID profile_id: UUID sender_id: UUID | None = None # None means admin here recipient_id: UUID | None = None # None means admin here aswell content: str - role: str = "user" - created_at: datetime = Field(default_factory=lambda: datetime.now(tz=UTC)) + role: str + created_at: datetime class MessageInsertRequest(BaseModel): diff --git a/apps/api/src/models/session.py b/apps/api/src/models/session.py index 8b48eaf4..82fc2021 100644 --- a/apps/api/src/models/session.py +++ b/apps/api/src/models/session.py @@ -12,16 +12,15 @@ class SessionStatus(StrEnum): IDLE = auto() - class Session(BaseModel): - id: UUID = Field(default_factory=lambda: uuid4()) - created_at: datetime = Field(default_factory=lambda: datetime.now(tz=UTC)) + id: UUID + created_at: datetime profile_id: UUID - reply: str = "" + reply: str crew_id: UUID - title: str = "Untitled" - last_opened_at: datetime = Field(default_factory=lambda: datetime.now(tz=UTC)) - status: SessionStatus = SessionStatus.RUNNING + title: str + last_opened_at: datetime + status: SessionStatus class SessionInsertRequest(BaseModel): diff --git a/apps/api/src/routers/sessions.py b/apps/api/src/routers/sessions.py index 18ca18e4..3e309f3f 100644 --- a/apps/api/src/routers/sessions.py +++ b/apps/api/src/routers/sessions.py @@ -1,6 +1,7 @@ +from datetime import UTC, datetime import logging from typing import cast -from uuid import UUID +from uuid import UUID, uuid4 from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException @@ -23,6 +24,7 @@ Session, SessionUpdateRequest, SessionGetRequest, + SessionStatus, ) from src.models.session import SessionInsertRequest from src.parser import process_crew, get_processed_crew_by_id @@ -121,9 +123,14 @@ async def run_crew( if session is None: session = Session( + id=uuid4(), + created_at=datetime.now(tz=UTC), crew_id=request.crew_id, profile_id=request.profile_id, title=request.session_title, + reply="", + last_opened_at=datetime.now(tz=UTC), + status=SessionStatus.RUNNING ) db.post_session(session) @@ -134,12 +141,14 @@ async def on_reply( role: str, ) -> None: message = Message( + id=uuid4(), session_id=session.id, profile_id=session.profile_id, recipient_id=recipient_id, sender_id=sender_id, content=content, role=role, + created_at=datetime.now(tz=UTC) ) logger.debug(f"on_reply: {message}") db.post_message(message) From 2e6ae9af4d86a7c59d22b04f63ac5cd1523258fe Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 14:18:19 +0200 Subject: [PATCH 04/13] Gitignore cache and change api keys path to kebabcase (#199) --- apps/api/src/rest/.gitignore | 1 + apps/api/src/rest/cache/cache.db | Bin 282624 -> 0 bytes apps/api/src/routers/api_keys.py | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/rest/.gitignore delete mode 100644 apps/api/src/rest/cache/cache.db diff --git a/apps/api/src/rest/.gitignore b/apps/api/src/rest/.gitignore new file mode 100644 index 00000000..fc61eafa --- /dev/null +++ b/apps/api/src/rest/.gitignore @@ -0,0 +1 @@ +/cache/ \ No newline at end of file diff --git a/apps/api/src/rest/cache/cache.db b/apps/api/src/rest/cache/cache.db deleted file mode 100644 index 223eaf3acecaf358f72ca438470523321983e584..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 282624 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 s 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

3aQ!S@Ag3RV_;g1P$gf;S3YDR`ma>4JrruisN} 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 diff --git a/apps/api/src/routers/api_keys.py b/apps/api/src/routers/api_keys.py index 333b93ea..6b4bc24b 100644 --- a/apps/api/src/routers/api_keys.py +++ b/apps/api/src/routers/api_keys.py @@ -11,7 +11,7 @@ APIKeyGetRequest, ) -router = APIRouter(prefix="/api_keys", tags=["api keys"]) +router = APIRouter(prefix="/api-keys", tags=["api keys"]) @router.get("/") From b4fe61b3ff805007568d1bfdaff60a491b8f317f Mon Sep 17 00:00:00 2001 From: Daniel Aanensen <60237496+LVGrinder@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:22:22 +0200 Subject: [PATCH 05/13] Add billing endpoints (#198) * Add billing endpoints * Gitignore cache and change api keys path to kebabcase (#199) * comment fixes * change get request for billing info to use path param --------- Co-authored-by: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Co-authored-by: Leon Lam Nilsson --- apps/api/src/__init__.py | 2 + apps/api/src/interfaces/db.py | 62 +++++++++++++++++++++ apps/api/src/models/__init__.py | 8 +++ apps/api/src/models/billing_information.py | 23 ++++++++ apps/api/src/routers/billing_information.py | 60 ++++++++++++++++++++ 5 files changed, 155 insertions(+) create mode 100644 apps/api/src/models/billing_information.py create mode 100644 apps/api/src/routers/billing_information.py diff --git a/apps/api/src/__init__.py b/apps/api/src/__init__.py index 8b2d630b..234ba7a5 100644 --- a/apps/api/src/__init__.py +++ b/apps/api/src/__init__.py @@ -31,6 +31,7 @@ api_keys, tools, subscriptions, + billing_information, ) logger = logging.getLogger("root") @@ -48,6 +49,7 @@ app.include_router(rest.router) app.include_router(tools.router) app.include_router(subscriptions.router) +app.include_router(billing_information.router) app.add_middleware( CORSMiddleware, diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index d0e90a0d..44a28b82 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -37,6 +37,9 @@ SubscriptionInsertRequest, SubscriptionUpdateRequest, SubscriptionGetRequest, + Billing, + BillingInsertRequest, + BillingUpdateRequest, ) load_dotenv() @@ -270,6 +273,65 @@ def update_subscription( return Subscription(**response.data[0]) +def get_billing( + profile_id: UUID, +) -> Billing | None: + """Gets billings, filtered by what parameters are given""" + supabase: Client = create_client(url, key) + logger.debug(f"Getting billings") + response = ( + supabase.table("billing_information") + .select("*") + .eq("profile_id", profile_id) + .execute() + ) + if len(response.data) == 0: + return None + + return Billing(**response.data[0]) + + +def insert_billing(billing: BillingInsertRequest) -> Billing: + """Posts a billing to the db""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("billing_information") + .insert(json.loads(billing.model_dump_json(exclude_none=True))) + .execute() + ) + return Billing(**response.data[0]) + + +def delete_billing(profile_id: UUID) -> Billing | None: + """Deletes a billing by an id (the primary key)""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("billing_information") + .delete() + .eq("profile_id", profile_id) + .execute() + ) + if len(response.data) == 0: + return None + + return Billing(**response.data[0]) + + +def update_billing(profile_id: UUID, content: BillingUpdateRequest) -> Billing | None: + """Updates a billing by an id""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("billing_information") + .update(json.loads(content.model_dump_json(exclude_none=True))) + .eq("profile_id", profile_id) + .execute() + ) + if len(response.data) == 0: + return None + + return Billing(**response.data[0]) + + def get_descriptions(agent_ids: list[UUID]) -> dict[UUID, list[str]] | None: """Get the description list for the given agent.""" supabase: Client = create_client(url, key) diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index d429627f..c5c2c71f 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -26,6 +26,11 @@ SubscriptionUpdateRequest, SubscriptionGetRequest, ) +from .billing_information import ( + Billing, + BillingInsertRequest, + BillingUpdateRequest, +) from .profile import ( ProfileInsertRequest, Profile, @@ -88,4 +93,7 @@ "SubscriptionInsertRequest", "SubscriptionUpdateRequest", "SubscriptionGetRequest", + "Billing", + "BillingInsertRequest", + "BillingUpdateRequest", ] diff --git a/apps/api/src/models/billing_information.py b/apps/api/src/models/billing_information.py new file mode 100644 index 00000000..e9590672 --- /dev/null +++ b/apps/api/src/models/billing_information.py @@ -0,0 +1,23 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + + +class Billing(BaseModel): + profile_id: UUID + stripe_payment_method: str | None = None + description: str | None = None + created_at: datetime + + +class BillingInsertRequest(BaseModel): + profile_id: UUID + stripe_payment_method: str | None = None + description: str | None = None + + +class BillingUpdateRequest(BaseModel): + stripe_payment_method: str | None = None + description: str | None = None + diff --git a/apps/api/src/routers/billing_information.py b/apps/api/src/routers/billing_information.py new file mode 100644 index 00000000..81b47173 --- /dev/null +++ b/apps/api/src/routers/billing_information.py @@ -0,0 +1,60 @@ +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException + +from src.dependencies import ( + RateLimitResponse, + rate_limit, + rate_limit_profile, + rate_limit_tiered, +) +from src.interfaces import db +from src.models import ( + Billing, + BillingInsertRequest, + BillingUpdateRequest, +) + +router = APIRouter(prefix="/billing", tags=["billings"]) + +logger = logging.getLogger("root") + + +@router.get("/{id}") +def get_billings(id: UUID) -> Billing: + response = db.get_billing(id) + if not response: + raise HTTPException(404, "billing information not found") + + return response + + +@router.post("/") +def insert_billing(subscription: BillingInsertRequest) -> Billing: + return db.insert_billing(subscription) + + +@router.delete("/{profile_id}") +def delete_billing(profile_id: UUID) -> Billing: + response = db.delete_billing(profile_id) + if not response: + raise HTTPException(404, "stripe subscription id not found") + + return response + + +@router.patch("/{profile_id}") +def update_billing(profile_id: UUID, content: BillingUpdateRequest) -> Billing: + response = db.update_billing(profile_id, content) + if not response: + raise HTTPException(404, "message not found") + + return response + + +# +# +# @router.get("/{message_id}") +# def get_message(message_id: UUID) -> Message: +# return db.get_message(message_id) From 129eb1229bd6dd0a8d2b05d077910cd7dabccd93 Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:23:04 +0200 Subject: [PATCH 06/13] Fix error output on inputting session (#202) --- apps/api/src/routers/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/routers/sessions.py b/apps/api/src/routers/sessions.py index 3e309f3f..9a075cdc 100644 --- a/apps/api/src/routers/sessions.py +++ b/apps/api/src/routers/sessions.py @@ -157,7 +157,7 @@ async def on_reply( crew = AutogenCrew(session.profile_id, session, crew_model, on_reply) except ValueError as e: logger.error(e) - raise HTTPException(400, "crew model bad input") + raise HTTPException(400, f"crew model bad input: {e}") background_tasks.add_task(crew.run, message, messages=cached_messages) From f56f92fd23c53baa62feb1a519760ff39e8a5403 Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:58:58 +0200 Subject: [PATCH 07/13] Fix error handling on sessions (#203) * fix error output on inputting session * fix more error handling in running crew * delete session if crew run errors so we don't create sessions with no messages --- apps/api/src/crew.py | 16 ---------------- apps/api/src/parser.py | 10 ++++++++++ apps/api/src/routers/sessions.py | 4 +++- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/api/src/crew.py b/apps/api/src/crew.py index 27ff23de..1c5935e0 100644 --- a/apps/api/src/crew.py +++ b/apps/api/src/crew.py @@ -32,8 +32,6 @@ def __init__( self.profile_id = profile_id self.session = session self.on_reply = on_message - if not self._validate_crew_model(crew_model): - raise ValueError("composition is invalid") self.crew_model = crew_model self.valid_tools = [] @@ -137,20 +135,6 @@ async def _on_reply( await self.on_reply(recipient_id, sender_id, content, role) return False, None - def _validate_crew_model(self, crew_model: CrewProcessed) -> bool: - if len(crew_model.agents) == 0: - return False - - # Validate agents - for agent in crew_model.agents: - if agent.role == "": - return False - if agent.title == "": - return False - if agent.system_message == "": - return False - return True - def _extract_uuid(self, dictionary: dict[UUID, list[str]]) -> dict[UUID, list[str]]: new_dict = {} for key, value in dictionary.items(): diff --git a/apps/api/src/parser.py b/apps/api/src/parser.py index 70bb2c39..f75b4390 100644 --- a/apps/api/src/parser.py +++ b/apps/api/src/parser.py @@ -60,6 +60,16 @@ def process_crew(crew: Crew) -> tuple[str, CrewProcessed]: ) if not crew.prompt: raise HTTPException(400, "got no prompt") + if len(crew_model.agents) == 0: + raise ValueError("crew had no agents") + # Validate agents + for agent in crew_model.agents: + if agent.role == "": + raise ValueError(f"agent {agent.id} had no role") + if agent.title == "": + raise ValueError(f"agent {agent.id} had no title") + if agent.system_message == "": + raise ValueError(f"agent {agent.id} had no system message") message: str = crew.prompt["content"] return message, crew_model diff --git a/apps/api/src/routers/sessions.py b/apps/api/src/routers/sessions.py index 9a075cdc..bb161b88 100644 --- a/apps/api/src/routers/sessions.py +++ b/apps/api/src/routers/sessions.py @@ -95,6 +95,8 @@ async def run_crew( if mock: message, crew_model = process_crew(Crew(**mocks.crew_model)) + request.crew_id = UUID("1c11a9bf-748f-482b-9746-6196f136401a") + request.profile_id = UUID("070c1d2e-9d72-4854-a55e-52ade5a42071") else: message, crew_model = get_processed_crew_by_id(request.crew_id) @@ -120,7 +122,6 @@ async def run_crew( status_code=400, detail=f"Session with id {request.session_id} found, but has no messages", ) - if session is None: session = Session( id=uuid4(), @@ -156,6 +157,7 @@ async def on_reply( try: crew = AutogenCrew(session.profile_id, session, crew_model, on_reply) except ValueError as e: + db.delete_session(session.id) logger.error(e) raise HTTPException(400, f"crew model bad input: {e}") From 4eb1132aa8e58d1dd820378cbc5a2b201119189c Mon Sep 17 00:00:00 2001 From: Daniel Aanensen <60237496+LVGrinder@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:44:56 +0200 Subject: [PATCH 08/13] Add tiers endpoints (#200) * add tiers endpoint * fixing up tier endpoints * tiers changes * tier change testing * changes tier endpoint and def * def get_tier change * fixed response --------- Signed-off-by: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Co-authored-by: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> --- apps/api/src/__init__.py | 2 ++ apps/api/src/interfaces/db.py | 56 +++++++++++++++++++++++++++++++-- apps/api/src/models/__init__.py | 10 ++++++ apps/api/src/models/tiers.py | 42 +++++++++++++++++++++++++ apps/api/src/routers/tiers.py | 54 +++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/models/tiers.py create mode 100644 apps/api/src/routers/tiers.py diff --git a/apps/api/src/__init__.py b/apps/api/src/__init__.py index 234ba7a5..0c601b7b 100644 --- a/apps/api/src/__init__.py +++ b/apps/api/src/__init__.py @@ -31,6 +31,7 @@ api_keys, tools, subscriptions, + tiers, billing_information, ) @@ -49,6 +50,7 @@ app.include_router(rest.router) app.include_router(tools.router) app.include_router(subscriptions.router) +app.include_router(tiers.router) app.include_router(billing_information.router) app.add_middleware( diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index 44a28b82..32586dad 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -37,10 +37,15 @@ SubscriptionInsertRequest, SubscriptionUpdateRequest, SubscriptionGetRequest, + Tier, + TierInsertRequest, + TierUpdateRequest, Billing, BillingInsertRequest, BillingUpdateRequest, + ) +from src.models.tiers import TierGetRequest load_dotenv() url: str | None = os.environ.get("SUPABASE_URL") @@ -217,7 +222,7 @@ def get_subscriptions( profile_id: UUID | None = None, stripe_subscription_id: str | None = None, ) -> list[Subscription]: - """Gets messages, filtered by what parameters are given""" + """Gets subscriptions, filtered by what parameters are given""" supabase: Client = create_client(url, key) logger.debug(f"Getting subscriptions") query = supabase.table("subscriptions").select("*") @@ -234,7 +239,7 @@ def get_subscriptions( def insert_subscription(subscription: SubscriptionInsertRequest) -> Subscription: - """Posts a Subscription to the db""" + """Posts a subscription to the db""" supabase: Client = create_client(url, key) response = ( supabase.table("subscriptions") @@ -273,6 +278,48 @@ def update_subscription( return Subscription(**response.data[0]) + +def get_tier(id: UUID) -> Tier | None: + """Gets tiers, filtered by what parameters are given""" + supabase: Client = create_client(url, key) + response = supabase.table("tiers").select("*").eq("id", id).execute() + if len(response.data) == 0: + return None + + return Tier(**response.data[0]) + + +def insert_tier(tier: TierInsertRequest) -> Tier: + """Posts a tier to the db""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("tiers") + .insert(json.loads(tier.model_dump_json(exclude_none=True))) + .execute() + ) + return Tier(**response.data[0]) + + +def delete_tier(id: UUID) -> Tier | None: + """Deletes a tier by an id (the primary key)""" + supabase: Client = create_client(url, key) + response = supabase.table("tiers").delete().eq("id", id).execute() + if len(response.data) == 0: + return None + + return Tier(**response.data[0]) + + +def update_tier(id: UUID, content: TierUpdateRequest) -> Tier | None: + """Updates a tier by an id""" + supabase: Client = create_client(url, key) + response = ( + supabase.table("tiers") + .update(json.loads(content.model_dump_json(exclude_none=True))) + .eq("id", id) + .execute() + + return Tier(**response.data[0]) def get_billing( profile_id: UUID, ) -> Billing | None: @@ -299,6 +346,7 @@ def insert_billing(billing: BillingInsertRequest) -> Billing: .insert(json.loads(billing.model_dump_json(exclude_none=True))) .execute() ) + return Billing(**response.data[0]) @@ -324,14 +372,16 @@ def update_billing(profile_id: UUID, content: BillingUpdateRequest) -> Billing | supabase.table("billing_information") .update(json.loads(content.model_dump_json(exclude_none=True))) .eq("profile_id", profile_id) + .execute() ) if len(response.data) == 0: return None - + return Billing(**response.data[0]) + def get_descriptions(agent_ids: list[UUID]) -> dict[UUID, list[str]] | None: """Get the description list for the given agent.""" supabase: Client = create_client(url, key) diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index c5c2c71f..b49538d1 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -26,6 +26,12 @@ SubscriptionUpdateRequest, SubscriptionGetRequest, ) +from .tiers import ( + Tier, + TierInsertRequest, + TierUpdateRequest, + TierGetRequest, +) from .billing_information import ( Billing, BillingInsertRequest, @@ -93,6 +99,10 @@ "SubscriptionInsertRequest", "SubscriptionUpdateRequest", "SubscriptionGetRequest", + "Tier", + "TierInsertRequest", + "TierUpdateRequest", + "TierGetRequest", "Billing", "BillingInsertRequest", "BillingUpdateRequest", diff --git a/apps/api/src/models/tiers.py b/apps/api/src/models/tiers.py new file mode 100644 index 00000000..12df0ae1 --- /dev/null +++ b/apps/api/src/models/tiers.py @@ -0,0 +1,42 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + + +class Tier(BaseModel): + id: UUID + created_at: datetime + period: int + limit: int + stripe_price_id: str | None = None + name: str | None = None + description: str | None = None + slug: str | None = None + image: str | None = None + + +class TierInsertRequest(BaseModel): + period: int | None = None + limit: int | None = None + stripe_price_id: str | None = None + name: str | None = None + description: str | None = None + slug: str | None = None + image: str | None = None + + +class TierUpdateRequest(BaseModel): + period: int | None = None + limit: int | None = None + stripe_price_id: str | None = None + name: str | None = None + description: str | None = None + slug: str | None = None + image: str | None = None + + +class TierGetRequest(BaseModel): + id: UUID + stripe_price_id: str | None = None + name: str | None = None diff --git a/apps/api/src/routers/tiers.py b/apps/api/src/routers/tiers.py new file mode 100644 index 00000000..3eb0ff83 --- /dev/null +++ b/apps/api/src/routers/tiers.py @@ -0,0 +1,54 @@ +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException + +from src.dependencies import ( + RateLimitResponse, + rate_limit, + rate_limit_profile, + rate_limit_tiered, +) +from src.interfaces import db +from src.models import ( + Tier, + TierInsertRequest, + TierUpdateRequest, + TierGetRequest, +) + +router = APIRouter(prefix="/tiers", tags=["tiers"]) + +logger = logging.getLogger("root") + + +@router.get("/{id}") +def get_tier(id: UUID) -> Tier: + response = db.get_tier(id) + if not response: + raise HTTPException(404, "tiers information not found") + + return response + + +@router.post("/") +def insert_tier(tier: TierInsertRequest) -> Tier: + return db.insert_tier(tier) + + +@router.delete("/{id}") +def delete_tier(id: UUID) -> Tier: + response = db.delete_tier(id) + if not response: + raise HTTPException(404, "stripe tier id not found") + + return response + + +@router.patch("/{id}") +def update_tier(id: UUID, content: TierUpdateRequest) -> Tier: + response = db.update_tier(id, content) + if not response: + raise HTTPException(404, "message not found") + + return response From f8844aba450ce5cf64183380d68ef3ef0c983aa4 Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:49:29 +0200 Subject: [PATCH 09/13] Add one parenthesis (plus some empty lines) (#204) --- apps/api/src/interfaces/db.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index 32586dad..173315e1 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -318,8 +318,10 @@ def update_tier(id: UUID, content: TierUpdateRequest) -> Tier | None: .update(json.loads(content.model_dump_json(exclude_none=True))) .eq("id", id) .execute() + ) + return Tier(**response.data[0]) + - return Tier(**response.data[0]) def get_billing( profile_id: UUID, ) -> Billing | None: From 554e85e24449bea1eb3ad35f600a2b63a7ca113b Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:01:18 +0200 Subject: [PATCH 10/13] Fix code smell complaints (#205) --- apps/api/src/interfaces/db.py | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index 173315e1..e882164b 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -143,7 +143,7 @@ def get_messages( ) -> list[Message]: """Gets messages, filtered by what parameters are given""" supabase: Client = create_client(url, key) - logger.debug(f"Getting messages") + logger.debug("Getting messages") query = supabase.table("messages").select("*") if session_id: @@ -224,7 +224,7 @@ def get_subscriptions( ) -> list[Subscription]: """Gets subscriptions, filtered by what parameters are given""" supabase: Client = create_client(url, key) - logger.debug(f"Getting subscriptions") + logger.debug("Getting subscriptions") query = supabase.table("subscriptions").select("*") if profile_id: @@ -327,7 +327,7 @@ def get_billing( ) -> Billing | None: """Gets billings, filtered by what parameters are given""" supabase: Client = create_client(url, key) - logger.debug(f"Getting billings") + logger.debug("Getting billings") response = ( supabase.table("billing_information") .select("*") @@ -457,7 +457,7 @@ def get_crews( ) -> list[Crew]: """Gets crews, filtered by what parameters are given""" supabase: Client = create_client(url, key) - logger.debug(f"Getting crews") + logger.debug("Getting crews") query = supabase.table("crews").select("*") if profile_id: @@ -781,24 +781,4 @@ def delete_profile(profile_id: UUID) -> Profile: if __name__ == "__main__": from src.models import Session - # print( - # insert_session( - # SessionRequest( - # crew_id=UUID("1c11a9bf-748f-482b-9746-6196f136401a"), - # profile_id=UUID("070c1d2e-9d72-4854-a55e-52ade5a42071"), - # title="hello", - # ) - # ) - # ) - # - # print(get_crew(UUID("bf9f1cdc-fb63-45e1-b1ff-9a1989373ce3"))) - ##print(insert_message(MessageRequestModel( - # session_id=UUID("ec4a9ae1-f4de-46cf-946d-956b3081c432"), - # profile_id=UUID("070c1d2e-9d72-4854-a55e-52ade5a42071"), - # content="hello test message", - # recipient_id=UUID("7c707c30-2cfe-46a0-afa7-8bcc38f9687e"), - # ))) - - # print(update_message(UUID("c3e4755b-141d-4f77-8ea8-924961ccf36d"), content=MessageUpdateRequest(content="wowzer"))) - # print(get_api_keys(api_key_type_id=UUID("3b64fe26-20b9-4064-907e-f2708b5f1656"))) print(get_api_key_type_ids(["612ddae6-ecdd-4900-9314-1a2c9de6003d"])) From 0a4feedb2f237b695fdc2690e60aa321d0f12492 Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:19:33 +0200 Subject: [PATCH 11/13] Add tool endpoints (#201) * add tools (untested) * change api key types endpoint name to kebab case * add error handling on delete tool --------- Signed-off-by: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> --- apps/api/src/__init__.py | 2 +- apps/api/src/interfaces/db.py | 147 +++++++++++++++++--------- apps/api/src/models/__init__.py | 10 ++ apps/api/src/models/subscription.py | 1 - apps/api/src/models/tool.py | 29 +++++ apps/api/src/routers/api_key_types.py | 2 +- apps/api/src/routers/subscriptions.py | 2 +- apps/api/src/routers/tools.py | 44 +++++++- 8 files changed, 179 insertions(+), 58 deletions(-) create mode 100644 apps/api/src/models/tool.py diff --git a/apps/api/src/__init__.py b/apps/api/src/__init__.py index 0c601b7b..c27f4b7f 100644 --- a/apps/api/src/__init__.py +++ b/apps/api/src/__init__.py @@ -47,9 +47,9 @@ app.include_router(api_keys.router) app.include_router(auth_router.router) app.include_router(api_key_types.router) -app.include_router(rest.router) app.include_router(tools.router) app.include_router(subscriptions.router) +app.include_router(rest.router) app.include_router(tiers.router) app.include_router(billing_information.router) diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index e882164b..757281bb 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -36,6 +36,9 @@ Subscription, SubscriptionInsertRequest, SubscriptionUpdateRequest, + Tool, + ToolInsertRequest, + ToolUpdateRequest, SubscriptionGetRequest, Tier, TierInsertRequest, @@ -43,7 +46,6 @@ Billing, BillingInsertRequest, BillingUpdateRequest, - ) from src.models.tiers import TierGetRequest @@ -483,39 +485,6 @@ def delete_crew(crew_id: UUID) -> Crew: return Crew(**response.data[0]) -def get_api_key(api_key_id: UUID) -> APIKey: - supabase: Client = create_client(url, key) - response = ( - supabase.table("users_api_keys") - .select("*, api_key_types(*)") - .eq("id", api_key_id) - .single() - .execute() - ) - - api_key_type = APIKeyType(**response.data["api_key_types"]) - return APIKey(**response.data, api_key_type=api_key_type) - - -def get_tool_api_keys( - profile_id: UUID, api_key_type_ids: list[str] | None = None -) -> dict[str, str]: - """Gets all api keys for a profile id, if api_key_type_ids is given, only give api keys corresponding to those key types.""" - supabase: Client = create_client(url, key) - # casted_ids = [str(api_key_type_id) for api_key_type_id in api_key_type_ids] - query = ( - supabase.table("users_api_keys") - .select("api_key", "api_key_type_id") - .eq("profile_id", profile_id) - ) - - if api_key_type_ids: - query = query.in_("api_key_type_id", api_key_type_ids) - - response = query.execute() - return {data["api_key_type_id"]: data["api_key"] for data in response.data} - - def get_api_key(api_key_id: UUID) -> APIKey | None: supabase: Client = create_client(url, key) response = ( @@ -627,6 +596,15 @@ def update_status(session_id: UUID, status: SessionStatus) -> None: supabase.table("sessions").update({"status": status}).eq("id", session_id).execute() +def get_agent(agent_id: UUID) -> Agent | None: + supabase: Client = create_client(url, key) + response = supabase.table("agents").select("*").eq("id", agent_id).execute() + if not response.data: + return None + + return Agent(**response.data[0]) + + def get_agents( profile_id: UUID | None = None, crew_id: UUID | None = None, @@ -658,15 +636,6 @@ def get_agents( return [Agent(**data) for data in response.data] -def get_agent(agent_id: UUID) -> Agent | None: - supabase: Client = create_client(url, key) - response = supabase.table("agents").select("*").eq("id", agent_id).execute() - if not response.data: - return None - - return Agent(**response.data[0]) - - def get_agents_from_crew(crew_id: UUID) -> list[Agent] | None: supabase: Client = create_client(url, key) nodes = supabase.table("crews").select("nodes").eq("id", crew_id).execute() @@ -704,6 +673,63 @@ def delete_agent(agent_id: UUID) -> Agent: return Agent(**response.data[0]) +def get_tool(tool_id: UUID) -> Tool | None: + supabase: Client = create_client(url, key) + response = supabase.table("tools").select("*").eq("id", tool_id).execute() + if len(response.data) == 0: + return None + + return Tool(**response.data[0]) + + +def get_tools( + name: str | None = None, + api_key_type_id: UUID | None = None, +) -> list[Tool]: + supabase: Client = create_client(url, key) + query = supabase.table("tools").select("*") + + if name: + query = query.eq("name", name) + + if api_key_type_id: + query = query.eq("api_key_type_id", api_key_type_id) + + response = query.execute() + + return [Tool(**data) for data in response.data] + + +def update_tool(tool_id: UUID, content: ToolUpdateRequest) -> Tool: + supabase: Client = create_client(url, key) + response = ( + supabase.table("tools") + .update(json.loads(content.model_dump_json(exclude_none=True))) + .eq("id", tool_id) + .execute() + ) + return Tool(**response.data[0]) + + +def insert_tool(tool: ToolInsertRequest) -> Tool: + supabase: Client = create_client(url, key) + response = ( + supabase.table("tools") + .insert(json.loads(tool.model_dump_json(exclude_none=True))) + .execute() + ) + return Tool(**response.data[0]) + + +def delete_tool(tool_id: UUID) -> Tool | None: + supabase: Client = create_client(url, key) + response = supabase.table("tools").delete().eq("id", tool_id).execute() + if len(response.data) == 0: + return None + + return Tool(**response.data[0]) + + def update_agent_tool(agent_id: UUID, tool_id: UUID) -> Agent: supabase: Client = create_client(url, key) agent_tools = supabase.table("agents").select("tools").eq("id", agent_id).execute() @@ -720,6 +746,33 @@ def update_agent_tool(agent_id: UUID, tool_id: UUID) -> Agent: return Agent(**response.data[0]) +def get_tool_api_keys( + profile_id: UUID, api_key_type_ids: list[str] | None = None +) -> dict[str, str]: + """Gets all api keys for a profile id, if api_key_type_ids is given, only give api keys corresponding to those key types.""" + supabase: Client = create_client(url, key) + # casted_ids = [str(api_key_type_id) for api_key_type_id in api_key_type_ids] + query = ( + supabase.table("users_api_keys") + .select("api_key", "api_key_type_id") + .eq("profile_id", profile_id) + ) + + if api_key_type_ids: + query = query.in_("api_key_type_id", api_key_type_ids) + + response = query.execute() + return {data["api_key_type_id"]: data["api_key"] for data in response.data} + + +def get_profile(profile_id: UUID) -> Profile | None: + supabase: Client = create_client(url, key) + response = supabase.table("profiles").select("*").eq("id", profile_id).execute() + if len(response.data) == 0: + return None + return Profile(**response.data[0]) + + def get_profiles( tier_id: UUID | None = None, display_name: str | None = None, @@ -743,14 +796,6 @@ def get_profiles( return [Profile(**data) for data in response.data] -def get_profile(profile_id: UUID) -> Profile | None: - supabase: Client = create_client(url, key) - response = supabase.table("profiles").select("*").eq("id", profile_id).execute() - if len(response.data) == 0: - return None - return Profile(**response.data[0]) - - def update_profile(profile_id: UUID, content: ProfileUpdateRequest) -> Profile: supabase: Client = create_client(url, key) response = ( diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index b49538d1..86012ec4 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -59,6 +59,12 @@ APIKeyUpdateRequest, APIKeyGetRequest, ) +from .tool import ( + Tool, + ToolInsertRequest, + ToolUpdateRequest, + ToolGetRequest, +) from .user import User __all__ = [ @@ -99,6 +105,10 @@ "SubscriptionInsertRequest", "SubscriptionUpdateRequest", "SubscriptionGetRequest", + "Tool", + "ToolInsertRequest", + "ToolUpdateRequest", + "ToolGetRequest", "Tier", "TierInsertRequest", "TierUpdateRequest", diff --git a/apps/api/src/models/subscription.py b/apps/api/src/models/subscription.py index 53451ece..3bbbd736 100644 --- a/apps/api/src/models/subscription.py +++ b/apps/api/src/models/subscription.py @@ -22,4 +22,3 @@ class SubscriptionUpdateRequest(BaseModel): class SubscriptionGetRequest(BaseModel): profile_id: UUID | None = None stripe_subscription_id: str | None = None - created_at: datetime diff --git a/apps/api/src/models/tool.py b/apps/api/src/models/tool.py new file mode 100644 index 00000000..45106133 --- /dev/null +++ b/apps/api/src/models/tool.py @@ -0,0 +1,29 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + + +class Tool(BaseModel): + id: UUID + created_at: datetime + name: str + description: str + api_key_type_id: UUID | None = None + + +class ToolInsertRequest(BaseModel): + name: str + description: str + api_key_type_id: UUID | None = None + + +class ToolUpdateRequest(BaseModel): + name: str | None = None + description: str | None = None + api_key_type_id: UUID | None = None + + +class ToolGetRequest(BaseModel): + name: str | None = None + api_key_type_id: UUID | None = None \ No newline at end of file diff --git a/apps/api/src/routers/api_key_types.py b/apps/api/src/routers/api_key_types.py index 741bf75f..6b31ecb4 100644 --- a/apps/api/src/routers/api_key_types.py +++ b/apps/api/src/routers/api_key_types.py @@ -8,7 +8,7 @@ APIKeyType, ) -router = APIRouter(prefix="/api_key_types", tags=["api key types"]) +router = APIRouter(prefix="/api-key-types", tags=["api key types"]) logger = logging.getLogger("root") diff --git a/apps/api/src/routers/subscriptions.py b/apps/api/src/routers/subscriptions.py index 88e155ed..801d5825 100644 --- a/apps/api/src/routers/subscriptions.py +++ b/apps/api/src/routers/subscriptions.py @@ -27,7 +27,7 @@ def get_subscriptions(q: SubscriptionGetRequest = Depends()) -> list[Subscriptio return db.get_subscriptions(q.profile_id, q.stripe_subscription_id) -@router.post("/") +@router.post("/", status_code=201) def insert_subscription(subscription: SubscriptionInsertRequest) -> Subscription: return db.insert_subscription(subscription) diff --git a/apps/api/src/routers/tools.py b/apps/api/src/routers/tools.py index 6ae66632..a85c6eb8 100644 --- a/apps/api/src/routers/tools.py +++ b/apps/api/src/routers/tools.py @@ -5,10 +5,11 @@ from src.interfaces import db from src.models import ( - AgentInsertRequest, - AgentUpdateModel, Agent, - AgentGetRequest, + Tool, + ToolGetRequest, + ToolInsertRequest, + ToolUpdateRequest, ) router = APIRouter( @@ -16,6 +17,43 @@ tags=["tools"], ) +@router.get("/") +def get_tools(q: ToolGetRequest = Depends()) -> list[Tool]: + return db.get_tools(q.name, q.api_key_type_id) + + +@router.get("/{tool_id}") +def get_tool(tool_id: UUID) -> Tool: + response = db.get_tool(tool_id) + if not response: + raise HTTPException(404, "tool not found") + + return response + + +@router.post("/", status_code=201) +def insert_tool(tool: ToolInsertRequest) -> Tool: + return db.insert_tool(tool) + + +@router.delete("/{tool_id}") +def delete_tool(tool_id: UUID) -> Tool: + response = db.delete_tool(tool_id) + if not response: + raise HTTPException(404, "could not find tool") + + return response + + +@router.patch("/{tool_id}") +def update_profile( + tool_id: UUID, tool_update_request: ToolUpdateRequest +) -> Tool: + if not db.get_tool(tool_id): + raise HTTPException(404, "tool not found") + + return db.update_tool(tool_id, tool_update_request) + @router.patch("/{agent_id}") def add_tool(agent_id: UUID, tool_id: UUID) -> Agent: From 3cd053a751f35dc44d32b6dee76ec6836f2438e9 Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 18:30:21 +0200 Subject: [PATCH 12/13] Fix sonarcloud issues (#206) fixed the issues in the code i wrote --- apps/api/src/crew.py | 3 +- apps/api/src/interfaces/db.py | 3 +- apps/api/src/mock.py | 13 +++++---- apps/api/src/models/rest_comment.py | 10 ------- apps/api/src/routers/api_keys.py | 5 ++-- apps/api/src/routers/billing_information.py | 6 ---- apps/api/src/routers/rest.py | 31 --------------------- apps/api/src/routers/subscriptions.py | 6 ---- apps/api/src/tools/__init__.py | 4 +-- apps/api/src/tools/scraper.py | 2 -- 10 files changed, 14 insertions(+), 69 deletions(-) delete mode 100644 apps/api/src/models/rest_comment.py diff --git a/apps/api/src/crew.py b/apps/api/src/crew.py index 1c5935e0..c071d23f 100644 --- a/apps/api/src/crew.py +++ b/apps/api/src/crew.py @@ -129,7 +129,7 @@ async def _on_reply( ] ): logger.error( - f"on_reply: both ids are none, sender is not admin and recipient is not chat manager" + "on_reply: both ids are none, sender is not admin and recipient is not chat manager" ) await self.on_reply(recipient_id, sender_id, content, role) @@ -192,6 +192,7 @@ def _create_agents( tool, api_key_types, profile_api_keys ) except TypeError as e: + logger.error(f"tried to generate tool, got error: {e}") raise e ( ( diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index 757281bb..b5452851 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -101,7 +101,7 @@ def get_sessions( def insert_session(content: SessionInsertRequest) -> Session: supabase: Client = create_client(url, key) - logger.info(f"inserting session") + logger.info("inserting session") response = ( supabase.table("sessions") .insert(json.loads(content.model_dump_json())) @@ -751,7 +751,6 @@ def get_tool_api_keys( ) -> dict[str, str]: """Gets all api keys for a profile id, if api_key_type_ids is given, only give api keys corresponding to those key types.""" supabase: Client = create_client(url, key) - # casted_ids = [str(api_key_type_id) for api_key_type_id in api_key_type_ids] query = ( supabase.table("users_api_keys") .select("api_key", "api_key_type_id") diff --git a/apps/api/src/mock.py b/apps/api/src/mock.py index b51e1e0b..4950a868 100644 --- a/apps/api/src/mock.py +++ b/apps/api/src/mock.py @@ -1,4 +1,5 @@ from .tools import get_file_path_of_example +DATE="2024-01-01T00:00:00.000Z" fizz_buzz: dict = { "id": "00000000-0000-0000-0000-000000000000", @@ -16,7 +17,7 @@ "title": "", "content": "Write a program that prints the numbers from 1 to 100. But for multiples of three print “Fizz” instead of the number and for the multiples of five print “Buzz”. For numbers which are multiples of both three and five print “FizzBuzz”.", }, - "created_at": "2024-01-01T00:00:00.000Z", + "created_at": DATE, } markdown_table: dict = { @@ -35,7 +36,7 @@ "title": "", "content": "Create a markdown table of the top 10 large language models comparing their abilities by researching on the internet.", }, - "created_at": "2024-01-01T00:00:00.000Z", + "created_at": DATE, } # read_file: dict = { @@ -55,7 +56,7 @@ # "title": "", # "content": f"Get the file content of the file '{get_file_path_of_example()}', the 'agent python software' can call what function it has been", # }, -# "created_at": "2024-01-01T00:00:00.000Z", +# "created_at": DATE, # } # "6e541720-b4ac-4c47-abf3-f17147c9a32a", agent for code reviewing # "2ce0b7db-84f7-4d59-8c38-3fcc3fd7da98", agent for writing tables in markdown @@ -78,7 +79,7 @@ # "title": "", # "content": f"Move the file: '{get_file_path_of_example()}' to the destination: {get_file_path_of_example().replace('.txt', '_2.txt')} the 'agent python software' can call what function it has been", # }, -# "created_at": "2024-01-01T00:00:00.000Z", +# "created_at": DATE, # } tool, prompt = "brave search tool", "what is openai?" @@ -98,8 +99,8 @@ "title": "", "content": f"This is a tool testing environment, use the tool: {tool}, {prompt}. Suggest this function call", }, - "created_at": "2024-01-01T00:00:00.000Z", + "created_at": DATE, "edges": [], - "updated_at": "2024-01-01T00:00:00.000Z", + "updated_at": DATE, } crew_model = test_tool diff --git a/apps/api/src/models/rest_comment.py b/apps/api/src/models/rest_comment.py deleted file mode 100644 index 195ac3f2..00000000 --- a/apps/api/src/models/rest_comment.py +++ /dev/null @@ -1,10 +0,0 @@ -from uuid import UUID -from pydantic import BaseModel - -# TODO: This is placed here since the openapi schema of this model cant be generated if its in the src/rest/models directory for some reason -# will move this later on but this works for now -class PublishCommentRequest(BaseModel): - lead_id: UUID - comment: str - reddit_username: str - reddit_password: str diff --git a/apps/api/src/routers/api_keys.py b/apps/api/src/routers/api_keys.py index 6b4bc24b..cb7babde 100644 --- a/apps/api/src/routers/api_keys.py +++ b/apps/api/src/routers/api_keys.py @@ -17,9 +17,8 @@ @router.get("/") def get_api_keys(q: APIKeyGetRequest = Depends()) -> list[APIKey]: """Returns api keys with the api key type as an object with the id, name, description etc.""" - if q.profile_id: - if not db.get_profile(q.profile_id): - raise HTTPException(404, "profile not found") + if q.profile_id and not db.get_profile(q.profile_id): + raise HTTPException(404, "profile not found") return db.get_api_keys(q.profile_id, q.api_key_type_id, q.api_key) diff --git a/apps/api/src/routers/billing_information.py b/apps/api/src/routers/billing_information.py index 81b47173..d4e567ee 100644 --- a/apps/api/src/routers/billing_information.py +++ b/apps/api/src/routers/billing_information.py @@ -52,9 +52,3 @@ def update_billing(profile_id: UUID, content: BillingUpdateRequest) -> Billing: return response - -# -# -# @router.get("/{message_id}") -# def get_message(message_id: UUID) -> Message: -# return db.get_message(message_id) diff --git a/apps/api/src/routers/rest.py b/apps/api/src/routers/rest.py index 48e5dcbf..2e2dd201 100644 --- a/apps/api/src/routers/rest.py +++ b/apps/api/src/routers/rest.py @@ -1,34 +1,3 @@ -#import logging -#from uuid import UUID -# -#from fastapi import APIRouter, HTTPException -#from src.models import Crew, Message, Session -#from src.rest import comment_bot -#from src.rest.interfaces import db -#from src.rest.models import PublishCommentRequest, PublishCommentResponse -# -#router = APIRouter(prefix="/rest", tags=["rest"]) -# -#logger = logging.getLogger("root") -# -# -#@router.post("/") -#def publish_comment(publish_request: PublishCommentRequest): -# updated_content = comment_bot.publish_comment( -# publish_request.lead_id, -# publish_request.comment, -# publish_request.reddit_username, -# publish_request.reddit_password, -# ) -# if updated_content is None: -# raise HTTPException(404, "lead not found") -# -# return updated_content -# -#@router.get("/") -#def get_leads() -> list[PublishCommentResponse]: -# return db.get_all_leads() -# import os from dotenv import load_dotenv import logging diff --git a/apps/api/src/routers/subscriptions.py b/apps/api/src/routers/subscriptions.py index 801d5825..1f13b926 100644 --- a/apps/api/src/routers/subscriptions.py +++ b/apps/api/src/routers/subscriptions.py @@ -51,9 +51,3 @@ def update_subscription( return response - -# -# -# @router.get("/{message_id}") -# def get_message(message_id: UUID) -> Message: -# return db.get_message(message_id) diff --git a/apps/api/src/tools/__init__.py b/apps/api/src/tools/__init__.py index caf7193c..2d792261 100644 --- a/apps/api/src/tools/__init__.py +++ b/apps/api/src/tools/__init__.py @@ -104,13 +104,13 @@ def generate_tool_from_uuid( api_key = api_keys[tool_key_type] if has_param(tool_cls, "api_key"): - logger.info(f"has parameter 'api_key'") + logger.info("has parameter 'api_key'") if not api_key: raise TypeError( "api key should not be none when passed to tool that needs api key" ) tool_object = tools[tool_id](api_key=api_key) - logger.info(f"creating tool") + logger.info("creating tool") return tool_object logger.info("making tool without api_key") diff --git a/apps/api/src/tools/scraper.py b/apps/api/src/tools/scraper.py index eb2bb65a..454b7c23 100644 --- a/apps/api/src/tools/scraper.py +++ b/apps/api/src/tools/scraper.py @@ -12,8 +12,6 @@ ID = "4ac25953-dc41-42d5-b9f2-bcae3b2c1d9f" API_KEY_TYPE = "3b64fe26-20b9-4064-907e-f2708b5f1656" -# key = os.environ.get("SERPAPI_API_KEY") - class ScraperToolInput(BaseModel): query: str = Field( From e5b72cce3073986fe0caa114d6b9052fe60331a2 Mon Sep 17 00:00:00 2001 From: Leon Nilsson <70595163+failandimprove1@users.noreply.github.com> Date: Wed, 17 Apr 2024 19:42:07 +0200 Subject: [PATCH 13/13] Format and fix typing (#207) * format * fixup on typing --- apps/api/poetry.lock | 2 +- apps/api/pyproject.toml | 1 + apps/api/src/__init__.py | 14 ++-- apps/api/src/auth.py | 11 ++- apps/api/src/crew.py | 8 ++- apps/api/src/dependencies/__init__.py | 1 + apps/api/src/interfaces/db.py | 42 +++++------- apps/api/src/mock.py | 3 +- apps/api/src/models/__init__.py | 68 ++++++++----------- apps/api/src/models/agent_model.py | 2 +- apps/api/src/models/api_key.py | 6 +- apps/api/src/models/billing_information.py | 1 - apps/api/src/models/crew_model.py | 7 +- apps/api/src/models/edge.py | 8 ++- apps/api/src/models/message.py | 10 +-- apps/api/src/models/profile.py | 5 +- apps/api/src/models/session.py | 12 ++-- apps/api/src/models/tool.py | 2 +- apps/api/src/models/user.py | 2 +- apps/api/src/parser.py | 3 +- apps/api/src/rest/__init__.py | 26 +++---- apps/api/src/rest/chat_bot.py | 14 ++-- apps/api/src/rest/comment_bot.py | 17 ++--- apps/api/src/rest/dm.py | 14 ++-- apps/api/src/rest/interfaces/db.py | 6 +- apps/api/src/rest/logging_utils.py | 4 +- apps/api/src/rest/mail.py | 12 ++-- apps/api/src/rest/models/__init__.py | 8 +-- apps/api/src/rest/models/dummy_submission.py | 3 +- .../src/rest/models/evaluated_submission.py | 1 + apps/api/src/rest/models/false_lead.py | 3 +- apps/api/src/rest/models/filter_output.py | 5 +- apps/api/src/rest/models/filter_question.py | 2 +- apps/api/src/rest/models/lead.py | 10 +-- apps/api/src/rest/models/publish_comment.py | 5 +- apps/api/src/rest/models/reddit_comment.py | 2 +- apps/api/src/rest/models/relevance_result.py | 9 ++- apps/api/src/rest/models/saved_submission.py | 7 +- apps/api/src/rest/prompts/__init__.py | 9 ++- apps/api/src/rest/prompts/commenting.py | 20 ++++-- apps/api/src/rest/prompts/relevance.py | 18 +++-- apps/api/src/rest/reddit_utils.py | 5 +- apps/api/src/rest/reddit_worker.py | 12 ++-- apps/api/src/rest/relevance_bot.py | 32 +++++---- apps/api/src/rest/saving.py | 3 +- apps/api/src/rest/utils.py | 9 ++- apps/api/src/routers/agents.py | 10 ++- apps/api/src/routers/api_key_types.py | 6 +- apps/api/src/routers/api_keys.py | 7 +- apps/api/src/routers/billing_information.py | 7 +- apps/api/src/routers/crews.py | 7 +- apps/api/src/routers/messages.py | 15 ++-- apps/api/src/routers/profiles.py | 7 +- apps/api/src/routers/rest.py | 26 ++++--- apps/api/src/routers/sessions.py | 29 ++++---- apps/api/src/routers/subscriptions.py | 3 +- apps/api/src/routers/tiers.py | 2 +- apps/api/src/routers/tools.py | 9 ++- apps/api/src/tools/__init__.py | 27 ++++---- apps/api/src/tools/alpha_vantage.py | 2 +- apps/api/src/tools/arxiv_tool.py | 5 +- apps/api/src/tools/bing.py | 22 +++--- apps/api/src/tools/brave_search.py | 2 +- apps/api/src/tools/duckduckgo_tool.py | 21 ++++-- apps/api/src/tools/google_serper.py | 52 ++++++++------ apps/api/src/tools/scraper.py | 2 +- apps/api/src/tools/stackapi_tool.py | 4 +- apps/api/src/tools/wikipedia_tool.py | 2 +- 68 files changed, 386 insertions(+), 345 deletions(-) diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index ef97f18c..5cf67919 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -4636,4 +4636,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.13" -content-hash = "0162a9621b5487162b64103aeb609b65901aecb2ee79b06d6f824b5a49c3cd62" +content-hash = "3be1bebffa9dce9a2cea13c7d5eea6ac68c50fc9f7b79a38b5d5011132cc15c5" diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index de01e355..09757254 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -40,6 +40,7 @@ mail = "^2.1.0" duckduckgo-search = "^5.2.2" langchain-exa = "^0.0.1" stackapi = "^0.3.0" +mypy = "^1.9.0" [tool.poetry.group.dev.dependencies] mypy = "^1.7.0" diff --git a/apps/api/src/__init__.py b/apps/api/src/__init__.py index c27f4b7f..83122438 100644 --- a/apps/api/src/__init__.py +++ b/apps/api/src/__init__.py @@ -18,21 +18,19 @@ ) from .improver import PromptType, improve_prompt from .interfaces import db -from .models import CrewProcessed +from .models import Profile +from .routers import agents, api_key_types, api_keys from .routers import auth as auth_router from .routers import ( - agents, + billing_information, crews, messages, - sessions, profiles, - api_key_types, rest, - api_keys, - tools, + sessions, subscriptions, tiers, - billing_information, + tools, ) logger = logging.getLogger("root") @@ -114,5 +112,5 @@ def auto_build_crew(general_task: str) -> str: @app.get("/me") -def get_profile_from_header(current_user=Depends(get_current_user)): +def get_profile_from_header(current_user=Depends(get_current_user)) -> Profile: return current_user diff --git a/apps/api/src/auth.py b/apps/api/src/auth.py index 50b1fd83..6c76261e 100644 --- a/apps/api/src/auth.py +++ b/apps/api/src/auth.py @@ -5,13 +5,10 @@ import jwt from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException, status -from fastapi.security import ( - HTTPAuthorizationCredentials, - HTTPBearer, - -) +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from src.interfaces import db +from src.models import Profile load_dotenv() @@ -21,7 +18,7 @@ logger = logging.getLogger("root") -async def get_current_user(token: HTTPAuthorizationCredentials = Depends(HTTPBearer())): +async def get_current_user(token: HTTPAuthorizationCredentials = Depends(HTTPBearer())) -> Profile: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -43,4 +40,4 @@ async def get_current_user(token: HTTPAuthorizationCredentials = Depends(HTTPBea if not user_id or not profile: raise credentials_exception - return profile \ No newline at end of file + return profile diff --git a/apps/api/src/crew.py b/apps/api/src/crew.py index c071d23f..6cac7eb1 100644 --- a/apps/api/src/crew.py +++ b/apps/api/src/crew.py @@ -5,6 +5,7 @@ import autogen from autogen.cache import Cache +from langchain.tools import BaseTool from src.models.session import SessionStatus @@ -18,6 +19,7 @@ logger = logging.getLogger("root") + class AutogenCrew: def __init__( self, @@ -33,7 +35,7 @@ def __init__( self.session = session self.on_reply = on_message self.crew_model = crew_model - self.valid_tools = [] + self.valid_tools: list[BaseTool] = [] self.agents: list[autogen.ConversableAgent | autogen.Agent] = ( self._create_agents(crew_model) @@ -56,7 +58,7 @@ def __init__( ) if self.valid_tools else None - ) + ) self.user_proxy.register_reply([autogen.Agent, None], self._on_reply) self.base_config_list = autogen.config_list_from_json( @@ -174,7 +176,7 @@ def _create_agents( for agent in crew_model.agents: valid_agent_tools = [] - tool_schemas = {} + tool_schemas: list[dict] | None config_list = autogen.config_list_from_json( "OAI_CONFIG_LIST", filter_dict={ diff --git a/apps/api/src/dependencies/__init__.py b/apps/api/src/dependencies/__init__.py index 5e163b3b..c551020e 100644 --- a/apps/api/src/dependencies/__init__.py +++ b/apps/api/src/dependencies/__init__.py @@ -20,6 +20,7 @@ if url is None or key is None: raise ValueError("SUPABASE_URL and SUPABASE_ANON_KEY must be set") + @dataclass class RateLimitResponse: limit: int diff --git a/apps/api/src/interfaces/db.py b/apps/api/src/interfaces/db.py index b5452851..4675324c 100644 --- a/apps/api/src/interfaces/db.py +++ b/apps/api/src/interfaces/db.py @@ -13,39 +13,36 @@ Agent, AgentInsertRequest, AgentUpdateModel, - CrewInsertRequest, + APIKey, + APIKeyInsertRequest, + APIKeyType, + APIKeyUpdateRequest, + Billing, + BillingInsertRequest, + BillingUpdateRequest, Crew, + CrewInsertRequest, CrewUpdateRequest, Message, + MessageInsertRequest, + MessageUpdateRequest, Profile, + ProfileInsertRequest, ProfileUpdateRequest, Session, SessionInsertRequest, - Session, SessionStatus, SessionUpdateRequest, - ProfileInsertRequest, - APIKeyInsertRequest, - APIKey, - APIKeyType, - APIKeyUpdateRequest, - APIKeyType, - MessageInsertRequest, - Message, - MessageUpdateRequest, Subscription, + SubscriptionGetRequest, SubscriptionInsertRequest, SubscriptionUpdateRequest, - Tool, - ToolInsertRequest, - ToolUpdateRequest, - SubscriptionGetRequest, Tier, TierInsertRequest, TierUpdateRequest, - Billing, - BillingInsertRequest, - BillingUpdateRequest, + Tool, + ToolInsertRequest, + ToolUpdateRequest, ) from src.models.tiers import TierGetRequest @@ -280,7 +277,6 @@ def update_subscription( return Subscription(**response.data[0]) - def get_tier(id: UUID) -> Tier | None: """Gets tiers, filtered by what parameters are given""" supabase: Client = create_client(url, key) @@ -350,7 +346,7 @@ def insert_billing(billing: BillingInsertRequest) -> Billing: .insert(json.loads(billing.model_dump_json(exclude_none=True))) .execute() ) - + return Billing(**response.data[0]) @@ -376,14 +372,12 @@ def update_billing(profile_id: UUID, content: BillingUpdateRequest) -> Billing | supabase.table("billing_information") .update(json.loads(content.model_dump_json(exclude_none=True))) .eq("profile_id", profile_id) - .execute() ) if len(response.data) == 0: return None - - return Billing(**response.data[0]) + return Billing(**response.data[0]) def get_descriptions(agent_ids: list[UUID]) -> dict[UUID, list[str]] | None: @@ -679,7 +673,7 @@ def get_tool(tool_id: UUID) -> Tool | None: if len(response.data) == 0: return None - return Tool(**response.data[0]) + return Tool(**response.data[0]) def get_tools( diff --git a/apps/api/src/mock.py b/apps/api/src/mock.py index 4950a868..40600c41 100644 --- a/apps/api/src/mock.py +++ b/apps/api/src/mock.py @@ -1,5 +1,6 @@ from .tools import get_file_path_of_example -DATE="2024-01-01T00:00:00.000Z" + +DATE = "2024-01-01T00:00:00.000Z" fizz_buzz: dict = { "id": "00000000-0000-0000-0000-000000000000", diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 86012ec4..f9361e0c 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -1,70 +1,60 @@ from .agent_config import AgentConfig from .agent_model import ( Agent, + AgentGetRequest, AgentInsertRequest, AgentUpdateModel, - AgentGetRequest, +) +from .api_key import ( + APIKey, + APIKeyGetRequest, + APIKeyInsertRequest, + APIKeyType, + APIKeyUpdateRequest, +) +from .billing_information import ( + Billing, + BillingInsertRequest, + BillingUpdateRequest, ) from .code_execution_config import CodeExecutionConfig from .crew_model import ( - CrewProcessed, - CrewInsertRequest, Crew, - CrewUpdateRequest, CrewGetRequest, + CrewInsertRequest, + CrewProcessed, + CrewUpdateRequest, ) from .llm_config import LLMConfig from .message import ( Message, + MessageGetRequest, MessageInsertRequest, MessageUpdateRequest, - MessageGetRequest, -) -from .subscription import ( - Subscription, - SubscriptionInsertRequest, - SubscriptionUpdateRequest, - SubscriptionGetRequest, -) -from .tiers import ( - Tier, - TierInsertRequest, - TierUpdateRequest, - TierGetRequest, -) -from .billing_information import ( - Billing, - BillingInsertRequest, - BillingUpdateRequest, ) from .profile import ( - ProfileInsertRequest, Profile, - ProfileUpdateRequest, ProfileGetRequest, + ProfileInsertRequest, + ProfileUpdateRequest, ) from .session import ( - SessionRunRequest, - SessionRunResponse, Session, + SessionGetRequest, SessionInsertRequest, + SessionRunRequest, + SessionRunResponse, SessionStatus, SessionUpdateRequest, - SessionGetRequest, -) -from .api_key import ( - APIKeyInsertRequest, - APIKey, - APIKeyType, - APIKeyUpdateRequest, - APIKeyGetRequest, ) -from .tool import ( - Tool, - ToolInsertRequest, - ToolUpdateRequest, - ToolGetRequest, +from .subscription import ( + Subscription, + SubscriptionGetRequest, + SubscriptionInsertRequest, + SubscriptionUpdateRequest, ) +from .tiers import Tier, TierGetRequest, TierInsertRequest, TierUpdateRequest +from .tool import Tool, ToolGetRequest, ToolInsertRequest, ToolUpdateRequest from .user import User __all__ = [ diff --git a/apps/api/src/models/agent_model.py b/apps/api/src/models/agent_model.py index 8a4c6a15..c4cc63f0 100644 --- a/apps/api/src/models/agent_model.py +++ b/apps/api/src/models/agent_model.py @@ -18,7 +18,7 @@ class Agent(BaseModel): model: Literal["gpt-3.5-turbo", "gpt-4-turbo-preview"] tools: list[dict] description: str | None = None - role: str + role: str version: str | None = None diff --git a/apps/api/src/models/api_key.py b/apps/api/src/models/api_key.py index 9ee5febe..b4c5a07d 100644 --- a/apps/api/src/models/api_key.py +++ b/apps/api/src/models/api_key.py @@ -1,6 +1,8 @@ from __future__ import annotations -from uuid import UUID, uuid4 + from datetime import datetime +from uuid import UUID, uuid4 + from pydantic import BaseModel @@ -32,4 +34,4 @@ class APIKeyType(BaseModel): id: UUID created_at: datetime name: str | None = None - description: str | None = None \ No newline at end of file + description: str | None = None diff --git a/apps/api/src/models/billing_information.py b/apps/api/src/models/billing_information.py index e9590672..95308c77 100644 --- a/apps/api/src/models/billing_information.py +++ b/apps/api/src/models/billing_information.py @@ -20,4 +20,3 @@ class BillingInsertRequest(BaseModel): class BillingUpdateRequest(BaseModel): stripe_payment_method: str | None = None description: str | None = None - diff --git a/apps/api/src/models/crew_model.py b/apps/api/src/models/crew_model.py index 65d274d1..08c99efe 100644 --- a/apps/api/src/models/crew_model.py +++ b/apps/api/src/models/crew_model.py @@ -11,12 +11,13 @@ class CrewProcessed(BaseModel): receiver_id: UUID - delegator_id: UUID | None = None + delegator_id: UUID | None = None # None means admin again, so its the original crew (has no parent crew) agents: list[Agent] - sub_crews: list[Crew] = [] + sub_crews: list[Crew] = [] # Must set delegator_id for each sub_crew in sub_crews + class Crew(BaseModel): id: UUID created_at: datetime @@ -58,4 +59,4 @@ class CrewGetRequest(BaseModel): profile_id: UUID | None = None receiver_id: UUID | None = None title: str | None = None - published: bool | None = None \ No newline at end of file + published: bool | None = None diff --git a/apps/api/src/models/edge.py b/apps/api/src/models/edge.py index 7e82a997..e7a4c8cf 100644 --- a/apps/api/src/models/edge.py +++ b/apps/api/src/models/edge.py @@ -1,8 +1,10 @@ -from typing import Optional, Union, Generic, TypeVar +from typing import Generic, Optional, TypeVar, Union + from pydantic import BaseModel, Field T = TypeVar("T") + class Marker(BaseModel): type: str color: Optional[str] = None @@ -12,11 +14,13 @@ class Marker(BaseModel): orient: Optional[str] = None strokeWidth: Optional[float] = None + class PathOptions(BaseModel): offset: Optional[float] = None borderRadius: Optional[float] = None curvature: Optional[float] = None + class Edge(BaseModel, Generic[T]): id: str type: Optional[str] = None @@ -42,4 +46,4 @@ class Edge(BaseModel, Generic[T]): pathOptions: Optional[PathOptions] = None class Config: - populate_by_name = True \ No newline at end of file + populate_by_name = True diff --git a/apps/api/src/models/message.py b/apps/api/src/models/message.py index 0d3d8843..ba567b5a 100644 --- a/apps/api/src/models/message.py +++ b/apps/api/src/models/message.py @@ -5,13 +5,13 @@ class Message(BaseModel): - id: UUID + id: UUID session_id: UUID profile_id: UUID sender_id: UUID | None = None # None means admin here recipient_id: UUID | None = None # None means admin here aswell content: str - role: str + role: str created_at: datetime @@ -28,13 +28,13 @@ class MessageUpdateRequest(BaseModel): session_id: UUID | None = None content: str | None = None role: str | None = None - recipient_id: UUID | None = None - sender_id: UUID | None = None + recipient_id: UUID | None = None + sender_id: UUID | None = None profile_id: UUID | None = None class MessageGetRequest(BaseModel): session_id: UUID | None = None profile_id: UUID | None = None - recipient_id: UUID | None = None + recipient_id: UUID | None = None sender_id: UUID | None = None diff --git a/apps/api/src/models/profile.py b/apps/api/src/models/profile.py index a269234d..cad8ca11 100644 --- a/apps/api/src/models/profile.py +++ b/apps/api/src/models/profile.py @@ -1,9 +1,8 @@ +from datetime import datetime from uuid import UUID, uuid4 from pydantic import BaseModel, Field -from datetime import datetime -# = Field(default_factory=lambda: uuid4()) class Profile(BaseModel): id: UUID @@ -14,7 +13,7 @@ class Profile(BaseModel): class ProfileInsertRequest(BaseModel): - # user id needs to be passed since its created from some "auth" table in the db + # user id needs to be passed since its created from some "auth" table in the db user_id: UUID tier_id: UUID display_name: str diff --git a/apps/api/src/models/session.py b/apps/api/src/models/session.py index 82fc2021..988ab3c6 100644 --- a/apps/api/src/models/session.py +++ b/apps/api/src/models/session.py @@ -13,14 +13,14 @@ class SessionStatus(StrEnum): class Session(BaseModel): - id: UUID + id: UUID created_at: datetime profile_id: UUID - reply: str + reply: str crew_id: UUID - title: str - last_opened_at: datetime - status: SessionStatus + title: str + last_opened_at: datetime + status: SessionStatus class SessionInsertRequest(BaseModel): @@ -54,4 +54,4 @@ class SessionGetRequest(BaseModel): profile_id: UUID | None = None crew_id: UUID | None = None title: str | None = None - status: SessionStatus | None = None \ No newline at end of file + status: SessionStatus | None = None diff --git a/apps/api/src/models/tool.py b/apps/api/src/models/tool.py index 45106133..2f13101a 100644 --- a/apps/api/src/models/tool.py +++ b/apps/api/src/models/tool.py @@ -26,4 +26,4 @@ class ToolUpdateRequest(BaseModel): class ToolGetRequest(BaseModel): name: str | None = None - api_key_type_id: UUID | None = None \ No newline at end of file + api_key_type_id: UUID | None = None diff --git a/apps/api/src/models/user.py b/apps/api/src/models/user.py index 50bdeb63..ad1649bd 100644 --- a/apps/api/src/models/user.py +++ b/apps/api/src/models/user.py @@ -4,4 +4,4 @@ class User: id: UUID name: str - email: str \ No newline at end of file + email: str diff --git a/apps/api/src/parser.py b/apps/api/src/parser.py index f75b4390..ba0e6aa7 100644 --- a/apps/api/src/parser.py +++ b/apps/api/src/parser.py @@ -46,12 +46,13 @@ def get_agents(agent_ids: list[UUID]) -> list[Agent]: response = supabase.table("agents").select("*").in_("id", agent_ids).execute() return [Agent(**agent) for agent in response.data] + def process_crew(crew: Crew) -> tuple[str, CrewProcessed]: logger.debug("Processing crew") agent_ids: list[UUID] = crew.nodes if not crew.receiver_id: raise HTTPException(400, "got no receiver id") - + receiver_id: UUID = crew.receiver_id crew_model = CrewProcessed( diff --git a/apps/api/src/rest/__init__.py b/apps/api/src/rest/__init__.py index 735ea8d6..364f2d9f 100644 --- a/apps/api/src/rest/__init__.py +++ b/apps/api/src/rest/__init__.py @@ -1,27 +1,21 @@ -import os -from dotenv import load_dotenv import logging -import diskcache as dc +import os import threading from uuid import uuid4 -from .saving import update_db_with_submission -from . import mail -from .reddit_utils import get_subreddits -from .relevance_bot import evaluate_relevance -from .interfaces import db -from . import comment_bot -from .models import ( - PublishCommentRequest, - GenerateCommentRequest, - FalseLead, -) -from .reddit_worker import RedditStreamWorker - +import diskcache as dc +from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse +from . import comment_bot, mail +from .interfaces import db +from .models import FalseLead, GenerateCommentRequest, PublishCommentRequest +from .reddit_utils import get_subreddits +from .reddit_worker import RedditStreamWorker +from .relevance_bot import evaluate_relevance +from .saving import update_db_with_submission # Relevant subreddits to Startino SUBREDDIT_NAMES = ( diff --git a/apps/api/src/rest/chat_bot.py b/apps/api/src/rest/chat_bot.py index b480b463..4c32a7e7 100644 --- a/apps/api/src/rest/chat_bot.py +++ b/apps/api/src/rest/chat_bot.py @@ -2,24 +2,24 @@ from selenium.webdriver.common.keys import Keys # Set the path to your Chrome driver executable -driver_path = '/path/to/chromedriver' +driver_path = "/path/to/chromedriver" # Create a new instance of the Chrome driver driver = webdriver.Chrome(driver_path) # Open chat.reddit.com -driver.get('https://chat.reddit.com') +driver.get("https://chat.reddit.com") # Find the login button and click it login_button = driver.find_element_by_xpath('//button[contains(text(), "Log in")]') login_button.click() # Find the username and password input fields and enter your credentials -username_input = driver.find_element_by_name('username') -username_input.send_keys('your_username') +username_input = driver.find_element_by_name("username") +username_input.send_keys("your_username") -password_input = driver.find_element_by_name('password') -password_input.send_keys('your_password') +password_input = driver.find_element_by_name("password") +password_input.send_keys("your_password") # Submit the login form -password_input.send_keys(Keys.RETURN) \ No newline at end of file +password_input.send_keys(Keys.RETURN) diff --git a/apps/api/src/rest/comment_bot.py b/apps/api/src/rest/comment_bot.py index 5ffd7395..121b9e36 100644 --- a/apps/api/src/rest/comment_bot.py +++ b/apps/api/src/rest/comment_bot.py @@ -1,16 +1,17 @@ import logging -from langchain_openai import ChatOpenAI -from langchain_core.prompts import PromptTemplate +import os + +from dotenv import load_dotenv from langchain_core.output_parsers import JsonOutputParser -from .models import EvaluatedSubmission, RedditComment, PublishCommentResponse -from .dummy_submissions import relevant_submissions, irrelevant_submissions -from .prompts import generate_comment_prompt +from langchain_core.prompts import PromptTemplate +from langchain_openai import ChatOpenAI + +from .dummy_submissions import irrelevant_submissions, relevant_submissions from .interfaces import db +from .models import EvaluatedSubmission, PublishCommentResponse, RedditComment +from .prompts import generate_comment_prompt from .reddit_utils import get_reddit_instance -from dotenv import load_dotenv -import os - # Load Enviornment variables load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") diff --git a/apps/api/src/rest/dm.py b/apps/api/src/rest/dm.py index 77853a82..76561033 100644 --- a/apps/api/src/rest/dm.py +++ b/apps/api/src/rest/dm.py @@ -1,9 +1,15 @@ -from reddit_utils import REDDIT_PASSWORD, get_subreddits, reply -from Reddit_ChatBot_Python import ChatBot, RedditAuthentication -from Reddit_ChatBot_Python import CustomType, Snoo, Reaction -from dotenv import load_dotenv import os +from dotenv import load_dotenv +from Reddit_ChatBot_Python import ( + ChatBot, + CustomType, + Reaction, + RedditAuthentication, + Snoo, +) +from reddit_utils import REDDIT_PASSWORD, get_subreddits, reply + load_dotenv() REDDIT_PASSWORD = os.getenv("REDDIT_PASSWORD") diff --git a/apps/api/src/rest/interfaces/db.py b/apps/api/src/rest/interfaces/db.py index a6e1745a..c558590b 100644 --- a/apps/api/src/rest/interfaces/db.py +++ b/apps/api/src/rest/interfaces/db.py @@ -1,6 +1,7 @@ import json import logging import os +from datetime import datetime, timedelta from typing import Literal from uuid import UUID @@ -8,10 +9,7 @@ from pydantic import ValidationError from supabase import Client, create_client -from src.rest.models import Lead, PublishCommentResponse -from datetime import datetime, timedelta - -from src.rest.models import SavedSubmission +from src.rest.models import Lead, PublishCommentResponse, SavedSubmission load_dotenv() diff --git a/apps/api/src/rest/logging_utils.py b/apps/api/src/rest/logging_utils.py index cf2dfdd3..fad61c9e 100644 --- a/apps/api/src/rest/logging_utils.py +++ b/apps/api/src/rest/logging_utils.py @@ -1,6 +1,7 @@ -from praw.models import Submission from datetime import datetime +from praw.models import Submission + def log_relevance_calculation( model: str, submission: Submission, is_relevant: bool, cost: float, reason: str @@ -18,4 +19,3 @@ def log_relevance_calculation( print(f"Is Relevant: {'Yes' if is_relevant else 'No'}") print(f"Reason: {reason}") print("\n\n") - diff --git a/apps/api/src/rest/mail.py b/apps/api/src/rest/mail.py index 5013c3a8..69327966 100644 --- a/apps/api/src/rest/mail.py +++ b/apps/api/src/rest/mail.py @@ -1,12 +1,14 @@ -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart import os -from dotenv import load_dotenv +import smtplib from datetime import datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + import diskcache as dc -from .models import EvaluatedSubmission import markdown +from dotenv import load_dotenv + +from .models import EvaluatedSubmission load_dotenv() diff --git a/apps/api/src/rest/models/__init__.py b/apps/api/src/rest/models/__init__.py index fbac0500..7077061e 100644 --- a/apps/api/src/rest/models/__init__.py +++ b/apps/api/src/rest/models/__init__.py @@ -1,13 +1,13 @@ -from .relevance_result import RelevanceResult from .dummy_submission import DummySubmission +from .evaluated_submission import EvaluatedSubmission +from .false_lead import FalseLead from .filter_output import FilterOutput from .filter_question import FilterQuestion -from .evaluated_submission import EvaluatedSubmission from .lead import Lead -from .reddit_comment import RedditComment, GenerateCommentRequest from .publish_comment import PublishCommentRequest, PublishCommentResponse +from .reddit_comment import GenerateCommentRequest, RedditComment +from .relevance_result import RelevanceResult from .saved_submission import SavedSubmission -from .false_lead import FalseLead __all__ = [ "RelevanceResult", diff --git a/apps/api/src/rest/models/dummy_submission.py b/apps/api/src/rest/models/dummy_submission.py index 2b2526cf..ebc03630 100644 --- a/apps/api/src/rest/models/dummy_submission.py +++ b/apps/api/src/rest/models/dummy_submission.py @@ -1,8 +1,9 @@ from pydantic import BaseModel, Field + class DummySubmission(BaseModel): id: str url: str created_utc: int title: str - selftext: str \ No newline at end of file + selftext: str diff --git a/apps/api/src/rest/models/evaluated_submission.py b/apps/api/src/rest/models/evaluated_submission.py index 8ae024fc..2f71eeef 100644 --- a/apps/api/src/rest/models/evaluated_submission.py +++ b/apps/api/src/rest/models/evaluated_submission.py @@ -1,4 +1,5 @@ from typing import Optional + from praw.models import Submission from pydantic import BaseModel, ConfigDict diff --git a/apps/api/src/rest/models/false_lead.py b/apps/api/src/rest/models/false_lead.py index e8f7a249..d643aef4 100644 --- a/apps/api/src/rest/models/false_lead.py +++ b/apps/api/src/rest/models/false_lead.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel from uuid import UUID +from pydantic import BaseModel + class FalseLead(BaseModel): lead_id: UUID diff --git a/apps/api/src/rest/models/filter_output.py b/apps/api/src/rest/models/filter_output.py index 22fe05ec..7e3145e2 100644 --- a/apps/api/src/rest/models/filter_output.py +++ b/apps/api/src/rest/models/filter_output.py @@ -5,7 +5,10 @@ from langchain_core.pydantic_v1 import BaseModel, Field, validator from langchain_openai import ChatOpenAI + # Define your desired data structure. class FilterOutput(BaseModel): answer: bool = Field(description="Answer to the yes-no question.") - source: str = Field(description="Either the piece of text you used to answer the question or the logical reason behind it. Should be brief and only have the relevant information") + source: str = Field( + description="Either the piece of text you used to answer the question or the logical reason behind it. Should be brief and only have the relevant information" + ) diff --git a/apps/api/src/rest/models/filter_question.py b/apps/api/src/rest/models/filter_question.py index 66cb8609..36c8bb47 100644 --- a/apps/api/src/rest/models/filter_question.py +++ b/apps/api/src/rest/models/filter_question.py @@ -1,6 +1,6 @@ from pydantic import BaseModel + class FilterQuestion(BaseModel): question: str reject_on: bool - diff --git a/apps/api/src/rest/models/lead.py b/apps/api/src/rest/models/lead.py index 8082c90f..ce9c05c4 100644 --- a/apps/api/src/rest/models/lead.py +++ b/apps/api/src/rest/models/lead.py @@ -1,8 +1,9 @@ -from re import S -from pydantic import BaseModel, Field from datetime import UTC, datetime +from re import S from uuid import UUID, uuid4 +from pydantic import BaseModel, Field + class Lead(BaseModel): """ @@ -10,10 +11,9 @@ class Lead(BaseModel): """ id: UUID = Field(default_factory=lambda: uuid4()) - submission_id : UUID + submission_id: UUID reddit_id: str - discovered_at: datetime = Field( - default_factory=lambda: datetime.now(tz=UTC)) + discovered_at: datetime = Field(default_factory=lambda: datetime.now(tz=UTC)) last_contacted_at: datetime | None = None prospect_username: str source: str diff --git a/apps/api/src/rest/models/publish_comment.py b/apps/api/src/rest/models/publish_comment.py index 5633bfad..f6fbe816 100644 --- a/apps/api/src/rest/models/publish_comment.py +++ b/apps/api/src/rest/models/publish_comment.py @@ -1,13 +1,16 @@ from datetime import datetime from uuid import UUID + from pydantic import BaseModel + class PublishCommentRequest(BaseModel): lead_id: UUID comment: str reddit_username: str reddit_password: str + class PublishCommentDataObject(BaseModel): url: str body: str @@ -25,4 +28,4 @@ class PublishCommentResponse(BaseModel): data: PublishCommentDataObject | None = None last_event: str status: str - comment: str | None = None \ No newline at end of file + comment: str | None = None diff --git a/apps/api/src/rest/models/reddit_comment.py b/apps/api/src/rest/models/reddit_comment.py index b99184d7..b8823d42 100644 --- a/apps/api/src/rest/models/reddit_comment.py +++ b/apps/api/src/rest/models/reddit_comment.py @@ -4,11 +4,11 @@ from pydantic import BaseModel as PydanticBaseModel - class RedditComment(BaseModel): comment: str = Field(description="the text of the reddit comment") # Not sure if this should be a model or simply a string. + class GenerateCommentRequest(PydanticBaseModel): title: str selftext: str diff --git a/apps/api/src/rest/models/relevance_result.py b/apps/api/src/rest/models/relevance_result.py index 0c3caa0c..fc797ca7 100644 --- a/apps/api/src/rest/models/relevance_result.py +++ b/apps/api/src/rest/models/relevance_result.py @@ -1,6 +1,11 @@ from langchain_core.pydantic_v1 import BaseModel, Field + class RelevanceResult(BaseModel): - certainty: float = Field(description="A value between 0-1 to determine how certain you are that the is_relevant answer is factually correct.") + certainty: float = Field( + description="A value between 0-1 to determine how certain you are that the is_relevant answer is factually correct." + ) is_relevant: bool = Field(description="Determines if the post is relevant.") - reason: str = Field(description="Explain why you determined this post is relevant or irrelevant. Format: Post is [answer] because [reason]. Hence, it is not a lead and not relevant") + reason: str = Field( + description="Explain why you determined this post is relevant or irrelevant. Format: Post is [answer] because [reason]. Hence, it is not a lead and not relevant" + ) diff --git a/apps/api/src/rest/models/saved_submission.py b/apps/api/src/rest/models/saved_submission.py index 9e40fe8e..35b94d28 100644 --- a/apps/api/src/rest/models/saved_submission.py +++ b/apps/api/src/rest/models/saved_submission.py @@ -1,7 +1,8 @@ -from pydantic import BaseModel, Field -from datetime import datetime, UTC +from datetime import UTC, datetime from typing import Optional -from uuid import uuid4, UUID +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field class SavedSubmission(BaseModel): diff --git a/apps/api/src/rest/prompts/__init__.py b/apps/api/src/rest/prompts/__init__.py index 91ad6927..60e1a535 100644 --- a/apps/api/src/rest/prompts/__init__.py +++ b/apps/api/src/rest/prompts/__init__.py @@ -1,5 +1,12 @@ -from .relevance import calculate_relevance_prompt, purpose, ideal_customer_profile, context, good_examples, bad_examples from .commenting import generate_comment_prompt +from .relevance import ( + bad_examples, + calculate_relevance_prompt, + context, + good_examples, + ideal_customer_profile, + purpose, +) __all__ = [ "calculate_relevance_prompt", diff --git a/apps/api/src/rest/prompts/commenting.py b/apps/api/src/rest/prompts/commenting.py index 4bea145f..0e76dd8f 100644 --- a/apps/api/src/rest/prompts/commenting.py +++ b/apps/api/src/rest/prompts/commenting.py @@ -1,16 +1,21 @@ -from gptrim import trim import os +from gptrim import trim + # Get the directory of the current script file script_dir = os.path.dirname(os.path.realpath(__file__)) -with open(os.path.join(script_dir, "startino_business_plan.md"), "r", encoding='utf-8') as file: +with open( + os.path.join(script_dir, "startino_business_plan.md"), "r", encoding="utf-8" +) as file: company_context = file.read() -with open(os.path.join(script_dir, "good_comment_examples.md"), "r", encoding='utf-8') as file: +with open( + os.path.join(script_dir, "good_comment_examples.md"), "r", encoding="utf-8" +) as file: good_examples = file.read() -with open(os.path.join(script_dir, "bad_examples.md"), "r", encoding='utf-8') as file: +with open(os.path.join(script_dir, "bad_examples.md"), "r", encoding="utf-8") as file: bad_examples = file.read() purpose = """ @@ -53,7 +58,8 @@ writing comments that fulfill the purpose. """ -generate_comment_prompt = trim(f""" +generate_comment_prompt = trim( + f""" # INSTRUCTIONS {roleplay} # PURPOSE @@ -64,5 +70,5 @@ {context} # EXAMPLES {examples} -""") - +""" +) diff --git a/apps/api/src/rest/prompts/relevance.py b/apps/api/src/rest/prompts/relevance.py index f7ebafdc..c8152baf 100644 --- a/apps/api/src/rest/prompts/relevance.py +++ b/apps/api/src/rest/prompts/relevance.py @@ -1,16 +1,19 @@ -from gptrim import trim import os +from gptrim import trim + # Get the directory of the current script file script_dir = os.path.dirname(os.path.realpath(__file__)) -with open(os.path.join(script_dir, "startino_business_plan.md"), "r", encoding='utf-8') as file: +with open( + os.path.join(script_dir, "startino_business_plan.md"), "r", encoding="utf-8" +) as file: company_context = file.read() -with open(os.path.join(script_dir, "good_examples.md"), "r", encoding='utf-8') as file: +with open(os.path.join(script_dir, "good_examples.md"), "r", encoding="utf-8") as file: good_examples = file.read() -with open(os.path.join(script_dir, "bad_examples.md"), "r", encoding='utf-8') as file: +with open(os.path.join(script_dir, "bad_examples.md"), "r", encoding="utf-8") as file: bad_examples = file.read() purpose = """ @@ -70,7 +73,8 @@ relevant to look into for your boss. """ -calculate_relevance_prompt = trim(f""" +calculate_relevance_prompt = trim( + f""" # INSTRUCTIONS {roleplay} # PURPOSE @@ -85,5 +89,5 @@ {examples} -""") - +""" +) diff --git a/apps/api/src/rest/reddit_utils.py b/apps/api/src/rest/reddit_utils.py index 5190e1dc..f3d6182a 100644 --- a/apps/api/src/rest/reddit_utils.py +++ b/apps/api/src/rest/reddit_utils.py @@ -1,7 +1,8 @@ +import os + +from dotenv import load_dotenv from praw import Reddit from praw.models import Subreddits -from dotenv import load_dotenv -import os load_dotenv() diff --git a/apps/api/src/rest/reddit_worker.py b/apps/api/src/rest/reddit_worker.py index 20282fc4..15b74678 100644 --- a/apps/api/src/rest/reddit_worker.py +++ b/apps/api/src/rest/reddit_worker.py @@ -1,15 +1,15 @@ -from praw import Reddit -from praw.models import Subreddits, Submission import os -from dotenv import load_dotenv +from pathlib import Path from urllib.parse import quote_plus -import diskcache as dc -from pathlib import Path +import diskcache as dc +from dotenv import load_dotenv +from praw import Reddit +from praw.models import Submission, Subreddits +from .reddit_utils import get_reddit_instance, get_subreddits from .relevance_bot import evaluate_relevance from .saving import update_db_with_submission -from .reddit_utils import get_subreddits, get_reddit_instance load_dotenv() REDDIT_CLIENT_ID = os.getenv("REDDIT_CLIENT_ID") diff --git a/apps/api/src/rest/relevance_bot.py b/apps/api/src/rest/relevance_bot.py index 80f378f3..c8367ad9 100644 --- a/apps/api/src/rest/relevance_bot.py +++ b/apps/api/src/rest/relevance_bot.py @@ -1,21 +1,27 @@ +import os import time from typing import List -import os -from dotenv import load_dotenv +from dotenv import load_dotenv from gptrim import trim -from praw.models import Submission -from langchain_openai import ChatOpenAI -from langchain_core.prompts import PromptTemplate -from langchain_core.output_parsers import JsonOutputParser, PydanticOutputParser from langchain_community.callbacks import get_openai_callback +from langchain_core.output_parsers import JsonOutputParser, PydanticOutputParser +from langchain_core.prompts import PromptTemplate +from langchain_openai import ChatOpenAI +from praw.models import Submission -from .models import EvaluatedSubmission, RelevanceResult, FilterOutput, FilterQuestion -from .prompts import calculate_relevance_prompt, context as company_context, purpose -from .dummy_submissions import relevant_submissions, irrelevant_submissions -from .utils import majority_vote, calculate_certainty_from_bools +from .dummy_submissions import irrelevant_submissions, relevant_submissions from .logging_utils import log_relevance_calculation - +from .models import ( + EvaluatedSubmission, + FilterOutput, + FilterQuestion, + RelevanceResult, +) +from .prompts import calculate_relevance_prompt +from .prompts import context as company_context +from .prompts import purpose +from .utils import calculate_certainty_from_bools, majority_vote # Load Enviornment variables load_dotenv() @@ -77,8 +83,8 @@ def invoke_chain(chain, submission: Submission) -> tuple[RelevanceResult, float] time.sleep(10) # Wait for 10 seconds before trying again raise RuntimeError( - "Failed to invoke chain after 3 attempts. Most likely no more credits left or usage limit has been reached." -) + "Failed to invoke chain after 3 attempts. Most likely no more credits left or usage limit has been reached." + ) def summarize_submission(submission: Submission) -> Submission: diff --git a/apps/api/src/rest/saving.py b/apps/api/src/rest/saving.py index 066c2360..4e7f5125 100644 --- a/apps/api/src/rest/saving.py +++ b/apps/api/src/rest/saving.py @@ -1,9 +1,8 @@ import os from . import comment_bot -from .models import Lead from .interfaces import db -from .models import EvaluatedSubmission, SavedSubmission +from .models import EvaluatedSubmission, Lead, SavedSubmission # Get the current file's directory current_dir = os.path.dirname(os.path.realpath(__file__)) diff --git a/apps/api/src/rest/utils.py b/apps/api/src/rest/utils.py index 833d776a..6a758d41 100644 --- a/apps/api/src/rest/utils.py +++ b/apps/api/src/rest/utils.py @@ -1,16 +1,15 @@ - - from typing import List def majority_vote(bool_list: List[bool]) -> bool: return sum(bool_list) > len(bool_list) / 2 + def calculate_certainty_from_bools(bool_list: List[bool]) -> float: length = len(bool_list) total = sum(bool_list) - + true_certainty = total / length - false_certainty = (length-total) / length + false_certainty = (length - total) / length - return max(true_certainty, false_certainty) \ No newline at end of file + return max(true_certainty, false_certainty) diff --git a/apps/api/src/routers/agents.py b/apps/api/src/routers/agents.py index 4401c44d..e1ccdc68 100644 --- a/apps/api/src/routers/agents.py +++ b/apps/api/src/routers/agents.py @@ -5,10 +5,10 @@ from src.interfaces import db from src.models import ( - AgentInsertRequest, - AgentUpdateModel, Agent, AgentGetRequest, + AgentInsertRequest, + AgentUpdateModel, ) router = APIRouter( @@ -24,7 +24,7 @@ def get_agents(q: AgentGetRequest = Depends()) -> list[Agent]: response = db.get_agents(q.profile_id, q.crew_id, q.published) if not response: raise HTTPException(404, "crew not found or crew has no agents") - + return response @@ -46,9 +46,7 @@ def insert_agent(agent_request: AgentInsertRequest) -> Agent: @router.patch("/{agent_id}") -def patch_agent( - agent_id: UUID, agent_update_request: AgentUpdateModel -) -> Agent: +def patch_agent(agent_id: UUID, agent_update_request: AgentUpdateModel) -> Agent: if not db.get_agent(agent_id): raise HTTPException(404, "agent not found") diff --git a/apps/api/src/routers/api_key_types.py b/apps/api/src/routers/api_key_types.py index 6b31ecb4..a7017e52 100644 --- a/apps/api/src/routers/api_key_types.py +++ b/apps/api/src/routers/api_key_types.py @@ -4,9 +4,7 @@ from fastapi import APIRouter, HTTPException from src.interfaces import db -from src.models import ( - APIKeyType, -) +from src.models import APIKeyType router = APIRouter(prefix="/api-key-types", tags=["api key types"]) @@ -15,4 +13,4 @@ @router.get("/") def get_all_api_key_types() -> list[APIKeyType]: - return db.get_api_key_types() \ No newline at end of file + return db.get_api_key_types() diff --git a/apps/api/src/routers/api_keys.py b/apps/api/src/routers/api_keys.py index cb7babde..8c32f4e5 100644 --- a/apps/api/src/routers/api_keys.py +++ b/apps/api/src/routers/api_keys.py @@ -6,9 +6,9 @@ from src.interfaces import db from src.models import ( APIKey, + APIKeyGetRequest, APIKeyInsertRequest, APIKeyUpdateRequest, - APIKeyGetRequest, ) router = APIRouter(prefix="/api-keys", tags=["api keys"]) @@ -19,7 +19,7 @@ def get_api_keys(q: APIKeyGetRequest = Depends()) -> list[APIKey]: """Returns api keys with the api key type as an object with the id, name, description etc.""" if q.profile_id and not db.get_profile(q.profile_id): raise HTTPException(404, "profile not found") - + return db.get_api_keys(q.profile_id, q.api_key_type_id, q.api_key) @@ -40,6 +40,7 @@ def insert_api_key(api_key_request: APIKeyInsertRequest) -> APIKey: return response + @router.delete("/{api_key_id}") def delete_api_key(api_key_id: UUID) -> APIKey: deleted_key = db.delete_api_key(api_key_id) @@ -51,4 +52,4 @@ def delete_api_key(api_key_id: UUID) -> APIKey: @router.patch("/{api_key_id}") def update_api_key(api_key_id: UUID, api_key_update: APIKeyUpdateRequest) -> APIKey: - return db.update_api_key(api_key_id, api_key_update) \ No newline at end of file + return db.update_api_key(api_key_id, api_key_update) diff --git a/apps/api/src/routers/billing_information.py b/apps/api/src/routers/billing_information.py index d4e567ee..44f22b67 100644 --- a/apps/api/src/routers/billing_information.py +++ b/apps/api/src/routers/billing_information.py @@ -10,11 +10,7 @@ rate_limit_tiered, ) from src.interfaces import db -from src.models import ( - Billing, - BillingInsertRequest, - BillingUpdateRequest, -) +from src.models import Billing, BillingInsertRequest, BillingUpdateRequest router = APIRouter(prefix="/billing", tags=["billings"]) @@ -51,4 +47,3 @@ def update_billing(profile_id: UUID, content: BillingUpdateRequest) -> Billing: raise HTTPException(404, "message not found") return response - diff --git a/apps/api/src/routers/crews.py b/apps/api/src/routers/crews.py index 3a18cc29..a837b48a 100644 --- a/apps/api/src/routers/crews.py +++ b/apps/api/src/routers/crews.py @@ -4,7 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException from src.interfaces import db -from src.models import CrewInsertRequest, Crew, CrewUpdateRequest, CrewGetRequest +from src.models import ( + Crew, + CrewGetRequest, + CrewInsertRequest, + CrewUpdateRequest, +) router = APIRouter( prefix="/crews", diff --git a/apps/api/src/routers/messages.py b/apps/api/src/routers/messages.py index 2c2ab76e..62fa88ae 100644 --- a/apps/api/src/routers/messages.py +++ b/apps/api/src/routers/messages.py @@ -2,6 +2,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException +from postgrest.exceptions import APIError from src.dependencies import ( RateLimitResponse, @@ -10,8 +11,12 @@ rate_limit_tiered, ) from src.interfaces import db -from src.models import Message, MessageInsertRequest, Message, MessageUpdateRequest, MessageGetRequest -from postgrest.exceptions import APIError +from src.models import ( + Message, + MessageGetRequest, + MessageInsertRequest, + MessageUpdateRequest, +) router = APIRouter(prefix="/messages", tags=["messages"]) @@ -33,7 +38,7 @@ def delete_message(message_id: UUID) -> Message: response = db.delete_message(message_id) if not response: raise HTTPException(404, "message not found") - + return response @@ -51,5 +56,5 @@ def get_message(message_id: UUID) -> Message: response = db.get_message(message_id) if not response: raise HTTPException(404, "message not found") - - return response \ No newline at end of file + + return response diff --git a/apps/api/src/routers/profiles.py b/apps/api/src/routers/profiles.py index e59dcced..e2540dc3 100644 --- a/apps/api/src/routers/profiles.py +++ b/apps/api/src/routers/profiles.py @@ -6,9 +6,9 @@ from src.interfaces import db from src.models import ( Profile, - ProfileUpdateRequest, + ProfileGetRequest, ProfileInsertRequest, - ProfileGetRequest + ProfileUpdateRequest, ) router = APIRouter(prefix="/profiles", tags=["profiles"]) @@ -31,7 +31,7 @@ def get_profile_by_id(profile_id: UUID) -> Profile: raise HTTPException(404, "profile not found") return profile - + @router.delete("/{profile_id}") def delete_profile(profile_id: UUID) -> Profile: @@ -46,4 +46,3 @@ def update_profile( raise HTTPException(404, "profile not found") return db.update_profile(profile_id, profile_update_request) - diff --git a/apps/api/src/routers/rest.py b/apps/api/src/routers/rest.py index 2e2dd201..fa928c59 100644 --- a/apps/api/src/routers/rest.py +++ b/apps/api/src/routers/rest.py @@ -1,26 +1,24 @@ -import os -from dotenv import load_dotenv import logging -import diskcache as dc +import os import threading from uuid import uuid4 -from src.rest.saving import update_db_with_submission -from src.rest import mail -from src.rest.reddit_utils import get_subreddits -from src.rest.relevance_bot import evaluate_relevance +import diskcache as dc +from dotenv import load_dotenv +from fastapi import APIRouter, HTTPException +from fastapi.responses import RedirectResponse + +from src.rest import comment_bot, mail from src.rest.interfaces import db -from src.rest import comment_bot from src.rest.models import ( - PublishCommentRequest, - GenerateCommentRequest, FalseLead, + GenerateCommentRequest, + PublishCommentRequest, ) +from src.rest.reddit_utils import get_subreddits from src.rest.reddit_worker import RedditStreamWorker - -from fastapi import APIRouter, HTTPException -from fastapi.responses import RedirectResponse - +from src.rest.relevance_bot import evaluate_relevance +from src.rest.saving import update_db_with_submission # Relevant subreddits to Startino SUBREDDIT_NAMES = ( diff --git a/apps/api/src/routers/sessions.py b/apps/api/src/routers/sessions.py index bb161b88..b928e69b 100644 --- a/apps/api/src/routers/sessions.py +++ b/apps/api/src/routers/sessions.py @@ -1,5 +1,5 @@ -from datetime import UTC, datetime import logging +from datetime import UTC, datetime from typing import cast from uuid import UUID, uuid4 @@ -15,19 +15,18 @@ ) from src.interfaces import db from src.models import ( - CrewProcessed, Crew, + CrewProcessed, Message, - SessionRunRequest, - SessionRunResponse, Session, - Session, - SessionUpdateRequest, SessionGetRequest, + SessionRunRequest, + SessionRunResponse, SessionStatus, + SessionUpdateRequest, ) from src.models.session import SessionInsertRequest -from src.parser import process_crew, get_processed_crew_by_id +from src.parser import get_processed_crew_by_id, process_crew router = APIRouter( prefix="/sessions", @@ -38,9 +37,7 @@ @router.get("/") -def get_sessions( - q: SessionGetRequest = Depends() -) -> list[Session]: +def get_sessions(q: SessionGetRequest = Depends()) -> list[Session]: return db.get_sessions(q.profile_id, q.crew_id, q.title, q.status) @@ -49,13 +46,13 @@ def get_session(session_id: UUID) -> Session: response = db.get_session(session_id) if response is None: raise HTTPException(500, "failed validation") - # not sure if 500 is correct, but this is failed validation on the returned data, so + # not sure if 500 is correct, but this is failed validation on the returned data, so # it makes sense in my mind to raise a server error for that - + return response # pretty sure this response object will always be a session, so casting it to stop typing errors - - + + @router.patch("/{session_id}") def update_session(session_id: UUID, content: SessionUpdateRequest) -> Session: return db.update_session(session_id, content) @@ -131,7 +128,7 @@ async def run_crew( title=request.session_title, reply="", last_opened_at=datetime.now(tz=UTC), - status=SessionStatus.RUNNING + status=SessionStatus.RUNNING, ) db.post_session(session) @@ -149,7 +146,7 @@ async def on_reply( sender_id=sender_id, content=content, role=role, - created_at=datetime.now(tz=UTC) + created_at=datetime.now(tz=UTC), ) logger.debug(f"on_reply: {message}") db.post_message(message) diff --git a/apps/api/src/routers/subscriptions.py b/apps/api/src/routers/subscriptions.py index 1f13b926..d8e73b08 100644 --- a/apps/api/src/routers/subscriptions.py +++ b/apps/api/src/routers/subscriptions.py @@ -12,9 +12,9 @@ from src.interfaces import db from src.models import ( Subscription, + SubscriptionGetRequest, SubscriptionInsertRequest, SubscriptionUpdateRequest, - SubscriptionGetRequest, ) router = APIRouter(prefix="/subscriptions", tags=["subscriptions"]) @@ -50,4 +50,3 @@ def update_subscription( raise HTTPException(404, "message not found") return response - diff --git a/apps/api/src/routers/tiers.py b/apps/api/src/routers/tiers.py index 3eb0ff83..a6de01ac 100644 --- a/apps/api/src/routers/tiers.py +++ b/apps/api/src/routers/tiers.py @@ -12,9 +12,9 @@ from src.interfaces import db from src.models import ( Tier, + TierGetRequest, TierInsertRequest, TierUpdateRequest, - TierGetRequest, ) router = APIRouter(prefix="/tiers", tags=["tiers"]) diff --git a/apps/api/src/routers/tools.py b/apps/api/src/routers/tools.py index a85c6eb8..eb430fd4 100644 --- a/apps/api/src/routers/tools.py +++ b/apps/api/src/routers/tools.py @@ -17,6 +17,7 @@ tags=["tools"], ) + @router.get("/") def get_tools(q: ToolGetRequest = Depends()) -> list[Tool]: return db.get_tools(q.name, q.api_key_type_id) @@ -27,7 +28,7 @@ def get_tool(tool_id: UUID) -> Tool: response = db.get_tool(tool_id) if not response: raise HTTPException(404, "tool not found") - + return response @@ -41,14 +42,12 @@ def delete_tool(tool_id: UUID) -> Tool: response = db.delete_tool(tool_id) if not response: raise HTTPException(404, "could not find tool") - + return response @router.patch("/{tool_id}") -def update_profile( - tool_id: UUID, tool_update_request: ToolUpdateRequest -) -> Tool: +def update_profile(tool_id: UUID, tool_update_request: ToolUpdateRequest) -> Tool: if not db.get_tool(tool_id): raise HTTPException(404, "tool not found") diff --git a/apps/api/src/tools/__init__.py b/apps/api/src/tools/__init__.py index 2d792261..3762d235 100644 --- a/apps/api/src/tools/__init__.py +++ b/apps/api/src/tools/__init__.py @@ -3,8 +3,8 @@ import os import random from typing import Any -from dotenv import load_dotenv +from dotenv import load_dotenv from langchain_core.tools import BaseTool from src.tools.alpha_vantage import ID as ALPHA_VANTAGE_TOOL_ID @@ -13,24 +13,23 @@ from src.tools.arxiv_tool import ArxivTool from src.tools.bing import ID as BING_SEARCH_TOOL_ID from src.tools.bing import BingTool +from src.tools.brave_search import ID as BRAVE_TOOL_ID +from src.tools.brave_search import BraveSearchTool +from src.tools.duckduckgo_tool import ID as DDGS_TOOL_ID +from src.tools.duckduckgo_tool import DuckDuckGoSearchTool +from src.tools.google_serper import RESULTS_ID as GOOGLE_SERPER_RESULTS_TOOL_ID +from src.tools.google_serper import RUN_ID as GOOGLE_SERPER_RUN_TOOL_ID +from src.tools.google_serper import GoogleSerperResultsTool, GoogleSerperRunTool from src.tools.move_file import ID as MOVE_TOOL_ID from src.tools.move_file import MoveFileTool from src.tools.read_file import ID as READ_TOOL_ID from src.tools.read_file import ReadFileTool from src.tools.scraper import ID as SCRAPER_TOOL_ID from src.tools.scraper import ScraperTool -from src.tools.wikipedia_tool import ID as WIKIPEDIA_TOOL_ID -from src.tools.wikipedia_tool import WikipediaTool -from src.tools.duckduckgo_tool import ID as DDGS_TOOL_ID -from src.tools.duckduckgo_tool import DuckDuckGoSearchTool -from src.tools.google_serper import RUN_ID as GOOGLE_SERPER_RUN_TOOL_ID -from src.tools.google_serper import GoogleSerperRunTool -from src.tools.google_serper import RESULTS_ID as GOOGLE_SERPER_RESULTS_TOOL_ID -from src.tools.google_serper import GoogleSerperResultsTool -from src.tools.brave_search import ID as BRAVE_TOOL_ID -from src.tools.brave_search import BraveSearchTool from src.tools.stackapi_tool import ID as STACKAPI_ID from src.tools.stackapi_tool import StackAPISearchTool +from src.tools.wikipedia_tool import ID as WIKIPEDIA_TOOL_ID +from src.tools.wikipedia_tool import WikipediaTool tools: dict = { ARXIV_TOOL_ID: ArxivTool, @@ -51,7 +50,7 @@ load_dotenv() -def get_file_path_of_example(): +def get_file_path_of_example() -> str: current_dir = os.getcwd() target_folder = os.path.join(current_dir, "src/tools/test_files") @@ -83,14 +82,14 @@ def get_tool_ids_from_agent(tools: list[dict[str, Any]]) -> list[str]: return [tool["id"] for tool in tools] -def has_param(cls, param_name): +def has_param(cls, param_name) -> bool: init_signature = inspect.signature(cls.__init__) return param_name in init_signature.parameters def generate_tool_from_uuid( tool: str, api_key_types: dict[str, str], api_keys: dict[str, str] -): +) -> BaseTool | None: for tool_id in tools: if tool_id == tool: tool_key_type = "" diff --git a/apps/api/src/tools/alpha_vantage.py b/apps/api/src/tools/alpha_vantage.py index 137cd34d..69a7a061 100644 --- a/apps/api/src/tools/alpha_vantage.py +++ b/apps/api/src/tools/alpha_vantage.py @@ -21,7 +21,7 @@ class AlphaVantageToolInput(BaseModel): class AlphaVantageTool(Tool, BaseTool): args_schema: Type[BaseModel] = AlphaVantageToolInput - def __init__(self, api_key): + def __init__(self, api_key: str) -> None: alpha_vantage = AlphaVantageAPIWrapper(alphavantage_api_key=api_key) super().__init__( name="alpha_vantage_tool", diff --git a/apps/api/src/tools/arxiv_tool.py b/apps/api/src/tools/arxiv_tool.py index 1caf2ff3..54bae5c1 100644 --- a/apps/api/src/tools/arxiv_tool.py +++ b/apps/api/src/tools/arxiv_tool.py @@ -18,7 +18,7 @@ class ArxivToolInput(BaseModel): class ArxivTool(Tool, BaseTool): args_schema: Type[BaseModel] = ArxivToolInput - def __init__(self): + def __init__(self) -> None: super().__init__( name="arxiv_tool", func=arxiv.run, @@ -27,6 +27,3 @@ def __init__(self): __all__ = ["ArxivTool"] - - - diff --git a/apps/api/src/tools/bing.py b/apps/api/src/tools/bing.py index a6a80912..97ffe3ae 100644 --- a/apps/api/src/tools/bing.py +++ b/apps/api/src/tools/bing.py @@ -2,25 +2,31 @@ from typing import Type from dotenv import load_dotenv -from langchain_community.tools import BingSearchRun -from langchain_community.utilities import BingSearchAPIWrapper from langchain.agents import Tool from langchain.pydantic_v1 import BaseModel, Field from langchain.tools import BaseTool +from langchain_community.tools import BingSearchRun from langchain_community.tools.bing_search.tool import BingSearchRun +from langchain_community.utilities import BingSearchAPIWrapper from langchain_community.utilities.bing_search import BingSearchAPIWrapper # TODO: Split this tool into 2 different tools, like I did with the Google Serper tool, so a BingSearchRun and a BingSearchResults -BING_SEARCH_URL="https://api.bing.microsoft.com/v7.0/search" +BING_SEARCH_URL = "https://api.bing.microsoft.com/v7.0/search" ID = "71e4ddcc-4475-46f2-9816-894173b1292e" class BingToolInput(BaseModel): - tool_input: str = Field(title="Query", description="Search query input to search bing") + tool_input: str = Field( + title="Query", description="Search query input to search bing" + ) - nr_of_results: int = Field(title="Number of results", description="The amount of returned results from the search", default=5) + nr_of_results: int = Field( + title="Number of results", + description="The amount of returned results from the search", + default=5, + ) class BingTool(Tool, BaseTool): @@ -28,7 +34,7 @@ class BingTool(Tool, BaseTool): api_key: str = "" # needs to be empty string or it throws validation errors - def __init__(self, api_key): + def __init__(self, api_key: str) -> None: super().__init__( name="bing_search", func=self._run, @@ -37,8 +43,8 @@ def __init__(self, api_key): Input should be a search query.""", ) self.api_key = api_key - - def _run(self, tool_input: str, nr_of_results: int = 5): + + def _run(self, tool_input: str, nr_of_results: int = 5) -> str: wrapper = BingSearchAPIWrapper( bing_subscription_key=self.api_key, bing_search_url=BING_SEARCH_URL, diff --git a/apps/api/src/tools/brave_search.py b/apps/api/src/tools/brave_search.py index 7cbad0dd..0bca14b0 100644 --- a/apps/api/src/tools/brave_search.py +++ b/apps/api/src/tools/brave_search.py @@ -20,7 +20,7 @@ class BraveSearchToolInput(BaseModel): class BraveSearchTool(Tool, BaseTool): args_schema: Type[BaseModel] = BraveSearchToolInput - def __init__(self, api_key): + def __init__(self, api_key: str) -> None: tool = BraveSearch.from_api_key(api_key=api_key, search_kwargs={"count": 3}) super().__init__( name="brave_search", diff --git a/apps/api/src/tools/duckduckgo_tool.py b/apps/api/src/tools/duckduckgo_tool.py index 232bf0cf..dc794abe 100644 --- a/apps/api/src/tools/duckduckgo_tool.py +++ b/apps/api/src/tools/duckduckgo_tool.py @@ -4,13 +4,16 @@ from langchain.agents import Tool from langchain.pydantic_v1 import BaseModel, Field from langchain.tools import BaseTool -from langchain_community.utilities.duckduckgo_search import DuckDuckGoSearchAPIWrapper from langchain_community.tools import DuckDuckGoSearchRun +from langchain_community.utilities.duckduckgo_search import ( + DuckDuckGoSearchAPIWrapper, +) -ID="7dc53d81-cdac-4320-8077-1a7ab9497551" +ID = "7dc53d81-cdac-4320-8077-1a7ab9497551" logger = logging.getLogger("root") + class DuckDuckGoSearchToolInput(BaseModel): tool_input: str = Field( title="query", description="Search query input to look up on duck duck go" @@ -19,25 +22,29 @@ class DuckDuckGoSearchToolInput(BaseModel): title="region", description="Region to use for the search", default="wt-wt" ) source: str = Field( - title="source", description="Source of information, ex 'text' or 'news'", default="text" + title="source", + description="Source of information, ex 'text' or 'news'", + default="text", ) class DuckDuckGoSearchTool(Tool, BaseTool): args_schema: Type[BaseModel] = DuckDuckGoSearchToolInput - def __init__(self): + def __init__(self) -> None: super().__init__( name="duck_duck_go_search", func=self._run, description="""search the internet through the search engine duck duck go""", ) - - def _run(self, tool_input: str, region: str = "wt-wt", source: str = "text") -> Callable: + + def _run( + self, tool_input: str, region: str = "wt-wt", source: str = "text" + ) -> str: """Method passed to agent so the agent can initialize the wrapper with additional args""" logger.debug("Creating DuckDuckGo wrapper") ddgs_tool = DuckDuckGoSearchRun( wrapper=DuckDuckGoSearchAPIWrapper(region=region, source=source) ) - return ddgs_tool.run(tool_input=tool_input) \ No newline at end of file + return ddgs_tool.run(tool_input=tool_input) diff --git a/apps/api/src/tools/google_serper.py b/apps/api/src/tools/google_serper.py index 37980c71..3319aec4 100644 --- a/apps/api/src/tools/google_serper.py +++ b/apps/api/src/tools/google_serper.py @@ -6,19 +6,30 @@ from langchain.tools import BaseTool from langchain_community.utilities.google_serper import GoogleSerperAPIWrapper -RUN_ID="3e2665a8-6d73-42ee-a64f-50ddcc0621c6" +RUN_ID = "3e2665a8-6d73-42ee-a64f-50ddcc0621c6" -RESULTS_ID="1046fefb-a540-498f-8b96-7292523559e0" +RESULTS_ID = "1046fefb-a540-498f-8b96-7292523559e0" logger = logging.getLogger("root") + class GoogleSerperRunToolInput(BaseModel): - query: str = Field(title="query", description="search query input, looks up on google search") + query: str = Field( + title="query", description="search query input, looks up on google search" + ) + class GoogleSerperResultsToolInput(BaseModel): - query: str = Field(title="query", description="search query input, looks up on google search and returns metadata") + query: str = Field( + title="query", + description="search query input, looks up on google search and returns metadata", + ) - nr_of_results: int = Field(title="number of results", description="number of results shown per page", default=10) + nr_of_results: int = Field( + title="number of results", + description="number of results shown per page", + default=10, + ) region: str = Field( title="region", @@ -27,7 +38,7 @@ class GoogleSerperResultsToolInput(BaseModel): ) language: str = Field( title="language", - description="sets the interface language of the search, given as a two letter code, for example English is 'en' and french is 'fr'", + description="sets the interface language of the search, given as a two letter code, for example English is 'en' and french is 'fr'", default="en", ) search_type: Literal["news", "search", "places", "images"] = Field( @@ -44,8 +55,8 @@ class GoogleSerperResultsToolInput(BaseModel): class GoogleSerperRunTool(Tool, BaseTool): args_schema: Type[BaseModel] = GoogleSerperRunToolInput - - def __init__(self, api_key): + + def __init__(self, api_key: str) -> None: search = GoogleSerperAPIWrapper(serper_api_key=api_key) super().__init__( name="google_serper_run_tool", @@ -53,12 +64,12 @@ def __init__(self, api_key): description="""search the web with serper's google search api""", ) - + class GoogleSerperResultsTool(Tool, BaseTool): args_schema: Type[BaseModel] = GoogleSerperResultsToolInput api_key: str = "" - def __init__(self, api_key): + def __init__(self, api_key: str) -> None: super().__init__( name="google_serper_results_tool", func=self._run, @@ -69,22 +80,21 @@ def __init__(self, api_key): def _run( self, query: str, - nr_of_results: int = 10, - region: str = "us", + nr_of_results: int = 10, + region: str = "us", language: str = "en", - search_type: Literal["news", "search", "places", "images"] = "search", + search_type: Literal["news", "search", "places", "images"] = "search", time_based_search: str | None = None, - ): + ) -> dict: """Method passed to the agent to allow it to pass additional optional parameters, similar to the DDG search tool""" search = GoogleSerperAPIWrapper( serper_api_key=self.api_key, - k=nr_of_results, - gl=region, - hl=language, - type=search_type, - tbs=time_based_search + k=nr_of_results, + gl=region, + hl=language, + type=search_type, + tbs=time_based_search, ) - return search.results(query) - \ No newline at end of file + return search.results(query) diff --git a/apps/api/src/tools/scraper.py b/apps/api/src/tools/scraper.py index 454b7c23..b78991fa 100644 --- a/apps/api/src/tools/scraper.py +++ b/apps/api/src/tools/scraper.py @@ -22,7 +22,7 @@ class ScraperToolInput(BaseModel): class ScraperTool(Tool, BaseTool): args_schema: Type[BaseModel] = ScraperToolInput - def __init__(self, api_key): + def __init__(self, api_key: str) -> None: web_scrape = SerpAPIWrapper(serpapi_api_key=api_key) super().__init__( name="scraper_tool", diff --git a/apps/api/src/tools/stackapi_tool.py b/apps/api/src/tools/stackapi_tool.py index 858a70c7..c5249281 100644 --- a/apps/api/src/tools/stackapi_tool.py +++ b/apps/api/src/tools/stackapi_tool.py @@ -4,8 +4,8 @@ from langchain.agents import Tool from langchain.pydantic_v1 import BaseModel, Field from langchain.tools import BaseTool -from langchain_community.utilities import StackExchangeAPIWrapper from langchain_community.tools.stackexchange.tool import StackExchangeTool +from langchain_community.utilities import StackExchangeAPIWrapper ID = "612ddae6-ecdd-4900-9314-1a2c9de6003d" @@ -21,7 +21,7 @@ class StackAPIToolInput(BaseModel): class StackAPISearchTool(Tool, BaseTool): args_schema: Type[BaseModel] = StackAPIToolInput - def __init__(self): + def __init__(self) -> None: tool = StackExchangeTool(api_wrapper=StackExchangeAPIWrapper()) super().__init__( name="stack_api_tool", diff --git a/apps/api/src/tools/wikipedia_tool.py b/apps/api/src/tools/wikipedia_tool.py index 2b402b10..a88e1014 100644 --- a/apps/api/src/tools/wikipedia_tool.py +++ b/apps/api/src/tools/wikipedia_tool.py @@ -18,7 +18,7 @@ class WikipediaToolInput(BaseModel): class WikipediaTool(Tool, BaseTool): args_schema: Type[BaseModel] = WikipediaToolInput - def __init__(self): + def __init__(self) -> None: wiki_tool = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper()) super().__init__( name="wikipedia",