from langchain_community.vectorstores import Qdrant from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_core.runnables.base import RunnableLambda from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.tools import StructuredTool from langchain_core.utils.function_calling import convert_to_openai_tool from langchain_core.messages import AIMessage from typing import List from chat_utils import get_init_modules, SYSTEM_PROMPT, SYSTEM_PROMPT_LOOP, ContextInput, Answer from langchain_core.documents.base import Document class EurLexChat: def __init__(self, config: dict): self.config = config self.max_history_messages = self.config["max_history_messages"] self.use_functions = ( 'use_context_function' in config["llm"] and config["llm"]["use_context_function"] and config["llm"]["class"] == "ChatOpenAI") self.embedder, self.llm, self.chatDB_class, self.retriever = get_init_modules(config) self.max_context_size = config["llm"]["max_context_size"] self.prompt = ChatPromptTemplate.from_messages([ ("system", SYSTEM_PROMPT), MessagesPlaceholder(variable_name="history"), ("human", "{question}"), ]) self.prompt_loop = ChatPromptTemplate.from_messages([ ("system", SYSTEM_PROMPT_LOOP), ("human", "History: {history}. Message:"), ]) self.chain_loop_answer = ( self.prompt_loop | self.llm ) if self.use_functions: GET_CONTEXT_TOOL = StructuredTool.from_function( func=self.get_context, name="get_context", description="To be used whenever the provided context is empty or the user changes the topic of the conversation and you need the context for the topic. " + "This function must be called only when is strictly necessary. " + "This function must not be called if you already have the information to answer the user. ", args_schema=ContextInput ) # self.llm = self.llm.bind(tools=[convert_to_openai_tool(GET_CONTEXT_TOOL)]) self.llm_with_functions = self.llm.bind(tools=[convert_to_openai_tool(GET_CONTEXT_TOOL)]) chain = self.prompt | RunnableLambda(self._resize_history) | self.llm_with_functions else: chain = self.prompt | RunnableLambda(self._resize_history) | self.llm self.chain_with_history = RunnableWithMessageHistory( chain, self.get_chat_history, input_messages_key="question", history_messages_key="history", ) self.relevant_documents_pipeline = ( self.retriever | self._parse_documents ) def _resize_history(self, input_dict): """ Resize the message history. Args: input_dict: The llm input containing the message history. Returns: dict: The resized version of the input_dict. """ messages = input_dict.messages if (len(messages) - 2) > self.max_history_messages: messages = [messages[0]] + messages[-(self.max_history_messages +1):] input_dict.messages = messages return input_dict def get_chat_history(self, session_id: str): """ Retrieve chat history instance for a specific session ID. Args: session_id (str): The unique identifier for the session. Returns: Chat history object: An instance of the appropriate chat history class. """ kwargs = self.config["chatDB"]["kwargs"] if self.config["chatDB"]["class"] == 'FileChatMessageHistory': file_path = f"{kwargs['output_path']}/{session_id}.json" return self.chatDB_class(file_path=file_path) else: return self.chatDB_class(session_id=session_id, **kwargs) def _parse_documents(self, docs: List[Document]) -> List[dict]: """ Parse a list of documents into a standardized format. Args: docs (List[Document]): A list of documents to parse. Returns: List[dict]: A list of dictionaries, each containing parsed information from the input documents. """ parsed_documents = [] for doc in docs: parsed_documents.append({ 'text': doc.page_content, 'source': doc.metadata["source"], '_id': doc.metadata["_id"] }) return parsed_documents def _format_context_docs(self, context_docs: List[dict]) -> str: """ Format a list of documents into a single string. Args: context_docs (List[dict]): A list of dictionaries containing text from context documents. Returns: str: A string containing the concatenated text from all context documents. """ context_str = '' for doc in context_docs: context_str += doc['text'] + "\n\n" return context_str def get_relevant_docs(self, question:str) -> List[dict]: """ Retrieve relevant documents based on a given question. Args: question (str): The question for which relevant documents are retrieved. Returns: List[dict]: A list of relevant documents. """ docs = self.relevant_documents_pipeline.invoke(question) return docs def get_context(self, text:str) -> str: """ Retrieve context for a given text. Args: text (str): The text for which context is retrieved. Returns: str: A formatted string containing the relevant documents texts. """ docs = self.get_relevant_docs(text) return self._format_context_docs(docs) def _remove_last_messages(self, session_id:str, n:int) -> None: """ Remove last n messages from the chat history of a specific session. Args: session_id (str): The session ID for which messages are removed. n (int): The number of last messages to remove. """ chat_history = self.get_chat_history(session_id=session_id) message_history = chat_history.messages chat_history.clear() message_history = message_history[:-n] for message in message_history: chat_history.add_message(message) def _format_history(self, session_id:str) -> str: """ Format chat history for a specific session into a string. Args: session_id (str): The session ID for which the chat history is formatted. Returns: str: A formatted string containing the chat history for the specified session. """ chat_history = self.get_chat_history(session_id).messages formatted_history = "" for message in chat_history: formatted_history += f"{message.type}: {message.content}\n\n" return formatted_history def _resize_context(self, context_docs:List[dict]) -> List[dict]: """ Resize the dimension of the context in terms of number of tokens. If the concatenation of document text exceeds max_context_size, the document text is cut off to meet the limit. Args: context_docs (List[dict]): List of formatted documents. Returns: List[dict]: Returns the list of resized documents. """ lengths = [self.llm.get_num_tokens(doc['text']) for doc in context_docs] resized_contexts = [] total_len = 0 for i, l in enumerate(lengths): if l + total_len <= self.max_context_size: resized_contexts.append(context_docs[i]) total_len += l return resized_contexts def get_answer(self, session_id:str, question:str, context_docs:List[dict], from_tool:bool=False) -> Answer: """ Get an answer to a question of a specific session, considering context documents and history messages. Args: session_id (str): The session ID for which the answer is retrieved. question (str): The new user message. context_docs (List[dict]): A list of documents used as context to answer the user message. from_tool (bool, optional): Whether the question originates from a tool. Defaults to False. Returns: Answer: An object containing the answer along with a new list of context documents if those provided are insufficient to answer the question. """ resized_docs = self._resize_context(context_docs) context = self._format_context_docs(resized_docs) result = self.chain_with_history.invoke( {"context": context, "question": question}, config={"configurable": {"session_id": session_id}} ) if self.use_functions and len(result.additional_kwargs) > 0: if from_tool: self._remove_last_messages(session_id=session_id, n=1) history = self._format_history(session_id) result = self.chain_loop_answer.invoke({'history': history}) self.get_chat_history(session_id=session_id).add_message(AIMessage(result.content)) return Answer(answer=result.content, status=-1) text = eval(result.additional_kwargs['tool_calls'][0]['function']['arguments'])['text'] new_docs = self.get_relevant_docs(text) self._remove_last_messages(session_id=session_id, n=2) result = self.get_answer( session_id=session_id, question=question, context_docs=new_docs, from_tool=True ) if result.status == 1: return Answer(answer=result.answer, new_documents=new_docs) else: return Answer(answer=result.answer) return Answer(answer=result.content)