diff --git a/common.py b/common.py index b6b4c0e1..4dc8fc7b 100644 --- a/common.py +++ b/common.py @@ -202,7 +202,7 @@ def aggregate_results( ) -def map_with_progress(f: callable, xs: list[Any], num_threads: int = 50): +def map_with_progress(f: callable, xs: list[Any], num_threads: int = 20): """ Apply f to each element of xs, using a ThreadPool, and show progress. """ diff --git a/sampler/deep_research.py b/sampler/deep_research.py new file mode 100644 index 00000000..98596d35 --- /dev/null +++ b/sampler/deep_research.py @@ -0,0 +1,433 @@ +import argparse +import io +import os +import re +import json +import functools +import logging +from enum import Enum +from datetime import datetime +from typing import Any, Dict, Optional, List, TypedDict + +import requests +import openai +from bs4 import BeautifulSoup +from dotenv import load_dotenv +from duckduckgo_search import DDGS +from tavily import TavilyClient +from markdown_pdf import MarkdownPdf, Section +import PyPDF2 + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Load UBICLOUD_API_KEY from .env file +load_dotenv() +UBICLOUD_API_KEY = os.getenv("UBICLOUD_API_KEY") +TAVILY_API_KEY = os.getenv("TAVILY_API_KEY") + +# Model configurations +SUMMARIZATION_MODEL = "ds-r1-qwen-32b" +SUMMARIZATION_CONTENT_CUTOFF = 40000 +REASONING_MODEL = "ds-r1-qwen-32b" +WRITING_MODEL = "ds-r1-qwen-32b" +JSON_MODEL = "ds-r1-qwen-32b" + + +class InferenceMode(Enum): + """Enum for different inference modes.""" + SUMMARIZATION = 1 + REASONING = 2 + WRITING = 3 + JSON = 4 + + def get_model(self) -> str: + """Retrieve the corresponding model name for the inference mode.""" + return globals().get(f"{self.name}_MODEL") + + +@functools.lru_cache(maxsize=128) +def get_search_client(search_engine: str): + if search_engine == "duckduckgo": + return DDGS() + elif search_engine == "tavily": + return TavilyClient(TAVILY_API_KEY) + raise Exception("Unsupported search engine") + + +@functools.lru_cache(maxsize=128) +def get_inference_client(model: str) -> openai.OpenAI: + """Get the inference client for the specified model.""" + base_url = f"https://{model}.ai.ubicloud.com/v1/" + return openai.OpenAI(api_key=UBICLOUD_API_KEY, base_url=base_url) + + +def extract_json(content: str) -> Optional[Dict[str, Any]]: + """Look for code block (```...```) and parse as JSON.""" + match = re.search(r"```(?:\w+)?\s*(.*?)\s*```", content, re.DOTALL) + if match: + json_str = match.group(1).strip() + try: + return json.loads(json_str) + except json.JSONDecodeError as e: + logger.error("JSON decoding failed: %s", e) + return None + return None + + +def inference(messages: List[Dict[str, str]], mode: InferenceMode) -> Any: + """Perform inference using the specified messages and inference mode.""" + model = mode.get_model() + inference_client = get_inference_client(model) + params = { + "model": model, + "messages": messages, + } + logger.debug(json.dumps(params, indent=2)) + completion = inference_client.chat.completions.create( + model=model, + messages=messages, + ) + content = completion.choices[0].message.content + logger.debug(content) + # Remove everything before the from the content + content = re.sub(r'.*?', '', content, flags=re.DOTALL).strip() + if mode == InferenceMode.JSON: + return extract_json(content) + return content + + +def create_system_message(content: str) -> Dict[str, str]: + """Create a structured system message for chat completions.""" + return {"role": "system", "content": content} + + +def create_user_message(content: Any) -> Dict[str, str]: + """Create a structured user message for chat completions.""" + if isinstance(content, dict): + # Filter out keys that start with '_' + content_str = json.dumps({ + key: value for key, value in content.items() if not key.startswith("_") + }, indent=2) + else: + content_str = str(content) + return {"role": "user", "content": content_str} + + +@functools.lru_cache(maxsize=128) +def search(query: str, search_engine: str) -> List[Dict[str, Any]]: + """Search for a query on the web and return the top results.""" + truncated_query = query[:200] + logger.info(f"Searching for \"{truncated_query}\"") + client = get_search_client(search_engine) + if search_engine == "duckduckgo": + search_results = client.text(truncated_query, max_results=5) + elif search_engine == "tavily": + search_results = client.search(query=truncated_query)["results"] + logger.debug(json.dumps(search_results, indent=2)) + return search_results + + +def read_pdf(pdf_file: io.BytesIO) -> str: + """Extract text from a PDF file.""" + reader = PyPDF2.PdfReader(pdf_file) + return "".join(page.extract_text() or "" for page in reader.pages) + + +@functools.lru_cache(maxsize=128) +def fetch_url(url: str) -> Optional[str]: + """Fetch content from a URL.""" + HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) + } + try: + response = requests.get(url, headers=HEADERS, timeout=30) + if response.status_code != 200: + logger.error( + f"Error fetching URL: {url} returned {response.status_code}") + return None + content_type = response.headers.get("Content-Type", "").lower() + if "application/pdf" in content_type or url.lower().endswith(".pdf"): + pdf_file = io.BytesIO(response.content) + return read_pdf(pdf_file) + soup = BeautifulSoup(response.content, 'html.parser') + return soup.get_text() + except Exception as e: + logger.error(f"Error fetching URL: {url}. Exception: {e}") + return None + + +class ResearchState(TypedDict): + """Represents the research state.""" + topic: str + search_results: List[Dict[str, Any]] + outline: str + report: str + _visited: List[str] + + +@functools.lru_cache(maxsize=128) +def summarize(title: str, content: str) -> Optional[str]: + """Summarize the given content based on the provided title.""" + try: + cutoff = SUMMARIZATION_CONTENT_CUTOFF + chunks = [content[i:i+cutoff] for i in range(0, len(content), cutoff)] + summaries = [] + for chunk in chunks: + summary = inference([ + create_system_message("""Summarize the content. + Only include information that is relevant to the title. + Be succinct. Summarize directly, don't repeat the title."""), + create_user_message({ + "title": title, + "content": chunk + }) + ], mode=InferenceMode.SUMMARIZATION) + summaries.append(summary) + return "\n\n".join(summaries) + except Exception as e: + logger.error(f"Error summarizing title '{title}': {e}") + return None + + +def fetch_and_summarize(topic: str, search_result: Dict[str, Any]) -> Optional[str]: + """Fetch and summarize content from a search result URL.""" + title = search_result.get("title", "No Title") + url = search_result.get("url") + logger.info( + f"Reading \"{title}\" with {InferenceMode.SUMMARIZATION.get_model()}") + content = fetch_url(url) + if not content: + logger.warning(f"No content fetched from URL: {url}") + return None + return summarize(topic, content) + + +def gather_information(topic: str, state: ResearchState, search_engine: str) -> ResearchState: + """Gather and update research information about a topic.""" + logger.info(f"Gathering information about \"{topic}\"") + raw_search_results = search(topic, search_engine) + search_results: List[Dict[str, Any]] = state.get("search_results", []) + visited: List[str] = state.get("_visited", []) + + for raw_search_result in raw_search_results: + # Some search engine stores the url as href. We copy href to url. + url = raw_search_result.get("href") or raw_search_result.get("url") + raw_search_result["url"] = url + if url in visited: + continue + visited.append(url) + summary = fetch_and_summarize(topic, raw_search_result) + if summary: + search_results.append({ + "title": raw_search_result.get("title", "No Title"), + "url": url, + "summary": summary, + "index": len(search_results) + 1 + }) + logger.debug(str(fetch_url.cache_info())) + updated_state: ResearchState = { + **state, + "search_results": search_results, + "_visited": visited + } + return updated_state + + +def thinking(state: ResearchState) -> ResearchState: + """Think about the topic.""" + logger.info( + f"Thinking about \"{state['topic']}\" with {InferenceMode.REASONING.get_model()}" + ) + # Remove any existing reasoning keys to ensure a fresh response + state.pop("thinking", None) + state.pop("outline", None) + state.pop("report", None) + thinking = inference([ + create_system_message(""" +Take a deep breath, go through the search results, think through the topic step by step, and provide a well-reasoned answer. +Use information from the search results as needed, prioritizing trustworthy sources, such as wiki, gov, bookforum, or fandom. Ignore unrelated or unreliable sources."""), + # Be objective, reasonable, and comprehensive."""), + create_user_message(state) + ], mode=InferenceMode.REASONING) + return {**state, "thinking": thinking} + + +def deep_dive(state: ResearchState, search_engine: str) -> ResearchState: + """Deep dive into a few areas.""" + subtopics = inference([ + create_system_message(""" +Generate 1 to 2 search queries on the given topic. +Return a JSON array of strings. +Each string should be a well-structured search engine query. +At least one of them should explicitly ask for trustworthy sources, such as wiki, gov, bookforum, or fandom."""), + create_user_message({ + "topic": state["topic"], + "thinking": state.get("thinking", "") + }) + ], InferenceMode.JSON) + if subtopics is None: + logger.warning("No JSON object found. Retrying deep_dive...") + return deep_dive(state, search_engine) + for subtopic in subtopics: + state = gather_information(subtopic, state, search_engine) + return state + + +def create_outline(state: ResearchState) -> ResearchState: + """Create an outline of the report.""" + logger.info( + f"Creating an outline of the report on \"{state['topic']}\" with {InferenceMode.REASONING.get_model()}" + ) + state.pop("outline", None) + state.pop("report", None) + outline = inference([ + create_system_message(""" +Generate an outline of a professional report on the given topic and thinking. +Think step by step. Use search results as needed."""), + create_user_message(state) + ], InferenceMode.REASONING) + return {**state, "outline": outline} + +def write_answer(state: ResearchState) -> ResearchState: + """Write out the answer.""" + logger.info( + f"Generate the answer on \"{state['topic']}\" with {InferenceMode.REASONING.get_model()}" + ) + state.pop("answer", None) + state.pop("outline", None) + state.pop("report", None) + outline = inference([ + create_system_message(""" +Using the provided topic question, search results, and thinking, generate a clear, accurate, and concise answer for the topic question. +For example: +What is the month, day, and year that ChatGPT is initially released? +Answer: November, 30, 2022 + +Who was the other founder of Google besides Larry Page? +Answer: Sergey Brin +"""), + create_user_message(state) + ], InferenceMode.REASONING) + return {**state, "answer": outline} + + +def write_report(state: ResearchState) -> ResearchState: + """Write the report.""" + logger.info( + f"Writing a report on \"{state['topic']}\" with {InferenceMode.WRITING.get_model()}" + ) + state.pop("report", None) + report = inference([ + create_system_message( + """Generate an extremely detailed professional report based on the provided topic, thinking, and outline. +Use search results as needed. +Ensure it is well-organized and each section is well-developed. +Use subsections and lists as needed. +Include the topic as the title. Include an executive summary at the beginning. +Refer to search results by their index as needed. Do not include the list of references at the end. +Use heading level 1 for the title. +Do not include figures."""), + create_user_message(state) + ], InferenceMode.WRITING) + references = [ + f"1. {result["title"]}. (n.d.). Retrieved from {result['url']}" + for result in state["search_results"]] + report += "\n\n## References\n\n" + \ + "\n".join(references) + "\n\nUbicloud AI" + return {**state, "report": report} + + +CUSTOM_MARKDOWN_PDF_CSS = """ +body { font-family: sans-serif; line-height: 1.5; margin: 1em; color: #333; } +h1, h2, h3 { color: #2c3e50; margin: 1em 0 0.5em; } +h1 { font-size: 2em; border-bottom: 1px solid #ccc; padding-bottom: 0.2em; } +p { margin: 0.8em 0; } +code, pre code { font-family: monospace; background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; } +pre code { background: #2d2d2d; color: #fff; padding: 1em; overflow-x: auto; } +blockquote { border-left: 4px solid #ddd; padding-left: 1em; color: #666; margin: 1em 0; } +ul, ol { margin: 0.8em 0; padding-left: 1.2em; } +table { width: 100%; border-collapse: collapse; margin: 1em 0; } +th, td { border: 1px solid #ddd; padding: 0.5em; } +th { background: #f9f9f9; } +""" + + +def save_pdf(topic: str, report: str, timestamp: str) -> None: + """Save the generated report as a PDF file with a custom CSS style.""" + pdf = MarkdownPdf(toc_level=2) + pdf.add_section(Section(report), user_css=CUSTOM_MARKDOWN_PDF_CSS) + pdf.meta["title"] = topic + filename = f"{topic}_{timestamp}.pdf" + pdf.save(filename) + logger.info(f"Saved PDF: {filename}") + + +def save_state_json(topic: str, state: dict, timestamp: str) -> None: + """Save the research state as a JSON file.""" + filename = f"{topic}_{timestamp}.json" + with open(filename, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + logger.info(f"Saved state JSON: {filename}") + + +def deep_research( + topic: str, depth: int, search_engine: str, initial_state: Optional[dict], debug: bool) -> None: + """Conduct deep research on a given topic and generate a PDF report""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + state = initial_state or {} + state["topic"] = topic + # state = gather_information(topic, state, search_engine) + # state = thinking(state) + # save_state_json(topic, state, timestamp) + for _ in range(depth): + state = deep_dive(state, search_engine) + state = thinking(state) + if debug: + save_state_json(topic, state, timestamp) + # state = create_outline(state) + state = write_answer(state) + if debug: + save_state_json(topic, state, timestamp) + return state["answer"] + # state = write_report(state) + # return state["report"] + # save_pdf(topic, state["report"], timestamp) + # save_state_json(topic, state, timestamp) + + +def main() -> None: + """Parse command-line arguments and start the deep research process.""" + parser = argparse.ArgumentParser( + description="Perform deep research on a given topic." + ) + parser.add_argument("topic", type=str, help="The topic to research") + parser.add_argument("--depth", type=int, default=3, + help="The depth of the research") + parser.add_argument("--resume", type=str, + help="Path to a JSON file to resume state from") + parser.add_argument("--search_engine", type=str, default="duckduckgo", + help="The search engine to use, either DuckDuckGo or Tavily.") + parser.add_argument("--debug", type=bool, default=False, help="Run in debug mode") + args = parser.parse_args() + + initial_state: Optional[dict] = None + if args.resume: + try: + with open(args.resume, "r", encoding="utf-8") as f: + initial_state = json.load(f) + logger.info(f"Resumed state loaded from {args.resume}") + except Exception as e: + logger.error( + f"Failed to load resume state from {args.resume}: {e}") + + answer = deep_research(args.topic, args.depth, args.search_engine, initial_state, args.debug) + logger.info(f"Answer: {answer}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sampler/deep_research_sampler.py b/sampler/deep_research_sampler.py new file mode 100644 index 00000000..7f85eceb --- /dev/null +++ b/sampler/deep_research_sampler.py @@ -0,0 +1,80 @@ +import base64 +import time +from typing import Any +import os + +import openai +from openai import OpenAI + +from ..types import MessageList, SamplerBase +from . import deep_research + +OPENAI_SYSTEM_MESSAGE_API = "You are a helpful assistant." +OPENAI_SYSTEM_MESSAGE_CHATGPT = ( + "You are ChatGPT, a large language model trained by OpenAI, based on the GPT-4 architecture." + + "\nKnowledge cutoff: 2023-12\nCurrent date: 2024-04-01" +) + + +class DeepRsearchCompletionSampler(SamplerBase): + """ + Sample from OpenAI's chat completion API + """ + + def __init__( + self, + model: str = "llama-3-3-70b-turbo", + system_message: str | None = None, + temperature: float = 0.5, + max_tokens: int = 1024, + depth: int = 3, + ): + self.api_key_name = "OPENAI_API_KEY" + self.client = OpenAI(base_url=f"https://{model}.ai.ubicloud.com/v1/") + # using api_key=os.environ.get("OPENAI_API_KEY") # please set your API_KEY + self.model = model + self.system_message = system_message + self.temperature = temperature + self.max_tokens = max_tokens + self.image_format = "url" + self.depth = depth + + def _handle_image( + self, image: str, encoding: str = "base64", format: str = "png", fovea: int = 768 + ): + new_image = { + "type": "image_url", + "image_url": { + "url": f"data:image/{format};{encoding},{image}", + }, + } + return new_image + + def _handle_text(self, text: str): + return {"type": "text", "text": text} + + def _pack_message(self, role: str, content: Any): + return {"role": str(role), "content": content} + + def __call__(self, message_list: MessageList) -> str: + if self.system_message: + message_list = [self._pack_message("system", self.system_message)] + message_list + trial = 0 + while True: + try: + print("message_list", message_list) + return deep_research.deep_research( + message_list[0]["content"], depth=self.depth, search_engine="tavily", initial_state=None, debug=False) + # NOTE: BadRequestError is triggered once for MMMU, please uncomment if you are reruning MMMU + except openai.BadRequestError as e: + print("Bad Request Error", e) + return "" + except Exception as e: + exception_backoff = 2**trial # expontial back off + print( + f"Rate limit exception so wait and retry {trial} after {exception_backoff} sec", + e, + ) + time.sleep(exception_backoff) + trial += 1 + # unknown error shall throw exception diff --git a/sampler/ubicloud_sampler.py b/sampler/ubicloud_sampler.py new file mode 100644 index 00000000..f5b45a4f --- /dev/null +++ b/sampler/ubicloud_sampler.py @@ -0,0 +1,82 @@ +import base64 +import time +from typing import Any +import os + +import openai +from openai import OpenAI + +from ..types import MessageList, SamplerBase + +OPENAI_SYSTEM_MESSAGE_API = "You are a helpful assistant." +OPENAI_SYSTEM_MESSAGE_CHATGPT = ( + "You are ChatGPT, a large language model trained by OpenAI, based on the GPT-4 architecture." + + "\nKnowledge cutoff: 2023-12\nCurrent date: 2024-04-01" +) + + +class UbicloudCompletionSampler(SamplerBase): + """ + Sample from OpenAI's chat completion API + """ + + def __init__( + self, + model: str = "llama-3-3-70b-turbo", + system_message: str | None = None, + temperature: float = 0.5, + max_tokens: int = 1024, + ): + self.api_key_name = "OPENAI_API_KEY" + self.client = OpenAI(base_url=f"https://{model}.ai.ubicloud.com/v1/") + # using api_key=os.environ.get("OPENAI_API_KEY") # please set your API_KEY + self.model = model + self.system_message = system_message + self.temperature = temperature + self.max_tokens = max_tokens + self.image_format = "url" + + def _handle_image( + self, image: str, encoding: str = "base64", format: str = "png", fovea: int = 768 + ): + new_image = { + "type": "image_url", + "image_url": { + "url": f"data:image/{format};{encoding},{image}", + }, + } + return new_image + + def _handle_text(self, text: str): + return {"type": "text", "text": text} + + def _pack_message(self, role: str, content: Any): + return {"role": str(role), "content": content} + + def __call__(self, message_list: MessageList) -> str: + if self.system_message: + message_list = [self._pack_message("system", self.system_message)] + message_list + trial = 0 + while True: + try: + # print("message_list", message_list) + response = self.client.chat.completions.create( + model=self.model, + messages=message_list, + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + return response.choices[0].message.content + # NOTE: BadRequestError is triggered once for MMMU, please uncomment if you are reruning MMMU + except openai.BadRequestError as e: + print("Bad Request Error", e) + return "" + except Exception as e: + exception_backoff = 2**trial # expontial back off + print( + f"Rate limit exception so wait and retry {trial} after {exception_backoff} sec", + e, + ) + time.sleep(exception_backoff) + trial += 1 + # unknown error shall throw exception diff --git a/simple_evals.py b/simple_evals.py index ceaa8c2a..771ec205 100644 --- a/simple_evals.py +++ b/simple_evals.py @@ -16,7 +16,8 @@ ) from .sampler.o_chat_completion_sampler import OChatCompletionSampler from .sampler.claude_sampler import ClaudeCompletionSampler, CLAUDE_SYSTEM_MESSAGE_LMSYS - +from .sampler.ubicloud_sampler import UbicloudCompletionSampler +from .sampler.deep_research_sampler import DeepRsearchCompletionSampler def main(): parser = argparse.ArgumentParser( @@ -99,6 +100,22 @@ def main(): model="claude-3-opus-20240229", system_message=CLAUDE_SYSTEM_MESSAGE_LMSYS, ), + # ubicloud models: + "llama-3-3-70b-turbo": UbicloudCompletionSampler( + model="llama-3-3-70b-turbo" + ), + "deep-research-0": DeepRsearchCompletionSampler( + depth=0 + ), + "deep-research-1": DeepRsearchCompletionSampler( + depth=1 + ), + "deep-research-2": DeepRsearchCompletionSampler( + depth=2 + ), + "deep-research-3": DeepRsearchCompletionSampler( + depth=3 + ), } if args.list_models: @@ -113,7 +130,7 @@ def main(): return models = {args.model: models[args.model]} - grading_sampler = ChatCompletionSampler(model="gpt-4o") + grading_sampler = UbicloudCompletionSampler(model="llama-3-3-70b-turbo") equality_checker = ChatCompletionSampler(model="gpt-4-turbo-preview") # ^^^ used for fuzzy matching, just for math @@ -154,7 +171,8 @@ def get_evals(eval_name, debug_mode): evals = { eval_name: get_evals(eval_name, args.debug) - for eval_name in ["simpleqa", "mmlu", "math", "gpqa", "mgsm", "drop", "humaneval"] + # for eval_name in ["simpleqa", "mmlu", "math", "gpqa", "mgsm", "drop", "humaneval"] + for eval_name in ["simpleqa"] } print(evals) debug_suffix = "_DEBUG" if args.debug else "" diff --git a/simpleqa_eval.py b/simpleqa_eval.py index 7ec3640b..49f2aaf9 100644 --- a/simpleqa_eval.py +++ b/simpleqa_eval.py @@ -7,6 +7,8 @@ import random import re import pandas +import requests +from io import StringIO from . import common from .types import Eval, EvalResult, SamplerBase, SingleEvalResult @@ -98,9 +100,17 @@ class SimpleQAEval(Eval): def __init__(self, grader_model: SamplerBase, num_examples: int | None = None, n_repeats: int = 1): - df = pandas.read_csv( - f"https://openaipublic.blob.core.windows.net/simple-evals/simple_qa_test_set.csv" - ) + # df = pandas.read_csv( + # f"https://openaipublic.blob.core.windows.net/simple-evals/simple_qa_test_set.csv", + # storage_options={'http_timeout': 60} + # ) + url = "https://openaipublic.blob.core.windows.net/simple-evals/simple_qa_test_set.csv" + response = requests.get(url, timeout=60) + response.raise_for_status() # Raise an error on a bad status + + # Use StringIO to simulate a file for pandas + data = StringIO(response.text) + df = pandas.read_csv(data) examples = [row.to_dict() for _, row in df.iterrows()] if num_examples: assert n_repeats == 1, "n_repeats only supported when max_examples = None"