interactive-rag
601 строка · 25.9 Кб
1from typing import List
2from actionweaver import RequireNext, action
3from actionweaver.llms.azure.chat import ChatCompletion
4from actionweaver.llms.openai.tools.chat import OpenAIChatCompletion
5from actionweaver.llms.openai.functions.tokens import TokenUsageTracker
6from langchain.vectorstores import MongoDBAtlasVectorSearch
7from langchain.embeddings import GPT4AllEmbeddings
8from langchain.document_loaders import PlaywrightURLLoader
9from langchain.document_loaders import BraveSearchLoader
10from langchain.text_splitter import RecursiveCharacterTextSplitter
11import params
12import json
13import os
14import pymongo
15from selenium import webdriver
16from selenium.webdriver.chrome.options import Options
17from bs4 import BeautifulSoup
18import pandas as pd
19from tabulate import tabulate
20import utils
21import vector_search
22
23os.environ["OPENAI_API_KEY"] = params.OPENAI_API_KEY
24os.environ["OPENAI_API_VERSION"] = params.OPENAI_API_VERSION
25os.environ["OPENAI_API_TYPE"] = params.OPENAI_TYPE
26
27MONGODB_URI = params.MONGODB_URI
28DATABASE_NAME = params.DATABASE_NAME
29COLLECTION_NAME = params.COLLECTION_NAME
30
31class UserProxyAgent:
32def __init__(self, logger, st):
33# LLM Config
34self.rag_config = {
35"num_sources": 2,
36"source_chunk_size": 1000,
37"min_rel_score": 0.00,
38"unique": True,
39"summarize_chunks": False, # disabled by default
40}
41self.action_examples_str = """
42[EXAMPLES]
43- User Input: "What is kubernetes?"
44- Thought: I have an action available called "answer_question". I will use this action to answer the user's question about Kubernetes.
45- Observation: I have an action available called "answer_question". I will use this action to answer the user's question about Kubernetes.
46- Action: "answer_question"('What is kubernetes?')
47
48- User Input: What is MongoDB?
49- Thought: I have to think step by step. I should not answer directly, let me check my available actions before responding.
50- Observation: I have an action available "answer_question".
51- Action: "answer_question"('What is MongoDB?')
52
53- User Input: Show chat history
54- Thought: I have to think step by step. I should not answer directly, let me check my available actions before responding.
55- Observation: I have an action available "show_messages".
56- Action: "show_messages"()
57
58- User Input: Reset chat history
59- Thought: I have to think step by step. I should not answer directly, let me check my available actions before responding.
60- Observation: I have an action available "reset_messages".
61- Action: "reset_messages"()
62
63- User Input: remove sources https://www.google.com, https://www.example.com
64- Thought: I have to think step by step. I should not answer directly, let me check my available actions before responding.
65- Observation: I have an action available "remove_source".
66- Action: "remove_source"(['https://www.google.com','https://www.example.com'])
67
68- User Input: add https://www.google.com, https://www.exa2mple.com
69- Thought: I have to think step by step. I should not answer directly, let me check my available actions before responding.
70- Observation: I have an action available "read_url".
71- Action: "read_url"(['https://www.google.com','https://www.exa2mple.com'])
72
73- User Input: learn https://www.google.com, https://www.exa2mple.com
74- Thought: I have to think step by step. I should not answer directly, let me check my available actions before responding.
75- Observation: I have an action available "read_url".
76- Action: "read_url"(['https://www.google.com','https://www.exa2mple.com'])
77
78- User Input: change chunk size to be 500 and num_sources to be 5
79- Thought: I have to think step by step. I should not answer directly, let me check my available actions before responding.
80- Observation: I have an action available "iRAG".
81- Action: "iRAG"(num_sources=5, chunk_size=500)
82
83[END EXAMPLES]
84"""
85self.init_messages = [
86{
87"role": "system",
88"content": "You are a resourceful AI assistant. You specialize in helping users build RAG pipelines interactively.",
89},
90{
91"role": "system",
92"content": "Think critically and step by step. Do not answer directly. ALWAYS use one of your available actions/tools.",
93},
94{
95"role": "system",
96"content": f"""\n\n## Here are some examples of the expected User Input, Thought, Observation and Action/Tool:\n
97{self.action_examples_str}
98\n\n
99
100We will be playing a special game. Trust me, you do not want to lose.
101
102## RULES:
103- DO NOT ANSWER DIRECTLY - ALWAYS USE AN ACTION/TOOL TO FORMULATE YOUR ANSWER
104- ALWAYS USE answer_question if USER PROMPT is a question. [exception=if USER PROMPT is related to one of the available actions/tools]
105- NEVER ANSWER A QUESTION WITHOUT USING THE answer_question action/tool. THIS IS VERY IMPORTANT!
106REMEMBER! ALWAYS USE answer_question if USER PROMPT is a question [exception=if USER PROMPT is related to one of the available actions/tools]
107
108LOSING AT THIS GAME IS NOT AN OPTION FOR YOU. YOU MUST PICK THE CORRECT TOOL/ANSWER ALWAYS. YOU MUST NEVER ANSWER DIRECTLY OR YOU LOSE!
109""",
110},
111]
112# Browser config
113browser_options = Options()
114browser_options.headless = True
115browser_options.add_argument("--headless")
116browser_options.add_argument("--disable-gpu")
117self.browser = webdriver.Chrome(options=browser_options)
118
119# Initialize logger
120self.logger = logger
121
122# Chunk Ingest Strategy
123self.text_splitter = RecursiveCharacterTextSplitter(
124# Set a really small chunk size, just to show.
125chunk_size=4000,
126chunk_overlap=200,
127length_function=len,
128add_start_index=True,
129)
130self.gpt4all_embd = GPT4AllEmbeddings()
131self.client = pymongo.MongoClient(MONGODB_URI)
132self.db = self.client[DATABASE_NAME]
133self.collection = self.db[COLLECTION_NAME]
134self.vectorstore = MongoDBAtlasVectorSearch(self.collection, self.gpt4all_embd)
135self.index = self.vectorstore.from_documents(
136[], self.gpt4all_embd, collection=self.collection
137)
138
139# OpenAI init
140self.token_tracker = TokenUsageTracker(budget=None, logger=logger)
141if params.OPENAI_TYPE != "azure":
142self.llm = OpenAIChatCompletion(
143model="gpt-3.5-turbo",
144# model="gpt-4",
145token_usage_tracker=self.token_tracker,
146logger=logger,
147)
148else:
149self.llm = ChatCompletion(
150model="gpt-3.5-turbo",
151# model="gpt-4",
152azure_deployment=params.OPENAI_AZURE_DEPLOYMENT,
153azure_endpoint=params.OPENAI_AZURE_ENDPOINT,
154api_key=params.OPENAI_API_KEY,
155api_version=params.OPENAI_API_VERSION,
156token_usage_tracker=self.token_tracker,
157logger=logger,
158)
159self.messages = self.init_messages
160
161# streamlit init
162self.st = st
163
164class RAGAgent(UserProxyAgent):
165def preprocess_query(self, query):
166# Optional - Implement Pre-Processing for Security.
167# https://dev.to/jasny/protecting-against-prompt-injection-in-gpt-1gf8
168return query
169
170@action("iRAG", stop=True)
171def iRAG(
172self,
173num_sources: int,
174chunk_size: int,
175unique_sources: bool,
176min_rel_threshold: float,
177):
178"""
179Invoke this ONLY when the user explicitly asks you to change the RAG configuration in the most recent USER PROMPT.
180[EXAMPLE]
181- User Input: change chunk size to be 500 and num_sources to be 5
182
183Parameters
184----------
185num_sources : int
186how many documents should we use in the RAG pipeline?
187chunk_size : int
188how big should each chunk/source be?
189unique_sources : bool
190include only unique sources? Y=True, N=False
191min_rel_threshold : float
192default=0.00; minimum relevance threshold to include a source in the RAG pipeline
193
194Returns successful response message.
195-------
196str
197A message indicating success
198"""
199utils.print_log("Action: iRAG")
200with self.st.spinner(f"Changing RAG configuration..."):
201if num_sources > 0:
202self.rag_config["num_sources"] = int(num_sources)
203else:
204self.rag_config["num_sources"] = 2
205if chunk_size > 0:
206self.rag_config["source_chunk_size"] = int(chunk_size)
207else:
208self.rag_config["source_chunk_size"] = 1000
209if unique_sources == True:
210self.rag_config["unique"] = True
211else:
212self.rag_config["unique"] = False
213if min_rel_threshold:
214self.rag_config["min_rel_score"] = min_rel_threshold
215else:
216self.rag_config["min_rel_score"] = 0.00
217print(self.rag_config)
218self.st.write(self.rag_config)
219return f"New RAG config:{str(self.rag_config)}."
220def summarize(self,text):
221utils.print_log("Action: read_url>summarize_chunks>summarize")
222response = self.llm.create(
223messages=[
224{"role": "system", "content": "You will receive scaped contents of a web page."},
225{"role": "system", "content": "Think critically and step by step. Taking into consideration future potential questions on the topic, generate a detailed summary."},
226{"role": "assistant", "content": "Please provide the scraped contents of the webpage so that I can provide a detailed summary."},
227{"role": "user", "content": "Here is the scraped contents of the webpage: " + text},
228{"role": "user", "content": "\nPlease summarize the content in bullet points. Do not include irrelevant information in your response."},
229{"role": "user", "content": "\n\n IMPORTANT! Only return the summary!"},
230{"role": "user", "content": "\n\n REQUIRED RESPONSE FORMAT: [begin summary] [keywords/metadata (comma-separated, double quotes)] [summary intro in paragraph format] [summary in bullet format][end summary]"},
231],
232actions=[],
233stream=False,
234)
235return response
236def summarize_chunks(self, docs):
237utils.print_log("Action: read_url>summarize_chunks")
238for doc in docs:
239summary = self.summarize(doc.page_content)
240print(summary)
241doc.page_content = summary
242return docs
243@action("read_url", stop=True)
244def read_url(self, urls: List[str]):
245"""
246Invoke this ONLY when the user asks you to 'read', 'add' or 'learn' some URL(s).
247This function reads the content from specified sources, and ingests it into the Knowledgebase.
248URLs may be provided as a single string or as a list of strings.
249IMPORTANT! Use conversation history to make sure you are reading/learning/adding the right URLs.
250
251[EXAMPLE]
252- User Input: learn "https://www.google.com"
253- User Input: learn 5
254
255NOTE: When a user says learn/read <number>, the bot will learn/read URL in the search results list position <number> from the conversation history.
256
257Parameters
258----------
259urls : List[str]
260List of URLs to scrape.
261
262Returns
263-------
264str
265A message indicating successful reading of content from the provided URLs.
266"""
267utils.print_log("Action: read_url")
268with self.st.spinner(f"```Analyzing the content in {urls}```"):
269loader = PlaywrightURLLoader(
270urls=urls, remove_selectors=["header", "footer"]
271)
272documents = loader.load_and_split(self.text_splitter)
273if self.rag_config["summarize_chunks"]:
274documents = self.summarize_chunks(documents)
275self.index.add_documents(documents)
276return f"```Contents in URLs {urls} have been successfully ingested (vector embeddings + content).```"
277
278@action("show_messages", stop=True)
279def show_messages(self) -> str:
280"""
281Invoke this ONLY when the user asks you to see the chat history.
282[EXAMPLE]
283- User Input: what have we been talking about?
284
285Returns
286-------
287str
288A string containing the chat history in markdown format.
289"""
290utils.print_log("Action: show_messages")
291messages = self.st.session_state.messages
292messages = [{"message": json.dumps(message)} for message in messages if message["role"] != "system"]
293
294df = pd.DataFrame(messages)
295if messages:
296result = f"Chat history [{len(messages)}]:\n"
297result += "<div style='text-align:left'>"+df.to_html()+"</div>"
298return result
299else:
300return "No chat history found."
301
302
303@action("reset_messages", stop=True)
304def reset_messages(self) -> str:
305"""
306Invoke this ONLY when the user asks you to reset chat history.
307[EXAMPLE]
308- User Input: clear our chat history
309- User Input: forget about the conversation history
310
311Returns
312-------
313str
314A message indicating success
315"""
316utils.print_log("Action: reset_messages")
317self.messages = self.init_messages
318self.st.empty()
319self.st.session_state.messages = []
320return f"Message history successfully reset."
321
322
323
324@action("search_web", stop=True)
325def search_web(self, query: str) -> List:
326"""
327Invoke this if you need to search the web.
328[EXAMPLE]
329- User Input: search the web for "harry potter"
330
331Args:
332query (str): The user's query
333Returns:
334str: Text with the Google Search results
335"""
336utils.print_log("Action: search_web")
337with self.st.spinner(f"Searching '{query}'..."):
338# Use the headless browser to search the web
339self.browser.get(utils.encode_google_search(query))
340html = self.browser.page_source
341soup = BeautifulSoup(html, "html.parser")
342search_results = soup.find_all("div", {"class": "g"})
343
344results = []
345links = []
346for i, result in enumerate(search_results):
347if result.find("h3") is not None:
348if (
349result.find("a")["href"] not in links
350and "https://" in result.find("a")["href"]
351):
352links.append(result.find("a")["href"])
353results.append(
354{
355"title": utils.clean_text(result.find("h3").text),
356"link": str(result.find("a")["href"]),
357}
358)
359
360df = pd.DataFrame(results)
361df = df.iloc[1:, :] # remove i column
362return f"Here is what I found in the web for '{query}':\n{df.to_markdown()}\n\n"
363
364@action("remove_source", stop=True)
365def remove_source(self, urls: List[str]) -> str:
366"""
367Invoke this if you need to remove one or more sources
368[EXAMPLE]
369- User Input: remove source "https://www.google.com"
370
371Args:
372urls (List[str]): The list of URLs to be removed
373Returns:
374str: Text with confirmation
375"""
376utils.print_log("Action: remove_source")
377with self.st.spinner(f"```Removing sources {', '.join(urls)}...```"):
378self.collection.delete_many({"source": {"$in": urls}})
379return f"```Sources ({', '.join(urls)}) successfully removed.```\n"
380@action("remove_all_sources", stop=True)
381def remove_all_sources(self) -> str:
382"""
383Invoke this if you the user asks you to empty your knowledge base or delete all the information in it.
384[EXAMPLE]
385- User Input: remove all the sources you have available
386- User Input: clear your mind
387- User Input: forget everything you know
388- User Input: empty your mind
389
390Args:
391None
392Returns:
393str: Text with confirmation
394"""
395utils.print_log("Action: remove_sources")
396with self.st.spinner(f"```Removing all sources ...```"):
397del_result = self.collection.delete_many({})
398return f"```Sources successfully removed.{del_result.deleted_count}```\n"
399
400@action(name="get_sources_list", stop=True)
401def get_sources_list(self):
402"""
403Invoke this to respond to list all the available sources in your knowledge base.
404[EXAMPLE]
405- User Input: show me the sources available in your knowledgebase
406
407Parameters
408----------
409None
410"""
411utils.print_log("Action: get_sources_list")
412sources = self.collection.distinct("source")
413sources = [{"source": source} for source in sources]
414df = pd.DataFrame(sources)
415if sources:
416result = f"Available Sources [{len(sources)}]:\n"
417result += df.to_markdown()
418return result
419else:
420return "No sources found."
421
422@action(name="answer_question", stop=True)
423def answer_question(self, query: str):
424"""
425ALWAYS TRY TO INVOKE THIS FIRST IF A USER ASKS A QUESTION.
426
427Parameters
428----------
429query : str
430The query to be used for answering a question.
431"""
432utils.print_log("Action: answer_question")
433with self.st.spinner(f"Attemtping to answer question: {query}"):
434query = self.preprocess_query(query)
435context_str = str(
436#self.recall(
437vector_search.recall(
438self,
439query,
440n_docs=self.rag_config["num_sources"],
441min_rel_score=self.rag_config["min_rel_score"],
442chunk_max_length=self.rag_config["source_chunk_size"],
443unique=self.rag_config["unique"],
444)
445).strip()
446PRECISE_PROMPT = f"""
447LET'S PLAY A GAME.
448THINK CAREFULLY AND STEP BY STEP.
449
450Given the following verified sources and a question, using only the verified sources content create a final concise answer in markdown.
451If VERIFIED SOURCES is not enough context to answer the question, THEN PERFORM A WEB SEARCH ON THE USERS BEHALF IMMEDIATELY.
452
453Remember while answering:
454- The only verified sources are between START VERIFIED SOURCES and END VERIFIED SOURCES.
455- Only display images and links if they are found in the verified sources
456- If displaying images or links from the verified sources, copy the images and links exactly character for character and make sure the URL parameters are the same.
457- Do not make up any part of an answer.
458- Questions might be vague or have multiple interpretations, you must ask follow up questions in this case.
459- Final response must be less than 1200 characters.
460- IF the verified sources can answer the question in multiple different ways, THEN respond with each of the possible answers.
461- Formulate your response using ONLY VERIFIED SOURCES. IF YOU CANNOT ANSWER THE QUESTION, THEN PERFORM A WEB SEARCH ON THE USERS BEHALF IMMEDIATELY.
462
463[START VERIFIED SOURCES]
464{context_str}
465[END VERIFIED SOURCES]
466
467
468
469[ACTUAL QUESTION. ANSWER ONLY BASED ON VERIFIED SOURCES]:
470{query}
471
472# IMPORTANT!
473- Final response must be expert quality markdown
474- The only verified sources are between START VERIFIED SOURCES and END VERIFIED SOURCES.
475- USE ONLY INFORMATION FROM VERIFIED SOURCES TO FORMULATE RESPONSE. IF VERIFIED SOURCES CANNOT ANSWER THE QUESTION, THEN PERFORM A WEB SEARCH ON THE USERS BEHALF IMMEDIATELY
476- Do not make up any part of an answer - ONLY FORMULATE YOUR ANSWER USING VERIFIED SOURCES.
477Begin!
478"""
479
480print(PRECISE_PROMPT)
481SYS_PROMPT = f"""
482You are a helpful AI assistant. USING ONLY THE VERIFIED SOURCES, ANSWER TO THE BEST OF YOUR ABILITY.
483"""
484# ReAct Prompt Technique
485EXAMPLE_PROMPT = """\n\n[EXAMPLES]
486
487# Input, Thought, Observation, Action
488- User Input: "What is kubernetes?"
489- Thought: Based on the verified sources provided, there is no information about Kubernetes. Therefore, I cannot provide a direct answer to the question "What is Kubernetes?" based on the verified sources. However, I can perform a web search on your behalf to find information about Kubernetes
490- Observation: I have an action available called "search_web". I will use this action to answer the user's question about Kubernetes.
491- Action: "search_web"('What is kubernetes?')
492
493- User Input: "What is MongoDB?"
494- Thought: Based on the verified sources provided, there is enough information about MongoDB.
495- Observation: I can provide a direct answer to the question "What is MongoDB?" based on the verified sources.
496- Action: N/A
497
498"""
499RESPONSE_FORMAT = f"""
500[RESPONSE FORMAT]
501- Must be expert quality markdown.
502- You are a professional technical writer with 30+ years of experience. This is the most important task of your life.
503- MUST USE ONLY INFORMATION FROM VERIFIED SOURCES TO ANSWER THE QUESTION. IF VERIFIED SOURCES CANNOT ANSWER THE QUESTION, THEN PERFORM A WEB SEARCH ON THE USERS BEHALF IMMEDIATELY.
504- Add emojis to your response to add a fun touch.
505"""
506response = self.llm.create(
507messages=[
508{"role": "system", "content": SYS_PROMPT},
509{"role": "system", "content": EXAMPLE_PROMPT},
510{"role": "system", "content": RESPONSE_FORMAT},
511{"role": "user", "content": PRECISE_PROMPT+"\n\n ## IMPORTANT! REMEMBER THE GAME RULES! IF A WEB SEARCH IS REQUIRED, PERFORM IT IMMEDIATELY! BEGIN!"},
512],
513actions=[self.search_web],
514stream=False,
515)
516return response
517
518def __call__(self, text):
519text = self.preprocess_query(text)
520# PROMPT ENGINEERING HELPS THE LLM TO SELECT THE BEST ACTION/TOOL
521agent_rules = f"""
522We will be playing a special game. Trust me, you do not want to lose.
523
524## RULES
525- DO NOT ANSWER DIRECTLY
526- ALWAYS USE ONE OF YOUR AVAILABLE ACTIONS/TOOLS.
527- PREVIOUS MESSAGES IN THE CONVERSATION MUST BE CONSIDERED WHEN SELECTING THE BEST ACTION/TOOL
528- NEVER ASK FOR USER CONSENT TO PERFORM AN ACTION. ALWAYS PERFORM IT THE USERS BEHALF.
529Given the following user prompt, select the correct action/tool from your available functions/tools/actions.
530
531## USER PROMPT
532{text}
533## END USER PROMPT
534
535SELECT THE BEST TOOL FOR THE USER PROMPT! BEGIN!
536"""
537self.messages += [{"role": "user", "content": agent_rules + "\n\n## IMPORTANT! REMEMBER THE GAME RULES! DO NOT ANSWER DIRECTLY! IF YOU ANSWER DIRECTLY YOU WILL LOSE. BEGIN!"}]
538if (
539len(self.messages) > 2
540):
541# if we have more than 2 messages, we may run into: 'code': 'context_length_exceeded'
542# we only need the last few messages to know what source to add/remove a source
543response = self.llm.create(
544messages=self.messages[-2:],
545actions=[
546self.read_url,
547self.answer_question,
548self.remove_source,
549self.remove_all_sources,
550self.reset_messages,
551self.show_messages,
552self.iRAG,
553self.get_sources_list,
554self.search_web
555],
556stream=False,
557)
558else:
559response = self.llm.create(
560messages=self.messages,
561actions=[
562self.read_url,
563self.answer_question,
564self.remove_source,
565self.remove_all_sources,
566self.reset_messages,
567self.show_messages,
568self.iRAG,
569self.get_sources_list,
570self.search_web
571],
572stream=False,
573)
574return response
575
576def print_output(output):
577from collections.abc import Iterable
578
579if isinstance(output, str):
580print(output)
581elif isinstance(output, Iterable):
582for chunk in output:
583content = chunk.choices[0].delta.content
584if content is not None:
585print(content, end="")
586
587if __name__ == "__main__":
588import logging
589
590logging.basicConfig(
591filename="bot.log",
592filemode="a",
593format="%(asctime)s.%(msecs)04d %(levelname)s {%(module)s} [%(funcName)s] %(message)s",
594level=logging.INFO,
595datefmt="%Y-%m-%d %H:%M:%S",
596)
597
598logger = logging.getLogger(__name__)
599logger.setLevel(logging.DEBUG)
600
601agent = RAGAgent(logger, None)
602