feat: allow users to chat with apis (#1612)

You can now create a brain which can fetch data from external APIs with
or without authentification

- POST query example with authentification 


https://github.com/StanGirard/quivr/assets/63923024/15013ba9-dedb-4f24-9e06-49daad9de7f3


- Get query example with authentification and search params



https://github.com/StanGirard/quivr/assets/63923024/1763875d-a8e9-4478-b07c-e99ca7337942


- Get query without authentification and search params



https://github.com/StanGirard/quivr/assets/63923024/f2742963-790d-4cb2-864a-8173979b650a
This commit is contained in:
Mamadou DICKO 2023-11-09 16:58:51 +01:00 committed by GitHub
parent addcd27fce
commit db5a6e4b9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 306 additions and 126 deletions

View File

@ -2,16 +2,12 @@ import json
from typing import Optional
from uuid import UUID
from langchain.schema import FunctionMessage
from fastapi import HTTPException
from litellm import completion
from models.chats import ChatQuestion
from models.databases.supabase.chats import CreateChatHistory
from repository.brain.get_brain_by_id import get_brain_by_id
from repository.chat.format_chat_history import (
format_chat_history,
format_history_to_openai_mesages,
)
from repository.chat.get_chat_history import get_chat_history
from repository.chat.get_chat_history import GetChatHistoryOutput, get_chat_history
from repository.chat.update_chat_history import update_chat_history
from repository.chat.update_message_by_id import update_message_by_id
@ -22,7 +18,9 @@ from llm.utils.get_api_brain_definition_as_json_schema import (
)
class APIBrainQA(QABaseBrainPicking):
class APIBrainQA(
QABaseBrainPicking,
):
user_id: UUID
def __init__(
@ -30,11 +28,14 @@ class APIBrainQA(QABaseBrainPicking):
model: str,
brain_id: str,
chat_id: str,
user_id: UUID,
streaming: bool = False,
prompt_id: Optional[UUID] = None,
**kwargs,
):
user_id = kwargs.get("user_id")
if not user_id:
raise HTTPException(status_code=400, detail="Cannot find user id")
super().__init__(
model=model,
brain_id=brain_id,
@ -45,48 +46,100 @@ class APIBrainQA(QABaseBrainPicking):
)
self.user_id = user_id
async def generate_stream(self, chat_id: UUID, question: ChatQuestion):
if not question.brain_id:
raise Exception("No brain id provided")
history = get_chat_history(self.chat_id)
prompt_content = self.prompt_to_use.content if self.prompt_to_use else ""
brain = get_brain_by_id(question.brain_id)
if not brain:
raise Exception("No brain found")
messages = format_history_to_openai_mesages(
format_chat_history(history),
prompt_content,
question.question,
)
async def make_completion(
self,
messages,
functions,
brain_id: UUID,
):
response = completion(
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
messages=messages,
functions=[get_api_brain_definition_as_json_schema(brain)],
functions=functions,
stream=True,
function_call="auto",
)
if response.choices[0].finish_reason == "function_call":
arguments = json.load(
response.choices[0].message["function_call"]["arguments"]
function_call = {
"name": None,
"arguments": "",
}
for chunk in response:
finish_reason = chunk.choices[0].finish_reason
if finish_reason == "stop":
break
if "function_call" in chunk.choices[0].delta:
if "name" in chunk.choices[0].delta["function_call"]:
function_call["name"] = chunk.choices[0].delta["function_call"][
"name"
]
if "arguments" in chunk.choices[0].delta["function_call"]:
function_call["arguments"] += chunk.choices[0].delta[
"function_call"
]["arguments"]
elif finish_reason == "function_call":
try:
arguments = json.loads(function_call["arguments"])
except Exception:
arguments = {}
api_call_response = call_brain_api(
brain_id=brain_id,
user_id=self.user_id,
arguments=arguments,
)
messages.append(
{
"role": "function",
"name": function_call["name"],
"content": api_call_response,
}
)
async for value in self.make_completion(
messages=messages,
functions=functions,
brain_id=brain_id,
):
yield value
else:
content = chunk.choices[0].delta.content
yield content
async def generate_stream(self, chat_id: UUID, question: ChatQuestion):
if not question.brain_id:
raise HTTPException(
status_code=400, detail="No brain id provided in the question"
)
content = call_brain_api(
brain_id=question.brain_id, user_id=self.user_id, arguments=arguments
)
messages.append(FunctionMessage(name=brain.name, content=content))
brain = get_brain_by_id(question.brain_id)
response = completion(
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
messages=messages,
stream=True,
)
if not brain:
raise HTTPException(status_code=404, detail="Brain not found")
prompt_content = "You'are a helpful assistant which can call APIs. Feel free to call the API when you need to. Don't force APIs call, do it when necessary. If it seems like you should call the API and there are missing parameters, ask user for them."
if self.prompt_to_use:
prompt_content += self.prompt_to_use.content
messages = [{"role": "system", "content": prompt_content}]
history = get_chat_history(self.chat_id)
for message in history:
formatted_message = [
{"role": "user", "content": message.user_message},
{"role": "assistant", "content": message.assistant},
]
messages.extend(formatted_message)
messages.append({"role": "user", "content": question.question})
streamed_chat_history = update_chat_history(
CreateChatHistory(
@ -99,7 +152,7 @@ class APIBrainQA(QABaseBrainPicking):
}
)
)
streamed_chat_history = get_chat_history.GetChatHistoryOutput(
streamed_chat_history = GetChatHistoryOutput(
**{
"chat_id": str(chat_id),
"message_id": streamed_chat_history.message_id,
@ -112,13 +165,14 @@ class APIBrainQA(QABaseBrainPicking):
"brain_name": brain.name if brain else None,
}
)
response_tokens = []
for chunk in response:
new_token = chunk["choices"][0]["delta"]
streamed_chat_history.assistant = new_token
response_tokens.append(new_token)
async for value in self.make_completion(
messages=messages,
functions=[get_api_brain_definition_as_json_schema(brain)],
brain_id=question.brain_id,
):
streamed_chat_history.assistant = value
response_tokens.append(value)
yield f"data: {json.dumps(streamed_chat_history.dict())}"
update_message_by_id(

View File

@ -1,28 +1,30 @@
from uuid import UUID
from llm.utils.extract_brain_api_params_values_from_llm_output import (
extract_brain_api_params_values_from_llm_output,
from fastapi import HTTPException
from llm.utils.extract_api_brain_definition_values_from_llm_output import (
extract_api_brain_definition_values_from_llm_output,
)
from llm.utils.make_api_request import make_api_request
from llm.utils.make_api_request import get_api_call_response_as_text
from repository.api_brain_definition.get_api_brain_definition import (
get_api_brain_definition,
)
from repository.external_api_secret.read_secret import read_secret
def call_brain_api(brain_id: UUID, user_id: UUID, arguments: dict):
def call_brain_api(brain_id: UUID, user_id: UUID, arguments: dict) -> str:
brain_definition = get_api_brain_definition(brain_id)
if brain_definition is None:
raise Exception("Brain definition not found")
brain_params = brain_definition.params.properties
brain_params_values = extract_brain_api_params_values_from_llm_output(
brain_params, arguments
if brain_definition is None:
raise HTTPException(
status_code=404, detail=f"Brain definition {brain_id} not found"
)
brain_params_values = extract_api_brain_definition_values_from_llm_output(
brain_definition.params, arguments
)
brain_search_params = brain_definition.search_params.properties
brain_search_params_values = extract_brain_api_params_values_from_llm_output(
brain_search_params, arguments
brain_search_params_values = extract_api_brain_definition_values_from_llm_output(
brain_definition.search_params, arguments
)
secrets = brain_definition.secrets
@ -34,9 +36,10 @@ def call_brain_api(brain_id: UUID, user_id: UUID, arguments: dict):
)
secrets_values[secret.name] = secret_value
return make_api_request(
return get_api_call_response_as_text(
api_url=brain_definition.url,
params=brain_params_values,
search_params=brain_search_params_values,
secrets=secrets_values,
method=brain_definition.method,
)

View File

@ -0,0 +1,25 @@
from fastapi import HTTPException
from models.ApiBrainDefinition import ApiBrainDefinitionSchema
def extract_api_brain_definition_values_from_llm_output(
brain_schema: ApiBrainDefinitionSchema, arguments: dict
) -> dict:
params_values = {}
properties = brain_schema.properties
required_values = brain_schema.required
for property in properties:
if property.name in arguments:
if property.type == "number":
params_values[property.name] = float(arguments[property.name])
else:
params_values[property.name] = arguments[property.name]
continue
if property.name in required_values:
raise HTTPException(
status_code=400,
detail=f"Required parameter {property.name} not found in arguments",
)
return params_values

View File

@ -0,0 +1,11 @@
from models.ApiBrainDefinition import ApiBrainDefinitionSchemaProperty
def format_api_brain_property(property: ApiBrainDefinitionSchemaProperty):
property_data: dict = {
"type": property.type,
"description": property.description,
}
if property.enum:
property_data["enum"] = property.enum
return property_data

View File

@ -1,17 +0,0 @@
from models.ApiBrainDefinition import ApiBrainDefinitionSchemaProperty
def extract_brain_api_params_values_from_llm_output(
params: list[ApiBrainDefinitionSchemaProperty], arguments: dict
):
params_values = {}
for param in params:
if param.name in arguments:
params_values[param.name] = arguments[param.name]
continue
if param.required:
raise Exception(f"Missing param {param.name}")
return params_values

View File

@ -1,3 +1,8 @@
from fastapi import HTTPException
from llm.utils.extract_api_definition import (
format_api_brain_property,
)
from llm.utils.sanitize_function_name import sanitize_function_name
from models.brain_entity import BrainEntity
from repository.api_brain_definition.get_api_brain_definition import (
get_api_brain_definition,
@ -5,22 +10,24 @@ from repository.api_brain_definition.get_api_brain_definition import (
def get_api_brain_definition_as_json_schema(brain: BrainEntity):
if not brain:
raise Exception("No brain found")
api_brain_definition = get_api_brain_definition(brain.id)
if not api_brain_definition:
raise Exception("No api brain definition found")
raise HTTPException(
status_code=404, detail=f"Brain definition {brain.id} not found"
)
required = []
required.extend(api_brain_definition.params.required)
required.extend(api_brain_definition.search_params.required)
properties = {}
for property in api_brain_definition.params.properties:
properties[property.name] = property
for property in api_brain_definition.search_params.properties:
properties[property.name] = property
api_properties = (
api_brain_definition.params.properties
+ api_brain_definition.search_params.properties
)
for property in api_properties:
properties[property.name] = format_api_brain_property(property)
parameters = {
"type": "object",
@ -28,7 +35,7 @@ def get_api_brain_definition_as_json_schema(brain: BrainEntity):
"required": required,
}
schema = {
"name": brain.name,
"name": sanitize_function_name(brain.name),
"description": brain.description,
"parameters": parameters,
}

View File

@ -1,18 +1,36 @@
import json
import requests
from logger import get_logger
logger = get_logger(__name__)
def make_api_request(api_url, params, search_params, secrets) -> str:
def get_api_call_response_as_text(
method, api_url, params, search_params, secrets
) -> str:
headers = {}
api_url_with_search_params = api_url + "?"
for search_param in search_params:
api_url_with_search_params += f"{search_param}={search_params[search_param]}&"
api_url_with_search_params = api_url
if search_params:
api_url_with_search_params += "?"
for search_param in search_params:
api_url_with_search_params += (
f"{search_param}={search_params[search_param]}&"
)
for secret in secrets:
headers[secret] = secrets[secret]
response = requests.get(
url=api_url_with_search_params, params=params, headers=headers
)
return str(response.json())
try:
response = requests.request(
method,
url=api_url_with_search_params,
params=search_params or None,
headers=headers or None,
data=json.dumps(params) or None,
)
return response.text
except Exception as e:
logger.error(f"Error calling API: {e}")
return str(e)

View File

@ -0,0 +1,7 @@
import re
def sanitize_function_name(string):
sanitized_string = re.sub(r"[^a-zA-Z0-9_-]", "", string)
return sanitized_string

View File

@ -1,19 +1,26 @@
from enum import Enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, Extra
class ApiBrainDefinitionSchemaProperty(BaseModel):
class ApiBrainDefinitionSchemaProperty(BaseModel, extra=Extra.forbid):
type: str
description: str
enum: list
enum: Optional[list]
name: str
required: bool
def dict(self, **kwargs):
result = super().dict(**kwargs)
if "enum" in result and result["enum"] is None:
del result["enum"]
return result
class ApiBrainDefinitionSchema(BaseModel):
properties: list[ApiBrainDefinitionSchemaProperty]
required: list[str]
class ApiBrainDefinitionSchema(BaseModel, extra=Extra.forbid):
properties: list[ApiBrainDefinitionSchemaProperty] = []
required: list[str] = []
class ApiBrainDefinitionSecret(BaseModel):
@ -21,9 +28,16 @@ class ApiBrainDefinitionSecret(BaseModel):
type: str
class ApiBrainDefinition(BaseModel):
class ApiBrainAllowedMethods(str, Enum):
GET = "GET"
POST = "POST"
PUT = "PUT"
DELETE = "DELETE"
class ApiBrainDefinition(BaseModel, extra=Extra.forbid):
brain_id: UUID
method: str
method: ApiBrainAllowedMethods
url: str
params: ApiBrainDefinitionSchema
search_params: ApiBrainDefinitionSchema

View File

@ -2,9 +2,13 @@ from enum import Enum
from typing import Optional
from uuid import UUID
from models.ApiBrainDefinition import ApiBrainDefinition
from models.ApiBrainDefinition import (
ApiBrainDefinition,
ApiBrainDefinitionSchema,
ApiBrainDefinitionSecret,
)
from models.databases.repository import Repository
from pydantic import BaseModel
from pydantic import BaseModel, Extra
class ApiMethod(str, Enum):
@ -14,13 +18,12 @@ class ApiMethod(str, Enum):
DELETE = "DELETE"
class CreateApiBrainDefinition(BaseModel):
brain_id: UUID
class CreateApiBrainDefinition(BaseModel, extra=Extra.forbid):
method: ApiMethod
url: str
params: dict
search_params: dict
secrets: dict
params: Optional[ApiBrainDefinitionSchema] = ApiBrainDefinitionSchema()
search_params: ApiBrainDefinitionSchema = ApiBrainDefinitionSchema()
secrets: Optional[list[ApiBrainDefinitionSecret]] = []
class ApiBrainDefinitions(Repository):
@ -40,10 +43,12 @@ class ApiBrainDefinitions(Repository):
return ApiBrainDefinition(**response.data[0])
def add_api_brain_definition(
self, brain_id: UUID, config: CreateApiBrainDefinition
self, brain_id: UUID, api_brain_definition: CreateApiBrainDefinition
) -> Optional[ApiBrainDefinition]:
response = self.db.table("api_brain_definition").insert(
[{"brain_id": str(brain_id), **config.dict()}]
response = (
self.db.table("api_brain_definition")
.insert([{"brain_id": str(brain_id), **api_brain_definition.dict()}])
.execute()
)
if len(response.data) == 0:
return None

View File

@ -4,12 +4,15 @@ from uuid import UUID
from logger import get_logger
from models.brain_entity import BrainEntity, BrainType, MinimalBrainEntity, PublicBrain
from models.databases.repository import Repository
from pydantic import BaseModel
from models.databases.supabase.api_brain_definition import (
CreateApiBrainDefinition,
)
from pydantic import BaseModel, Extra
logger = get_logger(__name__)
class CreateBrainProperties(BaseModel):
class CreateBrainProperties(BaseModel, extra=Extra.forbid):
name: Optional[str] = "Default brain"
description: Optional[str] = "This is a description"
status: Optional[str] = "private"
@ -19,6 +22,8 @@ class CreateBrainProperties(BaseModel):
openai_api_key: Optional[str] = None
prompt_id: Optional[UUID] = None
brain_type: Optional[BrainType] = BrainType.DOC
brain_definition: Optional[CreateApiBrainDefinition]
brain_secrets_values: dict = {}
def dict(self, *args, **kwargs):
brain_dict = super().dict(*args, **kwargs)
@ -53,7 +58,12 @@ class Brain(Repository):
self.db = supabase_client
def create_brain(self, brain: CreateBrainProperties):
response = (self.db.table("brains").insert(brain.dict())).execute()
response = (
self.db.table("brains").insert(
brain.dict(exclude={"brain_definition", "brain_secrets_values"})
)
).execute()
return BrainEntity(**response.data[0])
def get_user_brains(self, user_id) -> list[MinimalBrainEntity]:

View File

@ -7,8 +7,8 @@ from models.settings import get_supabase_db
def add_api_brain_definition(
brain_id: UUID, api_brain_configs: CreateApiBrainDefinition
brain_id: UUID, api_brain_definition: CreateApiBrainDefinition
) -> None:
supabase_db = get_supabase_db()
supabase_db.add_api_brain_definition(brain_id, api_brain_configs)
supabase_db.add_api_brain_definition(brain_id, api_brain_definition)

View File

@ -1,8 +1,45 @@
from uuid import UUID
from fastapi import HTTPException
from models import BrainEntity, get_supabase_db
from models.brain_entity import BrainType
from models.databases.supabase.brains import CreateBrainProperties
from repository.api_brain_definition.add_api_brain_definition import (
add_api_brain_definition,
)
from repository.external_api_secret.create_secret import create_secret
def create_brain(brain: CreateBrainProperties, user_id: UUID) -> BrainEntity:
if brain.brain_type == BrainType.API:
if brain.brain_definition is None:
raise HTTPException(status_code=404, detail="Brain definition not found")
if brain.brain_definition.url is None:
raise HTTPException(status_code=404, detail="Brain url not found")
if brain.brain_definition.method is None:
raise HTTPException(status_code=404, detail="Brain method not found")
def create_brain(brain: CreateBrainProperties) -> BrainEntity:
supabase_db = get_supabase_db()
return supabase_db.create_brain(brain)
created_brain = supabase_db.create_brain(brain)
if brain.brain_type == BrainType.API and brain.brain_definition is not None:
add_api_brain_definition(
brain_id=created_brain.brain_id,
api_brain_definition=brain.brain_definition,
)
secrets_values = brain.brain_secrets_values
for secret_name in secrets_values:
create_secret(
user_id=user_id,
brain_id=created_brain.brain_id,
secret_name=secret_name,
secret_value=secrets_values[secret_name],
)
return created_brain

View File

@ -1,6 +1,6 @@
pymupdf==1.22.3
langchain==0.0.304
litellm==0.1.816
langchain==0.0.332
litellm==0.13.2
Markdown==3.4.4
openai==0.27.8
GitPython==3.1.36

View File

@ -24,6 +24,7 @@ from repository.brain import (
update_brain_by_id,
)
from repository.prompt import delete_prompt_by_id, get_prompt_by_id
from routes.authorizations.brain_authorization import has_brain_authorization
from routes.authorizations.types import RoleEnum
@ -91,7 +92,10 @@ async def create_new_brain(
detail=f"Maximum number of brains reached ({user_settings.get('max_brains', 5)}).",
)
new_brain = create_brain(brain)
new_brain = create_brain(
brain,
user_id=current_user.id,
)
if get_user_default_brain(current_user.id):
logger.info(f"Default brain already exists for user {current_user.id}")
create_brain_user(

View File

@ -1,3 +1,4 @@
from fastapi import HTTPException
from llm.api_brain_qa import APIBrainQA
from llm.qa_base import QABaseBrainPicking
from models.brain_entity import BrainType
@ -46,7 +47,7 @@ class BrainfulChat(ChatInterface):
brain = get_brain_by_id(brain_id)
if not brain:
raise Exception("No brain found")
raise HTTPException(status_code=404, detail="Brain not found")
if (
brain.brain_type == BrainType.DOC
@ -62,6 +63,7 @@ class BrainfulChat(ChatInterface):
streaming=streaming,
prompt_id=prompt_id,
)
return APIBrainQA(
chat_id=chat_id,
model=model,

View File

@ -21,5 +21,6 @@ class ChatInterface(ABC):
user_openai_api_key,
streaming,
prompt_id,
user_id,
):
pass

View File

@ -173,7 +173,6 @@ async def create_question_handler(
try:
check_user_requests_limit(current_user)
is_model_ok = (brain_details or chat_question).model in userSettings.get("models", ["gpt-3.5-turbo"]) # type: ignore
gpt_answer_generator: HeadlessQA | QABaseBrainPicking
gpt_answer_generator = chat_instance.get_answer_generator(
chat_id=str(chat_id),
model=chat_question.model if is_model_ok else "gpt-3.5-turbo", # type: ignore