Files
stanford-oval--storm/knowledge_storm/storm_wiki/engine.py
T
2026-07-13 12:38:14 +08:00

442 lines
18 KiB
Python

import json
import logging
import os
from dataclasses import dataclass, field
from typing import Union, Literal, Optional
import dspy
from .modules.article_generation import StormArticleGenerationModule
from .modules.article_polish import StormArticlePolishingModule
from .modules.callback import BaseCallbackHandler
from .modules.knowledge_curation import StormKnowledgeCurationModule
from .modules.outline_generation import StormOutlineGenerationModule
from .modules.persona_generator import StormPersonaGenerator
from .modules.storm_dataclass import StormInformationTable, StormArticle
from ..interface import Engine, LMConfigs, Retriever
from ..lm import LitellmModel
from ..utils import FileIOHelper, makeStringRed, truncate_filename
class STORMWikiLMConfigs(LMConfigs):
"""Configurations for LLM used in different parts of STORM.
Given that different parts in STORM framework have different complexity, we use different LLM configurations
to achieve a balance between quality and efficiency. If no specific configuration is provided, we use the default
setup in the paper.
"""
def __init__(self):
self.conv_simulator_lm = (
None # LLM used in conversation simulator except for question asking.
)
self.question_asker_lm = None # LLM used in question asking.
self.outline_gen_lm = None # LLM used in outline generation.
self.article_gen_lm = None # LLM used in article generation.
self.article_polish_lm = None # LLM used in article polishing.
def init_openai_model(
self,
openai_api_key: str,
azure_api_key: str,
openai_type: Literal["openai", "azure"],
api_base: Optional[str] = None,
api_version: Optional[str] = None,
temperature: Optional[float] = 1.0,
top_p: Optional[float] = 0.9,
):
"""Legacy: Corresponding to the original setup in the NAACL'24 paper."""
azure_kwargs = {
"api_key": azure_api_key,
"temperature": temperature,
"top_p": top_p,
"api_base": api_base,
"api_version": api_version,
}
openai_kwargs = {
"api_key": openai_api_key,
"temperature": temperature,
"top_p": top_p,
"api_base": None,
}
if openai_type and openai_type == "openai":
self.conv_simulator_lm = LitellmModel(
model="gpt-4o-mini-2024-07-18", max_tokens=500, **openai_kwargs
)
self.question_asker_lm = LitellmModel(
model="gpt-4o-mini-2024-07-18", max_tokens=500, **openai_kwargs
)
# 1/12/2024: Update gpt-4 to gpt-4-1106-preview. (Currently keep the original setup when using azure.)
self.outline_gen_lm = LitellmModel(
model="gpt-4-0125-preview", max_tokens=400, **openai_kwargs
)
self.article_gen_lm = LitellmModel(
model="gpt-4o-2024-05-13", max_tokens=700, **openai_kwargs
)
self.article_polish_lm = LitellmModel(
model="gpt-4o-2024-05-13", max_tokens=4000, **openai_kwargs
)
elif openai_type and openai_type == "azure":
self.conv_simulator_lm = LitellmModel(
model="azure/gpt-4o-mini-2024-07-18", max_tokens=500, **openai_kwargs
)
self.question_asker_lm = LitellmModel(
model="azure/gpt-4o-mini-2024-07-18",
max_tokens=500,
**azure_kwargs,
model_type="chat",
)
# use combination of openai and azure-openai as azure-openai does not support gpt-4 in standard deployment
self.outline_gen_lm = LitellmModel(
model="azure/gpt-4o", max_tokens=400, **azure_kwargs, model_type="chat"
)
self.article_gen_lm = LitellmModel(
model="azure/gpt-4o-mini-2024-07-18",
max_tokens=700,
**azure_kwargs,
model_type="chat",
)
self.article_polish_lm = LitellmModel(
model="azure/gpt-4o-mini-2024-07-18",
max_tokens=4000,
**azure_kwargs,
model_type="chat",
)
else:
logging.warning(
"No valid OpenAI API provider is provided. Cannot use default LLM configurations."
)
def set_conv_simulator_lm(self, model: Union[dspy.dsp.LM, dspy.dsp.HFModel]):
self.conv_simulator_lm = model
def set_question_asker_lm(self, model: Union[dspy.dsp.LM, dspy.dsp.HFModel]):
self.question_asker_lm = model
def set_outline_gen_lm(self, model: Union[dspy.dsp.LM, dspy.dsp.HFModel]):
self.outline_gen_lm = model
def set_article_gen_lm(self, model: Union[dspy.dsp.LM, dspy.dsp.HFModel]):
self.article_gen_lm = model
def set_article_polish_lm(self, model: Union[dspy.dsp.LM, dspy.dsp.HFModel]):
self.article_polish_lm = model
@dataclass
class STORMWikiRunnerArguments:
"""Arguments for controlling the STORM Wiki pipeline."""
output_dir: str = field(
metadata={"help": "Output directory for the results."},
)
max_conv_turn: int = field(
default=3,
metadata={
"help": "Maximum number of questions in conversational question asking."
},
)
max_perspective: int = field(
default=3,
metadata={
"help": "Maximum number of perspectives to consider in perspective-guided question asking."
},
)
max_search_queries_per_turn: int = field(
default=3,
metadata={"help": "Maximum number of search queries to consider in each turn."},
)
disable_perspective: bool = field(
default=False,
metadata={"help": "If True, disable perspective-guided question asking."},
)
search_top_k: int = field(
default=3,
metadata={"help": "Top k search results to consider for each search query."},
)
retrieve_top_k: int = field(
default=3,
metadata={"help": "Top k collected references for each section title."},
)
max_thread_num: int = field(
default=10,
metadata={
"help": "Maximum number of threads to use. "
"Consider reducing it if keep getting 'Exceed rate limit' error when calling LM API."
},
)
class STORMWikiRunner(Engine):
"""STORM Wiki pipeline runner."""
def __init__(
self, args: STORMWikiRunnerArguments, lm_configs: STORMWikiLMConfigs, rm
):
super().__init__(lm_configs=lm_configs)
self.args = args
self.lm_configs = lm_configs
self.retriever = Retriever(rm=rm, max_thread=self.args.max_thread_num)
storm_persona_generator = StormPersonaGenerator(
self.lm_configs.question_asker_lm
)
self.storm_knowledge_curation_module = StormKnowledgeCurationModule(
retriever=self.retriever,
persona_generator=storm_persona_generator,
conv_simulator_lm=self.lm_configs.conv_simulator_lm,
question_asker_lm=self.lm_configs.question_asker_lm,
max_search_queries_per_turn=self.args.max_search_queries_per_turn,
search_top_k=self.args.search_top_k,
max_conv_turn=self.args.max_conv_turn,
max_thread_num=self.args.max_thread_num,
)
self.storm_outline_generation_module = StormOutlineGenerationModule(
outline_gen_lm=self.lm_configs.outline_gen_lm
)
self.storm_article_generation = StormArticleGenerationModule(
article_gen_lm=self.lm_configs.article_gen_lm,
retrieve_top_k=self.args.retrieve_top_k,
max_thread_num=self.args.max_thread_num,
)
self.storm_article_polishing_module = StormArticlePolishingModule(
article_gen_lm=self.lm_configs.article_gen_lm,
article_polish_lm=self.lm_configs.article_polish_lm,
)
self.lm_configs.init_check()
self.apply_decorators()
def run_knowledge_curation_module(
self,
ground_truth_url: str = "None",
callback_handler: BaseCallbackHandler = None,
) -> StormInformationTable:
(
information_table,
conversation_log,
) = self.storm_knowledge_curation_module.research(
topic=self.topic,
ground_truth_url=ground_truth_url,
callback_handler=callback_handler,
max_perspective=self.args.max_perspective,
disable_perspective=False,
return_conversation_log=True,
)
FileIOHelper.dump_json(
conversation_log,
os.path.join(self.article_output_dir, "conversation_log.json"),
)
information_table.dump_url_to_info(
os.path.join(self.article_output_dir, "raw_search_results.json")
)
return information_table
def run_outline_generation_module(
self,
information_table: StormInformationTable,
callback_handler: BaseCallbackHandler = None,
) -> StormArticle:
outline, draft_outline = self.storm_outline_generation_module.generate_outline(
topic=self.topic,
information_table=information_table,
return_draft_outline=True,
callback_handler=callback_handler,
)
outline.dump_outline_to_file(
os.path.join(self.article_output_dir, "storm_gen_outline.txt")
)
draft_outline.dump_outline_to_file(
os.path.join(self.article_output_dir, "direct_gen_outline.txt")
)
return outline
def run_article_generation_module(
self,
outline: StormArticle,
information_table=StormInformationTable,
callback_handler: BaseCallbackHandler = None,
) -> StormArticle:
draft_article = self.storm_article_generation.generate_article(
topic=self.topic,
information_table=information_table,
article_with_outline=outline,
callback_handler=callback_handler,
)
draft_article.dump_article_as_plain_text(
os.path.join(self.article_output_dir, "storm_gen_article.txt")
)
draft_article.dump_reference_to_file(
os.path.join(self.article_output_dir, "url_to_info.json")
)
return draft_article
def run_article_polishing_module(
self, draft_article: StormArticle, remove_duplicate: bool = False
) -> StormArticle:
polished_article = self.storm_article_polishing_module.polish_article(
topic=self.topic,
draft_article=draft_article,
remove_duplicate=remove_duplicate,
)
FileIOHelper.write_str(
polished_article.to_string(),
os.path.join(self.article_output_dir, "storm_gen_article_polished.txt"),
)
return polished_article
def post_run(self):
"""
Post-run operations, including:
1. Dumping the run configuration.
2. Dumping the LLM call history.
"""
config_log = self.lm_configs.log()
FileIOHelper.dump_json(
config_log, os.path.join(self.article_output_dir, "run_config.json")
)
llm_call_history = self.lm_configs.collect_and_reset_lm_history()
with open(
os.path.join(self.article_output_dir, "llm_call_history.jsonl"), "w"
) as f:
for call in llm_call_history:
if "kwargs" in call:
call.pop(
"kwargs"
) # All kwargs are dumped together to run_config.json.
f.write(json.dumps(call) + "\n")
def _load_information_table_from_local_fs(self, information_table_local_path):
assert os.path.exists(information_table_local_path), makeStringRed(
f"{information_table_local_path} not exists. Please set --do-research argument to prepare the conversation_log.json for this topic."
)
return StormInformationTable.from_conversation_log_file(
information_table_local_path
)
def _load_outline_from_local_fs(self, topic, outline_local_path):
assert os.path.exists(outline_local_path), makeStringRed(
f"{outline_local_path} not exists. Please set --do-generate-outline argument to prepare the storm_gen_outline.txt for this topic."
)
return StormArticle.from_outline_file(topic=topic, file_path=outline_local_path)
def _load_draft_article_from_local_fs(
self, topic, draft_article_path, url_to_info_path
):
assert os.path.exists(draft_article_path), makeStringRed(
f"{draft_article_path} not exists. Please set --do-generate-article argument to prepare the storm_gen_article.txt for this topic."
)
assert os.path.exists(url_to_info_path), makeStringRed(
f"{url_to_info_path} not exists. Please set --do-generate-article argument to prepare the url_to_info.json for this topic."
)
article_text = FileIOHelper.load_str(draft_article_path)
references = FileIOHelper.load_json(url_to_info_path)
return StormArticle.from_string(
topic_name=topic, article_text=article_text, references=references
)
def run(
self,
topic: str,
ground_truth_url: str = "",
do_research: bool = True,
do_generate_outline: bool = True,
do_generate_article: bool = True,
do_polish_article: bool = True,
remove_duplicate: bool = False,
callback_handler: BaseCallbackHandler = BaseCallbackHandler(),
):
"""
Run the STORM pipeline.
Args:
topic: The topic to research.
ground_truth_url: A ground truth URL including a curated article about the topic. The URL will be excluded.
do_research: If True, research the topic through information-seeking conversation;
if False, expect conversation_log.json and raw_search_results.json to exist in the output directory.
do_generate_outline: If True, generate an outline for the topic;
if False, expect storm_gen_outline.txt to exist in the output directory.
do_generate_article: If True, generate a curated article for the topic;
if False, expect storm_gen_article.txt to exist in the output directory.
do_polish_article: If True, polish the article by adding a summarization section and (optionally) removing
duplicated content.
remove_duplicate: If True, remove duplicated content.
callback_handler: A callback handler to handle the intermediate results.
"""
assert (
do_research
or do_generate_outline
or do_generate_article
or do_polish_article
), makeStringRed(
"No action is specified. Please set at least one of --do-research, --do-generate-outline, --do-generate-article, --do-polish-article"
)
self.topic = topic
self.article_dir_name = truncate_filename(
topic.replace(" ", "_").replace("/", "_")
)
self.article_output_dir = os.path.join(
self.args.output_dir, self.article_dir_name
)
os.makedirs(self.article_output_dir, exist_ok=True)
# research module
information_table: StormInformationTable = None
if do_research:
information_table = self.run_knowledge_curation_module(
ground_truth_url=ground_truth_url, callback_handler=callback_handler
)
# outline generation module
outline: StormArticle = None
if do_generate_outline:
# load information table if it's not initialized
if information_table is None:
information_table = self._load_information_table_from_local_fs(
os.path.join(self.article_output_dir, "conversation_log.json")
)
outline = self.run_outline_generation_module(
information_table=information_table, callback_handler=callback_handler
)
# article generation module
draft_article: StormArticle = None
if do_generate_article:
if information_table is None:
information_table = self._load_information_table_from_local_fs(
os.path.join(self.article_output_dir, "conversation_log.json")
)
if outline is None:
outline = self._load_outline_from_local_fs(
topic=topic,
outline_local_path=os.path.join(
self.article_output_dir, "storm_gen_outline.txt"
),
)
draft_article = self.run_article_generation_module(
outline=outline,
information_table=information_table,
callback_handler=callback_handler,
)
# article polishing module
if do_polish_article:
if draft_article is None:
draft_article_path = os.path.join(
self.article_output_dir, "storm_gen_article.txt"
)
url_to_info_path = os.path.join(
self.article_output_dir, "url_to_info.json"
)
draft_article = self._load_draft_article_from_local_fs(
topic=topic,
draft_article_path=draft_article_path,
url_to_info_path=url_to_info_path,
)
self.run_article_polishing_module(
draft_article=draft_article, remove_duplicate=remove_duplicate
)