diff --git a/main.py b/main.py index 01a4ad6..9be2b57 100644 --- a/main.py +++ b/main.py @@ -4,30 +4,41 @@ import asyncio from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler handler = StreamingStdOutCallbackHandler() - # Load environment variables load_dotenv() +# Load custom tools +import src.custom_tools as custom_tools + +tools = [custom_tools.disk_usage, custom_tools.memory_usage, + custom_tools.asyncArxivQueryRun(max_workers=4), + custom_tools.asyncDuckDuckGoSearchRun(max_workers=4)] + +# You can load more tools using load_tools +# from langchain.agents import load_tools +# tools.extend(load_tools(['ddg-search', 'arxiv', 'requests_all'])) + # Create SlackBot instance bot = SlackBot(name='SlackBot', verbose=True, + max_tokens=500, model_type='openai', chunk_size=500, # Chunk size for splitter chunk_overlap=50, # Chunk overlap for splitter k_similarity=5, # Numbers of chunks to return in retriever - log_filename='_slackbot.log' + log_filename='_slackbot.log', + tools=tools, ) ## LLM configuration -model_type = 'openai' -if model_type == 'llama': +if bot.model_type == 'llama': config = dict(gpu_layers=40, temperature=0.8, batch_size=1024, - context_length=2048, threads=6, stream=True, max_new_tokens=500) + context_length=2048, threads=6, stream=True, max_new_tokens=bot.max_tokens) else: - config = dict(model_name="gpt-3.5-turbo", temperature=0.8, max_tokens=500) + config = dict(model_name="gpt-3.5-turbo-16k", temperature=0.8, max_tokens=bot.max_tokens) # Initialize LLM and embeddings bot.app.logger.info("Initializing LLM and embeddings...") -bot.initialize_llm(model_type, max_tokens_threads=1000, config=config, callbacks=[handler]) -bot.initialize_embeddings(model_type) +bot.initialize_llm(bot.model_type, max_tokens_threads=4000, config=config, callbacks=[handler]) +bot.initialize_embeddings(bot.model_type) # Create handlers for commands /ask, /modify_bot, /bot_info and bot mentions create_handlers(bot) @@ -45,8 +56,8 @@ async def start(): if __name__ == "__main__": logger = bot.app.logger try: - logger.info('App started.') asyncio.run(start()) + logger.info('App started.') except KeyboardInterrupt: logger.info('App stopped by user.') except Exception as e: diff --git a/requirements.txt b/requirements.txt index dba2514..c0a5cd0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ chromadb==0.3.25 -langchain==0.0.205 +langchain==0.0.231 python-dotenv==1.0.0 slack_bolt==1.18.0 tiktoken==0.4.0 diff --git a/src/custom_tools.py b/src/custom_tools.py new file mode 100644 index 0000000..deb58d9 --- /dev/null +++ b/src/custom_tools.py @@ -0,0 +1,36 @@ +from langchain.tools import tool, DuckDuckGoSearchRun, ArxivQueryRun +from concurrent.futures import ThreadPoolExecutor +import asyncio +import subprocess +@tool +def disk_usage(query: str) -> str: + """useful for when you need to answer questions about the disk usage.""" + output = subprocess.check_output(['df', '-h'], text=True) + output = "This is the output of `df -h`:\n" + output + return output + +@tool +def memory_usage(query: str) -> str: + """useful for when you need to answer questions about memory usage.""" + output = subprocess.check_output(['free', '-h'], text=True) + output = "This is the output of `free -h`. Mem refers to RAM memory and Swap to swap memory:\n" + output + return output + +class asyncDuckDuckGoSearchRun(DuckDuckGoSearchRun): + # max number of parallel requests + max_workers: int = 2 + async def _arun(self,query: str) -> str: + """Use the tool asynchronously.""" + executor = ThreadPoolExecutor(max_workers=self.max_workers) + results = await asyncio.get_running_loop().run_in_executor(executor, self._run, query) + return results + +class asyncArxivQueryRun(ArxivQueryRun): + # max number of parallel requests + max_workers: int = 2 + async def _arun(self, query: str) -> str: + """Use the tool asynchronously.""" + executor = ThreadPoolExecutor(max_workers=self.max_workers) + results = await asyncio.get_running_loop().run_in_executor(executor, self._run, query) + return results + diff --git a/src/handlers.py b/src/handlers.py index f472f6e..5e1f0b3 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -1,7 +1,7 @@ from . import prompts from .slackbot import SlackBot from .utils import (parse_format_body, get_llm_reply, - extract_message_from_thread) + extract_message_from_thread, get_agent_reply) from .ingest import process_uploaded_files import json import os @@ -47,8 +47,12 @@ async def handle_ask(ack: Ack, respond: Respond, say: Say, 'instructions', 'query']) + channel_bot_info = bot.get_channel_llm_info(channel_id) # Get the response from the LLM - response, initial_ts = await get_llm_reply(bot, prompt, parsed_body) + if channel_bot_info['as_agent']: + response, initial_ts = await get_agent_reply(bot, parsed_body, None) + else: + response, initial_ts = await get_llm_reply(bot, prompt, parsed_body) # Format the response response = f"*<@{user_id}> asked*: {parsed_body['query']}\n*Answer*:\n{response}" @@ -93,6 +97,45 @@ async def handle_modify_bot(ack: Ack, body: Dict[str, Any], view["blocks"][1]["element"]["initial_value"] = channel_bot_info['instructions'] view["blocks"][2]["element"]["initial_value"] = str(channel_bot_info['temperature']) + # OpenAI model, only if model_type is openai + if bot.model_type == 'openai': + if 'openai_model' in channel_bot_info: + initial_text = ("ChatModel: " + if channel_bot_info['openai_model'].startswith('gpt') + else "InstructModel: ") + initial_option = {"text": { + "type": "plain_text", + "text": f"{initial_text}{channel_bot_info['openai_model']}" + }, + "value": channel_bot_info['openai_model'] + } + view["blocks"][3]["element"]["initial_option"] = initial_option + else: + view["blocks"][3] = { "type": "section", + "text": { "type": "plain_text", "text": " "}} + + # Agent or Chain + if channel_bot_info['as_agent']: + view["blocks"][4]["element"]["initial_option"]["value"] = "as_agent" + view["blocks"][4]["element"]["initial_option"]["text"]["text"] = "Use it as an Agent" + else: + view["blocks"][4]["element"]["initial_option"]["value"] = "as_llm_chain" + view["blocks"][4]["element"]["initial_option"]["text"]["text"] = "Use it as a LLM chain" + + + # Tools + all_options = [] + for tool in bot.tool_names: + option = { + "text": { + "type": "plain_text", + "text": tool + }, + "value": tool + } + all_options.append(option) + view["blocks"][5]["element"]["options"] = all_options + # Include channel_id in private_metadata extra_data = {"channel_id": channel_id} if '!no-notify' in body['text']: @@ -101,6 +144,20 @@ async def handle_modify_bot(ack: Ack, body: Dict[str, Any], extra_data["notify"] = True view["private_metadata"] = json.dumps(extra_data) + initial_options = [] + for tool in channel_bot_info['tool_names']: + if tool in bot.tool_names: + option = { + "text": { + "type": "plain_text", + "text": tool + }, + "value": tool + } + initial_options.append(option) + if initial_options: + view["blocks"][5]["element"]["initial_options"] = initial_options + # Open view for bot modification await bot.app.client.views_open(trigger_id=trigger_id, view=view) @@ -120,7 +177,6 @@ async def handle_modify_bot_view(ack: Ack, body: Dict[str, Any], channel_id = json.loads(view["private_metadata"])["channel_id"] notify = json.loads(view["private_metadata"])["notify"] user = body['user']['id'] - # Iterate through each bot value and update it for key in bot_values.keys(): input_value = values[key][key]['value'] @@ -136,6 +192,15 @@ async def handle_modify_bot_view(ack: Ack, body: Dict[str, Any], return bot_values[key] = input_value + if 'openai_model' in values: + bot_values['openai_model'] = values['openai_model']['openai_model']['selected_option']['value'] + as_agent = values['use_it_as']['unused_action']['selected_option']['value'] + bot_values['as_agent'] = True if 'as_agent' == as_agent else False + + selected_tools = values['tool_names']['unused_action']['selected_options'] + tool_names = [tool['value'] for tool in selected_tools] + bot_values['tool_names'] = tool_names + # Update channel's bot info bot.define_channel_llm_info(channel_id, bot_values) await ack() @@ -167,9 +232,16 @@ async def handle_bot_info(ack: Ack, respond: Respond, # Create a response string with the bot's default prompt and temperature prompt = prompts.INITIAL_BOT_PROMPT response = "*Default Prompt:*\n`" + if not bot_info['tool_names']: + bot_info['tool_names'] = ['None'] response += prompt.format(personality=bot_info["personality"], instructions=bot_info["instructions"]) - response += f"`\n*Temperature:* {bot_info['temperature']}" + response += (f"`\n*Temperature:* {bot_info['temperature']}" + f"\n*is Agent:* _{bot_info['as_agent']}_," + f" *Tools:* _" + ', '.join(bot_info['tool_names']) + '_') + + if bot.model_type == 'openai' and 'openai_model' in bot_info: + response += (f"\n*OpenAI Model:* _{bot_info['openai_model']}_") # Send the response to the user await respond(text=response) @@ -325,15 +397,19 @@ async def handle_mention(say: Say, extra_context = second_message['text'] qa_prompt = qa_prompt.partial(extra_context=extra_context) if bot.verbose: - bot.app.logger.info(f"Asking RetrievalQA. " + bot.app.logger.info(f"Asking inside a Thread. " f" {extra_context}:" f" {channel_id}/{first_message['ts']}:" f" {parsed_body['query']}") # Get reply and update initial message - response, initial_ts = await get_llm_reply(bot, prompt, parsed_body, - first_ts=first_message['ts'], - qa_prompt=qa_prompt) + channel_bot_info = bot.get_channel_llm_info(channel_id) + if channel_bot_info['as_agent']: + response, initial_ts = await get_agent_reply(bot,parsed_body, first_message['ts']) + else: + response, initial_ts = await get_llm_reply(bot, prompt, parsed_body, + first_ts=first_message['ts'], + qa_prompt=qa_prompt) client = bot.app.client await client.chat_update( @@ -476,9 +552,6 @@ async def handle_upload_files_view(ack: Ack, args=(channel_id, msg_timestamp, texts, file_name_list, extra_context)) thread.start() - # bot.define_thread_retriever_db(channel_id, msg_timestamp, texts) - # await say(f"_This is a QA Thread using files `{'` `'.join(file_name_list)}`_", - # thread_ts=msg_timestamp, channel=channel_id) @bot.app.action("unused_action") async def handle_unused(ack: Ack): diff --git a/src/payloads/modify_bot_template.json b/src/payloads/modify_bot_template.json index 01b656b..18372ac 100644 --- a/src/payloads/modify_bot_template.json +++ b/src/payloads/modify_bot_template.json @@ -19,7 +19,7 @@ "blocks": [ { "type": "input", - "block_id" : "personality", + "block_id": "personality", "element": { "type": "plain_text_input", "action_id": "personality", @@ -32,7 +32,7 @@ }, { "type": "input", - "block_id" : "instructions", + "block_id": "instructions", "element": { "type": "plain_text_input", "action_id": "instructions", @@ -50,7 +50,7 @@ }, { "type": "input", - "block_id" : "temperature", + "block_id": "temperature", "element": { "type": "plain_text_input", "action_id": "temperature", @@ -59,12 +59,130 @@ "hint": { "type": "plain_text", "text": "A number between 0 (concrete) to 1 (creative)" - }, "label": { "type": "plain_text", "text": "Temperature" } + }, + { + "type": "input", + "block_id": "openai_model", + "element": { + "type": "static_select", + "action_id": "openai_model", + "placeholder": { + "type": "plain_text", + "text": "Select options", + "emoji": true + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "ChatModel: gpt-3.5-turbo" + }, + "value": "gpt-3.5-turbo" + }, + { + "text": { + "type": "plain_text", + "text": "ChatModel: gpt-3.5-turbo-16k" + }, + "value": "gpt-3.5-turbo-16k" + }, + { + "text": { + "type": "plain_text", + "text": "ChatModel: gpt-4" + }, + "value": "gpt-4" + }, + { + "text": { + "type": "plain_text", + "text": "ChatModel: gpt-4-32k" + }, + "value": "gpt-4-32k" + }, + { + "text": { + "type": "plain_text", + "text": "InstructModel: text-davinci-003", + "emoji": true + }, + "value": "text-davinci-003" + } + ] + }, + "label": { + "type": "plain_text", + "text": "OpenAI Model to use", + "emoji": true + } + }, + { + "type": "input", + "block_id": "use_it_as", + "element": { + "type": "radio_buttons", + "action_id": "unused_action", + "initial_option": { + "value": "as_llm_chain", + "text": { + "type": "plain_text", + "text": "Use it as a LLM chain" + } + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "Use it as a LLM chain" + }, + "value": "as_llm_chain" + }, + { + "text": { + "type": "plain_text", + "text": "Use it as an Agent" + }, + "value": "as_agent" + } + ] + }, + "label": { + "type": "plain_text", + "text": " ", + "emoji": true + } + }, + { + "type": "input", + "block_id": "tool_names", + "optional": true, + "element": { + "type": "multi_static_select", + "placeholder": { + "type": "plain_text", + "text": "Select options" + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "*this is plain_text text*" + }, + "value": "value-0" + } + ], + "action_id": "unused_action" + }, + "label": { + "type": "plain_text", + "text": "Tools to use", + "emoji": true + } } ] } \ No newline at end of file diff --git a/src/prompts.py b/src/prompts.py index 2a26578..fd6ac44 100644 --- a/src/prompts.py +++ b/src/prompts.py @@ -36,4 +36,35 @@ "\n\n{context}\n" "\nQuestion: {question}" "\nHelpful Answer in the same language as question:" +) + +AGENT_PROMPT = ( + f"{INITIAL_BOT_PROMPT}" + " Given a conversation between users {users} and you with the name \"AI\"," + " and a follow up question, you must answer as best as you can. If you are" + " not sure about something, you can use a tool." + "\nTOOLS:" + "\n------" + "\n\nYou have access to the following tools:" + "\n\n{tools}\n\n" + "To use a tool, please use the following format:" + "\n```" + "\nThought: Do I need to use a tool? Yes" + "\nAction: the action to take, should be one of [{tool_names}]" + "\nAction Input: the input to the action" + "\nObservation: the result of the action" + "\n... (this Thought/Action/Action Input/Observation can repeat N times)" + "\n```" + "\n\nWhen you have a response to say, or if you do not need to use a tool," + " you MUST use the format:" + "\n\n```" + "\nThought: Do I need to use a tool? No" + "\nAI: [your response in the same language of the conversation]" + "\n```" + "\n\nBegin! If even after using a tool you still can't figure out the" + " answer, then say that you don't know and ask for more context." + "\n\nPrevious conversation history:" + "\n{chat_history}\n" + "\nNew message: {input}" + "\n{agent_scratchpad}" ) \ No newline at end of file diff --git a/src/slackagent.py b/src/slackagent.py new file mode 100644 index 0000000..0a9963f --- /dev/null +++ b/src/slackagent.py @@ -0,0 +1,132 @@ +from langchain.tools import BaseTool +from langchain.agents import AgentExecutor, LLMSingleActionAgent, AgentOutputParser +from langchain import LLMChain +from langchain.prompts import StringPromptTemplate +from typing import List, Union, Any +from langchain.schema import AgentAction, AgentFinish, OutputParserException +from langchain.llms.base import LLM +from .prompts import AGENT_PROMPT +from .slackbot import SlackBot +import asyncio +import re + +# Set up a prompt template +class CustomPromptTemplate(StringPromptTemplate): + """ + Custom prompt template for the Slack Agent + """ + # The template to use + template: str + # Personality of the bot + personality: str + # Instructions for the bot + instructions: str + # The list of tools available + tools: List[BaseTool] + # List of users inside the chat as a string + users : str + # Chat history as a string + chat_history : str + + def format(self, **kwargs) -> str: + # Get the intermediate steps (AgentAction, Observation tuples) + # Format them in a particular way + intermediate_steps = kwargs.pop("intermediate_steps") + thoughts = "" + for action, observation in intermediate_steps: + thoughts += action.log + thoughts += f"\nObservation: {observation}\nThought: " + # Set the agent_scratchpad variable to that value + kwargs["agent_scratchpad"] = thoughts + # Create a tools variable from the list of tools provided + kwargs["tools"] = "\n".join([f"{tool.name}: {tool.description}" for tool in self.tools]) + # Create a list of tool names for the tools provided + kwargs["tool_names"] = ", ".join([tool.name for tool in self.tools]) + kwargs["users"] = self.users + kwargs["chat_history"] = self.chat_history + kwargs["personality"] = self.personality + kwargs["instructions"] = self.instructions + return self.template.format(**kwargs) + +class CustomOutputParser(AgentOutputParser): + """ + Custom output parser for the Slack Agent + """ + # The Slackbot instance, used for logging + bot: Any + # timestamp of the initial message + initial_ts: str + # channel where the message was sent + channel_id: str + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + if f"AI: " in text: + return AgentFinish( + {"output": text.split(f"AI: ")[-1].strip()}, text + ) + regex = r"Action: (.*?)[\n]*Action Input: (.*)" + match = re.search(regex, text) + if not match: + raise OutputParserException(f"Could not parse LLM output: `{text}`") + action = match.group(1) + action_input = match.group(2) + msg = match[0].replace('\n', ', ') + self.bot.app.logger.info(msg) + client = self.bot.app.client + try: + loop = asyncio.get_event_loop() + loop.run_until_complete(client.chat_update(channel=self.channel_id, + ts=self.initial_ts, + text=msg + "... :hourglass_flowing_sand:")) + except Exception as e: + self.bot.app.logger.error(f'SlackAgentCannot update the message: {e}') + + return AgentAction(action.strip(), action_input.strip(" ").strip('"'), text) + + +def slack_agent(bot: SlackBot, llm: LLM, personality: str, + instructions: str, users: str, chat_history: str, + tools: List[BaseTool], initial_ts: float, channel_id: str + ) -> AgentExecutor: + """ + Create an agent executor for the Slackbot + + Args: + bot: The Slackbot object. + llm: The LLM to use + personality: The personality of the bot + instructions: The instructions for the bot + users: The list of users inside the chat as a string + chat_history: The chat history as a string + tools: The list of tools available + initial_ts: The timestamp of the initial message + channel_id: The channel where the message was sent + Returns: + agent_executor: The agent executor + """ + prompt = CustomPromptTemplate( + template=AGENT_PROMPT, + personality=personality, + instructions=instructions, + tools=tools, + users=users, + chat_history=chat_history, + input_variables=["input", "intermediate_steps"] + ) + llm_chain = LLMChain(llm=llm, prompt=prompt) + if not initial_ts: + initial_ts = "" + output_parser = CustomOutputParser(bot=bot, + initial_ts=initial_ts, + channel_id=channel_id) + tool_names = [tool.name for tool in tools] + agent = LLMSingleActionAgent( + llm_chain=llm_chain, + output_parser=output_parser, + stop=["\nObservation:"], + allowed_tools=tool_names + ) + agent_executor = (AgentExecutor + .from_agent_and_tools(agent=agent, tools=tools, + handle_parsing_errors="Check your output and make sure it conforms!")) # , verbose=bot.verbose + return agent_executor diff --git a/src/slackbot.py b/src/slackbot.py index 0c3c73d..d5b9482 100644 --- a/src/slackbot.py +++ b/src/slackbot.py @@ -13,6 +13,7 @@ from langchain.docstore.document import Document from langchain.vectorstores.base import VectorStore from langchain.vectorstores import Chroma +from langchain.tools import BaseTool import glob from chromadb.config import Settings @@ -31,9 +32,12 @@ def __init__(self, name: str='SlackBot', " user's questions. Answers in no more" " than 40 words. You must format your" " messages in Slack markdown.", - default_temp: float=0.8, chunk_size=500, chunk_overlap=50, + default_temp: float=0.8, max_tokens: int=500, + model_type='fakellm', chunk_size=500, + chunk_overlap=50, k_similarity=5, verbose: bool=False, - log_filename: Optional[str]=None) -> None: + log_filename: Optional[str]=None, + tools: List[Optional[str]]=[]) -> None: """ Initialize a new SlackBot instance. @@ -42,6 +46,8 @@ def __init__(self, name: str='SlackBot', default_personality: The default personality of the bot. default_instructions: The default instructions of the bot. default_temp: The default temperature of the bot. + max_tokens: The maximum number of tokens for the LLM. + model_type: The default model type to use for the LLM. chunk_size: The chunk size for the text splitter. chunk_overlap: The chunk overlap for the text splitter. k_similarity: The number of chunks to return using the retriever. @@ -53,7 +59,7 @@ def __init__(self, name: str='SlackBot', environment variables could not be found. """ self._name = name - + self._model_type = model_type.lower() # Setting Logger logger_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' self._verbose = verbose @@ -70,7 +76,7 @@ def __init__(self, name: str='SlackBot', logger.addHandler(console_handler) self._default_temp = default_temp - + self._max_tokens = max_tokens # for retriever and text splitter self._chunk_size = chunk_size self._chunk_overlap = chunk_overlap @@ -87,7 +93,9 @@ def __init__(self, name: str='SlackBot', # This could be a class default_llm_info = dict(personality=default_personality, instructions=default_instructions, - temperature=default_temp) + temperature=default_temp, + tool_names=[], + as_agent=False) self._default_llm_info = default_llm_info # This could be loaded using pydantic @@ -95,10 +103,19 @@ def __init__(self, name: str='SlackBot', if os.path.exists(llm_info_file): with open(llm_info_file, 'r') as f: channels_llm_info = json.load(f) - self._channels_llm_info = channels_llm_info + for channel_id in channels_llm_info: + # update with the new info + if 'tool_names' not in channels_llm_info[channel_id]: + channels_llm_info[channel_id]['tool_names'] = [] + if 'as_agent' not in channels_llm_info[channel_id]: + channels_llm_info[channel_id]['as_agent'] = False + self._channels_llm_info = channels_llm_info + else: self._channels_llm_info = {} + self._tools = tools + self._tool_names = [tool.name for tool in tools] logger.info("Loading Thread Retrievers locations..") thread_retriever_db = {} for channel_dir in glob.glob(db_dir + "/[CG]*"): @@ -172,13 +189,16 @@ def initialize_llm(self, model_type: str, config=config, **kwargs) else: - if (config["model_name"].startswith("gpt-3.5") - or config["model_name"].startswith("gpt-4")): + if config["model_name"].startswith("gpt"): self._llm = ChatOpenAI(**config, **kwargs) else: self._llm = OpenAI(**config, **kwargs) # self._llm = OpenAI(callbacks=[handler], **config) + for channel_id in self._channels_llm_info: + if 'openai_model' not in self._channels_llm_info[channel_id]: + self._channels_llm_info[channel_id]['openai_model'] = config["model_name"] + def initialize_embeddings(self, model_type: str, **kwargs) -> None: """ Initializes embeddings based on model type. @@ -212,6 +232,14 @@ async def start(self) -> None: self._bot_user_id = response["user_id"] await AsyncSocketModeHandler(self._app, self._app_token).start_async() + @property + def max_tokens(self) -> int: + return self._max_tokens + + @property + def model_type(self) -> str: + return self._model_type + @property def chunk_size(self): return self._chunk_size @@ -264,22 +292,25 @@ def max_tokens_threads(self) -> int: def embeddings(self) -> Embeddings: return self._embeddings #return (self.embeddings, self.embeddings_clf) - - def get_temperature(self) -> float: - """ - Get the temperature used in the language model. + + @property + def tool_names(self) -> List[str]: + return self._tool_names + def get_tools_by_names(self, tool_names: List[str]) -> List[BaseTool]: + """ + Get tools by their names. + + Args: + tool_names: The names of the tools to get. + Returns: - temp: The temperature used in the language model + tools: A list of tools. """ - if 'model_type' in self._llm.__dict__: # CTransformers - temperature = self._llm.client.config.temperature - else: - try: # OpenAI - temperature = self._llm.temperature - except: # FakeLLM - temperature = self._default_temp - return temperature + tools = [tool for tool in self._tools + if tool.name in tool_names] + return tools + def change_temperature(self, new_temperature: float) -> None : """ @@ -424,12 +455,38 @@ def get_stored_files_dict(self, timestamp: float) -> Dict[str, Any]: Args: timestamp: The timestamp of when the file was uploaded. - Returns + Returns: files_dict: The file dictionary """ files_dict = self._stored_files[timestamp] return files_dict + + def get_llm_by_channel(self, channel_id: str, **kwargs) -> LLM: + """ + Get the language model for a given channel. + + Args: + channel_id: The id of the channel. + kwargs: Additional keyword arguments for the language model. + + Returns: + llm: The language model to use in the channel + """ + channel_llm_info = self.get_channel_llm_info(channel_id) + if self._model_type == 'openai': + config = dict(model_name=channel_llm_info['openai_model'], + temperature=channel_llm_info['temperature'], + max_tokens=self._max_tokens) + if config["model_name"].startswith("gpt"): + llm = ChatOpenAI(**config, **kwargs) + else: + llm = OpenAI(**config, **kwargs) + else: + llm = self._llm + llm.client.config.temperature = channel_llm_info['temperature'] + return llm + def store_files_dict(self, timestamp: float, files_dict: Dict[str, Any]) -> None: """ Store a files dictionary from Slack. diff --git a/src/utils.py b/src/utils.py index 5e3d560..e53486e 100644 --- a/src/utils.py +++ b/src/utils.py @@ -3,14 +3,15 @@ import time import asyncio from typing import (Dict, Optional, Any, Union, Tuple, List, Set) -from slack_bolt import Say from .slackbot import SlackBot from langchain import PromptTemplate, LLMChain -from langchain.chains import ConversationalRetrievalChain +from langchain.chains import ConversationalRetrievalChain, RetrievalQA from langchain.vectorstores import Chroma from chromadb.config import Settings from .slackcallback import SlackAsyncCallbackHandler, SlackCallbackHandler from langchain.callbacks.base import AsyncCallbackHandler, BaseCallbackHandler +from langchain.agents import Tool +from langchain.llms.base import LLM # Get the directory path of the current script current_directory = os.path.dirname(os.path.abspath(__file__)) @@ -110,6 +111,9 @@ async def prepare_messages_history(bot: SlackBot, to_chain['query'] = parsed_body['query'] thread_ts = None warning_msg = "" + if bot.get_channel_llm_info(parsed_body['channel_id'])['as_agent']: + to_chain['chat_history'] = "" + to_chain['users'] = f"<@{parsed_body['user_id']}>" else: thread_ts = parsed_body['thread_ts'] messages_history, users = await extract_thread_conversation(bot, @@ -126,7 +130,7 @@ async def prepare_messages_history(bot: SlackBot, async def send_initial_message(bot: SlackBot, parsed_body: Dict[str, Union[str, float]], - thread_ts: Optional[float]) -> Optional[float]: + thread_ts: Optional[float]) -> Optional[str]: """ Send a initial message: "bot is thinking.." @@ -154,8 +158,41 @@ async def send_initial_message(bot: SlackBot, initial_ts = None return initial_ts +def get_temperature(llm: LLM) -> float: + """ + Get the temperature used in the language model. + + Args: + llm: The language model. + Returns: + temp: The temperature used in the language model + """ + if 'model_type' in llm.__dict__: # CTransformers + temperature = llm.client.config.temperature + else: + try: # OpenAI + temperature = llm.temperature + except: # FakeLLM + temperature = 0 + return temperature + +def change_temperature(llm: LLM, new_temperature: float) -> None : + """ + Update the temperature used in the language model. + + Args: + new_temperature: The new temperature to use. + """ + if 'model_type' in llm.__dict__: # CTransformers + llm.client.config.temperature = new_temperature + else: + try: # OpenAI + llm.temperature = new_temperature + except: # FakeLLM + pass -async def adjust_bot_temperature(bot: SlackBot, +async def adjust_llm_temperature(bot, + llm: LLM, parsed_body: Dict[str, Union[str, float]] ) -> float: """ @@ -170,10 +207,10 @@ async def adjust_bot_temperature(bot: SlackBot, Returns: temp: The new temperature of the bot """ - actual_temp = bot.get_temperature() + actual_temp = get_temperature(llm) temp = actual_temp if parsed_body['change_temp']: - bot.change_temperature(new_temperature=parsed_body['new_temp']) + change_temperature(llm, new_temperature=parsed_body['new_temp']) temp = parsed_body['new_temp'] if parsed_body['new_temp'] == -1: if parsed_body["from_command"]: @@ -189,7 +226,7 @@ async def get_llm_reply(bot: SlackBot, parsed_body: Dict[str, Union[str, float]], first_ts : Optional[float]=None, qa_prompt : Optional[PromptTemplate]=None - ) -> Tuple[str, Optional[Say]]: + ) -> Tuple[str, Optional[str]]: """ Generate a response using the bot's language model, given a prompt and a parsed request data. @@ -208,7 +245,7 @@ async def get_llm_reply(bot: SlackBot, initial_ts: The timestamp of the initial message sent by the bot. """ channel_llm_info = bot.get_channel_llm_info(parsed_body['channel_id']) - actual_temp = channel_llm_info['temperature'] + llm = bot.get_llm_by_channel(channel_id=parsed_body['channel_id']) # dictionary to format the prompt inside the chain to_chain = {k: channel_llm_info[k] for k in ['personality', 'instructions']} @@ -241,7 +278,7 @@ async def get_llm_reply(bot: SlackBot, llm_call = asyncio.Lock() async with llm_call: start_time = time.time() - temp = await adjust_bot_temperature(bot, parsed_body) + temp = await adjust_llm_temperature(bot, llm, parsed_body) if bot.model_type == 'fakellm': await asyncio.sleep(10) @@ -264,7 +301,7 @@ async def get_llm_reply(bot: SlackBot, instructions=to_chain['instructions'], users=to_chain['users']) chain = ConversationalRetrievalChain - chain = chain.from_llm(bot.llm, + chain = chain.from_llm(llm, vectorstore.as_retriever(kwargs={'k': bot.k_similarity}), combine_docs_chain_kwargs={"prompt" : qa_prompt}, condense_question_prompt=prompt, @@ -281,7 +318,7 @@ async def get_llm_reply(bot: SlackBot, callbacks=[handler]) else: # is not a QA question - chain = LLMChain(llm=bot.llm, prompt=prompt) + chain = LLMChain(llm=llm, prompt=prompt) try: resp_llm = await chain.arun(to_chain, callbacks=[async_handler]) except NotImplementedError: @@ -291,17 +328,135 @@ async def get_llm_reply(bot: SlackBot, response = resp_llm.strip() final_time = round((time.time() - start_time)/60,2) - bot.change_temperature(new_temperature=actual_temp) if bot.verbose: if qa_prompt: to_chain["question"] = parsed_body["query"] - n_tokens = bot.llm.get_num_tokens(prompt.format(**to_chain)) + n_tokens = llm.get_num_tokens(prompt.format(**to_chain)) response += f"\n(_time: `{final_time}` min. `temperature={temp}, n_tokens={n_tokens}`_)" bot.app.logger.info(response.replace('\n', '')) response += warning_msg return response, initial_ts +async def get_agent_reply(bot: SlackBot, + parsed_body: Dict[str, Union[str, float]], + first_ts : Optional[float]=None, + ) -> Tuple[str, Optional[str]]: + """ + Generate a response using the bot's language model, given a prompt and + a parsed request data. + + Args: + bot: The Slackbot object. + prompt: A PromptTemplate object containing the LLM prompt to be used. + parsed_body: The relevant information from the body obtained from + parse_format_body. + + Returns: + response: The generated response from the LLM. + initial_ts: The timestamp of the initial message sent by the bot. + """ + channel_llm_info = bot.get_channel_llm_info(parsed_body['channel_id']) + + llm = bot.get_llm_by_channel(channel_id=parsed_body['channel_id']) + + # dictionary to format the prompt inside the chain + to_chain = {k: channel_llm_info[k] for k in ['personality', 'instructions', 'tool_names']} + + # format prompt and get thread timestamp + (to_chain, thread_ts, + _, warning_msg) = await prepare_messages_history(bot, + parsed_body, + to_chain, + None) + + # send initial message + initial_ts = await send_initial_message(bot, parsed_body, thread_ts) + + if parsed_body['from_command']: + initial_msg = f"*<@{parsed_body['user_id']}> asked*: {parsed_body['query']}\n" + else: + initial_msg = "" + + if parsed_body['to_all']: + async_handler = SlackAsyncCallbackHandler(bot, channel_id=parsed_body['channel_id'], + ts=initial_ts, inital_message=initial_msg) + + handler = SlackCallbackHandler(bot, channel_id=parsed_body['channel_id'], + ts=initial_ts, inital_message=initial_msg) + else: + async_handler = AsyncCallbackHandler() + handler = BaseCallbackHandler() + + # generate response using language model + llm_call = asyncio.Lock() + async with llm_call: + start_time = time.time() + temp = await adjust_llm_temperature(bot, llm, parsed_body) + + if bot.model_type == 'fakellm': + await asyncio.sleep(10) + + if bot.verbose: + bot.app.logger.info('Getting response..') + + to_chain['tools'] = bot.get_tools_by_names(to_chain['tool_names']) + if first_ts: + # is a QA question + try: + db_path = bot.get_thread_retriever_db_path(parsed_body['channel_id'], + first_ts) + vectorstore = Chroma(persist_directory=db_path, + embedding_function=bot.embeddings, + client_settings=Settings( + chroma_db_impl='duckdb+parquet', + persist_directory=db_path, + anonymized_telemetry=False) + ) + qa_chain = RetrievalQA.from_chain_type( + llm=llm, chain_type="stuff", + retriever=vectorstore.as_retriever(kwargs={'k': bot.k_similarity}) + ) + second_message = await extract_message_from_thread(bot, parsed_body['channel_id'], + thread_ts, position=1) + extra_context = re.search("The files are about (.*)", second_message['text']).group(1) + doc_retriever = [Tool(name="doc_retriever", + func=qa_chain.run, + coroutine=qa_chain.arun, + description=f"useful for when you need to answer questions about {extra_context}.", + )] + to_chain['tools'].extend(doc_retriever) + except KeyError: + bot.app.logger.info('There are no documents for this thread') + + from .slackagent import slack_agent + executor_agent = slack_agent(bot, llm, personality=to_chain['personality'], + instructions=to_chain['instructions'], + users=to_chain['users'], + chat_history=to_chain['chat_history'], + tools=to_chain['tools'], + initial_ts=initial_ts, + channel_id=parsed_body['channel_id']) + try: + resp_llm = await executor_agent.arun(input=parsed_body['query'], callbacks=[async_handler]) + except NotImplementedError: + bot.app.logger.info('No Async generation implemented for this LLM' + ', using concurrent mode') + resp_llm = executor_agent.run(input=parsed_body['query'], callbacks=[handler]) + + response = resp_llm.strip() + final_time = round((time.time() - start_time)/60,2) + + if bot.verbose: + from .prompts import AGENT_PROMPT + to_chain["input"] = parsed_body["query"] + to_chain["agent_scratchpad"] = "" + n_tokens = llm.get_num_tokens(AGENT_PROMPT.format(**to_chain)) + n_tokens += 100 + response += f"\n(_time: `{final_time}` min. `temperature={temp}, n_tokens~={n_tokens}`_)" + bot.app.logger.info(response.replace('\n', '')) + response += warning_msg + return response, initial_ts async def extract_message_from_thread(bot: SlackBot, channel_id:str,