马一丁

Add Comments

1 """ 1 """
2 -Report Engine  
3 -一个智能报告生成AI代理实现  
4 -基于三个子agent的输出和论坛日志生成综合HTML报告 2 +Report Engine。
  3 +
  4 +一个智能报告生成AI代理实现,聚合 Query/Media/Insight 三个子引擎的
  5 +Markdown 与论坛讨论,最终落地结构化HTML报告。
5 """ 6 """
6 7
7 from .agent import ReportAgent, create_agent 8 from .agent import ReportAgent, create_agent
1 """ 1 """
2 -Report Agent主类  
3 -整合所有模块,实现完整的报告生成流程 2 +Report Agent主类。
  3 +
  4 +该模块串联模板选择、布局设计、章节生成、IR装订与HTML渲染等
  5 +所有子流程,是Report Engine的总调度中心。核心职责包括:
  6 +1. 管理输入数据与状态,协调三个分析引擎、论坛日志与模板;
  7 +2. 按节点顺序驱动模板选择→布局生成→篇幅规划→章节写作→装订渲染;
  8 +3. 负责错误兜底、流式事件分发、落盘清单与最终成果保存。
4 """ 9 """
5 10
6 import json 11 import json
@@ -33,15 +38,32 @@ from .utils.config import settings, Settings @@ -33,15 +38,32 @@ from .utils.config import settings, Settings
33 38
34 39
35 class FileCountBaseline: 40 class FileCountBaseline:
36 - """文件数量基准管理器""" 41 + """
  42 + 文件数量基准管理器。
  43 +
  44 + 该工具用于:
  45 + - 在任务启动时记录 Insight/Media/Query 三个引擎导出的 Markdown 数量;
  46 + - 在后续轮询中快速判断是否有新报告落地;
  47 + - 为 Flask 层提供“输入是否准备完毕”的依据。
  48 + """
37 49
38 def __init__(self): 50 def __init__(self):
39 - """在初始化阶段加载或创建文件数量基准快照""" 51 + """
  52 + 初始化时优先尝试读取既有的基准快照。
  53 +
  54 + 若 `logs/report_baseline.json` 不存在则会自动创建一份空快照,
  55 + 以便后续 `initialize_baseline` 在首次运行时写入真实基准。
  56 + """
40 self.baseline_file = 'logs/report_baseline.json' 57 self.baseline_file = 'logs/report_baseline.json'
41 self.baseline_data = self._load_baseline() 58 self.baseline_data = self._load_baseline()
42 59
43 def _load_baseline(self) -> Dict[str, int]: 60 def _load_baseline(self) -> Dict[str, int]:
44 - """加载基准数据""" 61 + """
  62 + 加载基准数据。
  63 +
  64 + - 当快照文件存在时直接解析JSON;
  65 + - 捕获所有加载异常并返回空字典,保证调用方逻辑简洁。
  66 + """
45 try: 67 try:
46 if os.path.exists(self.baseline_file): 68 if os.path.exists(self.baseline_file):
47 with open(self.baseline_file, 'r', encoding='utf-8') as f: 69 with open(self.baseline_file, 'r', encoding='utf-8') as f:
@@ -51,7 +73,12 @@ class FileCountBaseline: @@ -51,7 +73,12 @@ class FileCountBaseline:
51 return {} 73 return {}
52 74
53 def _save_baseline(self): 75 def _save_baseline(self):
54 - """保存基准数据""" 76 + """
  77 + 将当前基准写入磁盘。
  78 +
  79 + 采用 `ensure_ascii=False` + 缩进格式,方便人工查看;
  80 + 若目标目录缺失则自动创建。
  81 + """
55 try: 82 try:
56 os.makedirs(os.path.dirname(self.baseline_file), exist_ok=True) 83 os.makedirs(os.path.dirname(self.baseline_file), exist_ok=True)
57 with open(self.baseline_file, 'w', encoding='utf-8') as f: 84 with open(self.baseline_file, 'w', encoding='utf-8') as f:
@@ -60,7 +87,12 @@ class FileCountBaseline: @@ -60,7 +87,12 @@ class FileCountBaseline:
60 logger.exception(f"保存基准数据失败: {e}") 87 logger.exception(f"保存基准数据失败: {e}")
61 88
62 def initialize_baseline(self, directories: Dict[str, str]) -> Dict[str, int]: 89 def initialize_baseline(self, directories: Dict[str, str]) -> Dict[str, int]:
63 - """初始化文件数量基准""" 90 + """
  91 + 初始化文件数量基准。
  92 +
  93 + 遍历每个引擎目录并统计 `.md` 文件数量,将结果持久化为
  94 + 初始基准。后续 `check_new_files` 会据此对比增量。
  95 + """
64 current_counts = {} 96 current_counts = {}
65 97
66 for engine, directory in directories.items(): 98 for engine, directory in directories.items():
@@ -78,7 +110,13 @@ class FileCountBaseline: @@ -78,7 +110,13 @@ class FileCountBaseline:
78 return current_counts 110 return current_counts
79 111
80 def check_new_files(self, directories: Dict[str, str]) -> Dict[str, Any]: 112 def check_new_files(self, directories: Dict[str, str]) -> Dict[str, Any]:
81 - """检查是否有新文件""" 113 + """
  114 + 检查是否有新文件。
  115 +
  116 + 对比当前目录文件数与基准:
  117 + - 统计新增数量,并判定是否所有引擎都已准备就绪;
  118 + - 返回详细计数、缺失列表,供 Web 层提示给用户。
  119 + """
82 current_counts = {} 120 current_counts = {}
83 new_files_found = {} 121 new_files_found = {}
84 all_have_new = True 122 all_have_new = True
@@ -108,7 +146,12 @@ class FileCountBaseline: @@ -108,7 +146,12 @@ class FileCountBaseline:
108 } 146 }
109 147
110 def get_latest_files(self, directories: Dict[str, str]) -> Dict[str, str]: 148 def get_latest_files(self, directories: Dict[str, str]) -> Dict[str, str]:
111 - """获取每个目录的最新文件""" 149 + """
  150 + 获取每个目录的最新文件。
  151 +
  152 + 通过 `os.path.getmtime` 找出最近写入的 Markdown,
  153 + 以确保生成流程永远使用最新一版三引擎报告。
  154 + """
112 latest_files = {} 155 latest_files = {}
113 156
114 for engine, directory in directories.items(): 157 for engine, directory in directories.items():
@@ -122,14 +165,27 @@ class FileCountBaseline: @@ -122,14 +165,27 @@ class FileCountBaseline:
122 165
123 166
124 class ReportAgent: 167 class ReportAgent:
125 - """Report Agent主类""" 168 + """
  169 + Report Agent主类。
  170 +
  171 + 负责集成:
  172 + - LLM客户端及其上层四个推理节点;
  173 + - 章节存储、IR装订、渲染器等产出链路;
  174 + - 状态管理、日志、输入输出校验与持久化。
  175 + """
126 176
127 def __init__(self, config: Optional[Settings] = None): 177 def __init__(self, config: Optional[Settings] = None):
128 """ 178 """
129 - 初始化Report Agent 179 + 初始化Report Agent
130 180
131 Args: 181 Args:
132 config: 配置对象,如果不提供则自动加载 182 config: 配置对象,如果不提供则自动加载
  183 +
  184 + 步骤概览:
  185 + 1. 解析配置并接入日志/LLM/渲染等核心组件;
  186 + 2. 构造四个推理节点(模板、布局、篇幅、章节);
  187 + 3. 初始化文件基准与章节落盘目录;
  188 + 4. 构建可序列化的状态容器,供外部服务查询。
133 """ 189 """
134 # 加载配置 190 # 加载配置
135 self.config = config or settings 191 self.config = config or settings
@@ -166,7 +222,13 @@ class ReportAgent: @@ -166,7 +222,13 @@ class ReportAgent:
166 logger.info(f"使用LLM: {self.llm_client.get_model_info()}") 222 logger.info(f"使用LLM: {self.llm_client.get_model_info()}")
167 223
168 def _setup_logging(self): 224 def _setup_logging(self):
169 - """设置日志""" 225 + """
  226 + 设置日志。
  227 +
  228 + - 确保日志目录存在;
  229 + - 使用独立的 loguru sink 写入 Report Engine 专属 log 文件,
  230 + 避免与其他子系统混淆。
  231 + """
170 # 确保日志目录存在 232 # 确保日志目录存在
171 log_dir = os.path.dirname(self.config.LOG_FILE) 233 log_dir = os.path.dirname(self.config.LOG_FILE)
172 os.makedirs(log_dir, exist_ok=True) 234 os.makedirs(log_dir, exist_ok=True)
@@ -175,7 +237,12 @@ class ReportAgent: @@ -175,7 +237,12 @@ class ReportAgent:
175 logger.add(self.config.LOG_FILE, level="INFO") 237 logger.add(self.config.LOG_FILE, level="INFO")
176 238
177 def _initialize_file_baseline(self): 239 def _initialize_file_baseline(self):
178 - """初始化文件数量基准""" 240 + """
  241 + 初始化文件数量基准。
  242 +
  243 + 将 Insight/Media/Query 三个目录传入 `FileCountBaseline`,
  244 + 生成一次性的参考值,之后按增量判断三引擎是否产出新报告。
  245 + """
179 directories = { 246 directories = {
180 'insight': 'insight_engine_streamlit_reports', 247 'insight': 'insight_engine_streamlit_reports',
181 'media': 'media_engine_streamlit_reports', 248 'media': 'media_engine_streamlit_reports',
@@ -184,7 +251,12 @@ class ReportAgent: @@ -184,7 +251,12 @@ class ReportAgent:
184 self.file_baseline.initialize_baseline(directories) 251 self.file_baseline.initialize_baseline(directories)
185 252
186 def _initialize_llm(self) -> LLMClient: 253 def _initialize_llm(self) -> LLMClient:
187 - """初始化LLM客户端""" 254 + """
  255 + 初始化LLM客户端。
  256 +
  257 + 利用配置中的 API Key / 模型 / Base URL 构建统一的
  258 + `LLMClient` 实例,为所有节点提供复用的推理入口。
  259 + """
188 return LLMClient( 260 return LLMClient(
189 api_key=self.config.REPORT_ENGINE_API_KEY, 261 api_key=self.config.REPORT_ENGINE_API_KEY,
190 model_name=self.config.REPORT_ENGINE_MODEL_NAME, 262 model_name=self.config.REPORT_ENGINE_MODEL_NAME,
@@ -192,7 +264,12 @@ class ReportAgent: @@ -192,7 +264,12 @@ class ReportAgent:
192 ) 264 )
193 265
194 def _initialize_nodes(self): 266 def _initialize_nodes(self):
195 - """初始化处理节点""" 267 + """
  268 + 初始化处理节点。
  269 +
  270 + 顺序实例化模板选择、文档布局、篇幅规划、章节生成四个节点,
  271 + 其中章节节点额外依赖 IR 校验器与章节存储器。
  272 + """
196 self.template_selection_node = TemplateSelectionNode( 273 self.template_selection_node = TemplateSelectionNode(
197 self.llm_client, 274 self.llm_client,
198 self.config.TEMPLATE_DIR 275 self.config.TEMPLATE_DIR
@@ -209,7 +286,14 @@ class ReportAgent: @@ -209,7 +286,14 @@ class ReportAgent:
209 custom_template: str = "", save_report: bool = True, 286 custom_template: str = "", save_report: bool = True,
210 stream_handler: Optional[Callable[[str, Dict[str, Any]], None]] = None) -> str: 287 stream_handler: Optional[Callable[[str, Dict[str, Any]], None]] = None) -> str:
211 """ 288 """
212 - 生成综合报告(章节JSON → IR → HTML) 289 + 生成综合报告(章节JSON → IR → HTML)。
  290 +
  291 + 主要阶段:
  292 + 1. 归一化三引擎报告 + 论坛日志,并输出流式事件;
  293 + 2. 模板选择 → 模板切片 → 文档布局 → 篇幅规划;
  294 + 3. 结合篇幅目标逐章调用LLM,遇到解析错误会自动重试;
  295 + 4. 将章节装订成Document IR,再交给HTML渲染器生成成品;
  296 + 5. 可选地将HTML/IR/状态落盘,并向外界回传路径信息。
213 297
214 Returns: 298 Returns:
215 dict: HTML内容以及保存的文件路径信息 299 dict: HTML内容以及保存的文件路径信息
@@ -441,7 +525,13 @@ class ReportAgent: @@ -441,7 +525,13 @@ class ReportAgent:
441 raise 525 raise
442 526
443 def _select_template(self, query: str, reports: List[Any], forum_logs: str, custom_template: str): 527 def _select_template(self, query: str, reports: List[Any], forum_logs: str, custom_template: str):
444 - """选择报告模板""" 528 + """
  529 + 选择报告模板。
  530 +
  531 + 优先使用用户指定的模板;否则将查询、三引擎报告与论坛日志
  532 + 作为上下文交给 TemplateSelectionNode,由 LLM 返回最契合的
  533 + 模板名称、内容及理由,并自动记录在状态中。
  534 + """
445 logger.info("选择报告模板...") 535 logger.info("选择报告模板...")
446 536
447 # 如果用户提供了自定义模板,直接使用 537 # 如果用户提供了自定义模板,直接使用
@@ -481,7 +571,13 @@ class ReportAgent: @@ -481,7 +571,13 @@ class ReportAgent:
481 return fallback_template 571 return fallback_template
482 572
483 def _slice_template(self, template_markdown: str) -> List[TemplateSection]: 573 def _slice_template(self, template_markdown: str) -> List[TemplateSection]:
484 - """将模板切成章节列表,若为空则提供fallback""" 574 + """
  575 + 将模板切成章节列表,若为空则提供fallback。
  576 +
  577 + 委托 `parse_template_sections` 将Markdown标题/编号解析为
  578 + `TemplateSection` 列表,确保后续章节生成有稳定的章节ID。
  579 + 当模板格式异常时,会回退到内置的简单骨架避免崩溃。
  580 + """
485 sections = parse_template_sections(template_markdown) 581 sections = parse_template_sections(template_markdown)
486 if sections: 582 if sections:
487 return sections 583 return sections
@@ -510,10 +606,11 @@ class ReportAgent: @@ -510,10 +606,11 @@ class ReportAgent:
510 template_overview: Dict[str, Any], 606 template_overview: Dict[str, Any],
511 ) -> Dict[str, Any]: 607 ) -> Dict[str, Any]:
512 """ 608 """
513 - 构造章节生成所需的共享上下文 609 + 构造章节生成所需的共享上下文
514 610
515 - 这里把“全书设计稿”“章节篇幅约束”“统一主题配色”等一次性整理好,  
516 - 避免每次章节调用都重新拼装上下文。 611 + 将模板名称、布局设计、主题配色、篇幅规划、论坛日志等
  612 + 一次性整合为 `generation_context`,后续每章调用 LLM 时
  613 + 直接复用,确保所有章节共享一致的语调和视觉约束。
517 """ 614 """
518 # 优先使用设计稿定制的主题色,否则退回默认主题 615 # 优先使用设计稿定制的主题色,否则退回默认主题
519 theme_tokens = ( 616 theme_tokens = (
@@ -541,7 +638,12 @@ class ReportAgent: @@ -541,7 +638,12 @@ class ReportAgent:
541 } 638 }
542 639
543 def _normalize_reports(self, reports: List[Any]) -> Dict[str, str]: 640 def _normalize_reports(self, reports: List[Any]) -> Dict[str, str]:
544 - """将不同来源的报告统一转为字符串""" 641 + """
  642 + 将不同来源的报告统一转为字符串。
  643 +
  644 + 约定顺序为 Query/Media/Insight,引擎提供的对象可能是
  645 + 字典或自定义类型,因此统一走 `_stringify` 做容错。
  646 + """
545 keys = ["query_engine", "media_engine", "insight_engine"] 647 keys = ["query_engine", "media_engine", "insight_engine"]
546 normalized: Dict[str, str] = {} 648 normalized: Dict[str, str] = {}
547 for idx, key in enumerate(keys): 649 for idx, key in enumerate(keys):
@@ -551,7 +653,10 @@ class ReportAgent: @@ -551,7 +653,10 @@ class ReportAgent:
551 653
552 def _should_retry_inappropriate_content_error(self, error: Exception) -> bool: 654 def _should_retry_inappropriate_content_error(self, error: Exception) -> bool:
553 """ 655 """
554 - 判断LLM异常是否由内容安全/不当内容导致,满足时允许重新生成整章。 656 + 判断LLM异常是否由内容安全/不当内容导致。
  657 +
  658 + 当检测到供应商返回的错误包含特定关键词时,允许章节生成
  659 + 重新尝试,以便绕过偶发的内容审查触发。
555 """ 660 """
556 message = str(error) if error else "" 661 message = str(error) if error else ""
557 if not message: 662 if not message:
@@ -566,7 +671,12 @@ class ReportAgent: @@ -566,7 +671,12 @@ class ReportAgent:
566 return any(keyword in normalized for keyword in keywords) 671 return any(keyword in normalized for keyword in keywords)
567 672
568 def _stringify(self, value: Any) -> str: 673 def _stringify(self, value: Any) -> str:
569 - """安全地将对象转成字符串""" 674 + """
  675 + 安全地将对象转成字符串。
  676 +
  677 + - dict/list 统一序列化为格式化 JSON,便于提示词消费;
  678 + - 其他类型走 `str()`,None 则返回空串,避免 None 传播。
  679 + """
570 if value is None: 680 if value is None:
571 return "" 681 return ""
572 if isinstance(value, str): 682 if isinstance(value, str):
@@ -579,7 +689,11 @@ class ReportAgent: @@ -579,7 +689,11 @@ class ReportAgent:
579 return str(value) 689 return str(value)
580 690
581 def _default_theme_tokens(self) -> Dict[str, Any]: 691 def _default_theme_tokens(self) -> Dict[str, Any]:
582 - """默认的主题变量,供渲染器/LLM共用""" 692 + """
  693 + 构造默认主题变量,供渲染器/LLM共用。
  694 +
  695 + 当布局节点未返回专属配色时使用该套色板,保持报告风格统一。
  696 + """
583 return { 697 return {
584 "colors": { 698 "colors": {
585 "bg": "#f8f9fa", 699 "bg": "#f8f9fa",
@@ -610,7 +724,11 @@ class ReportAgent: @@ -610,7 +724,11 @@ class ReportAgent:
610 template_markdown: str, 724 template_markdown: str,
611 sections: List[TemplateSection], 725 sections: List[TemplateSection],
612 ) -> Dict[str, Any]: 726 ) -> Dict[str, Any]:
613 - """提取模板标题与章节骨架,供设计/篇幅规划统一引用""" 727 + """
  728 + 提取模板标题与章节骨架,供设计/篇幅规划统一引用。
  729 +
  730 + 同时记录章节ID/slug/order等辅助字段,保证多节点对齐。
  731 + """
614 fallback_title = sections[0].title if sections else "" 732 fallback_title = sections[0].title if sections else ""
615 overview = { 733 overview = {
616 "title": self._extract_template_title(template_markdown, fallback_title), 734 "title": self._extract_template_title(template_markdown, fallback_title),
@@ -633,7 +751,12 @@ class ReportAgent: @@ -633,7 +751,12 @@ class ReportAgent:
633 751
634 @staticmethod 752 @staticmethod
635 def _extract_template_title(template_markdown: str, fallback: str = "") -> str: 753 def _extract_template_title(template_markdown: str, fallback: str = "") -> str:
636 - """尝试从Markdown中提取首个标题,找不到时使用fallback""" 754 + """
  755 + 尝试从Markdown中提取首个标题。
  756 +
  757 + 优先返回首个 `#` 语法标题;如果模板首行就是正文,则回退到
  758 + 第一行非空文本或调用方提供的 fallback。
  759 + """
637 for line in template_markdown.splitlines(): 760 for line in template_markdown.splitlines():
638 stripped = line.strip() 761 stripped = line.strip()
639 if not stripped: 762 if not stripped:
@@ -645,7 +768,12 @@ class ReportAgent: @@ -645,7 +768,12 @@ class ReportAgent:
645 return fallback or "智能舆情分析报告" 768 return fallback or "智能舆情分析报告"
646 769
647 def _get_fallback_template_content(self) -> str: 770 def _get_fallback_template_content(self) -> str:
648 - """获取备用模板内容""" 771 + """
  772 + 获取备用模板内容。
  773 +
  774 + 当模板目录不可用或LLM选择失败时使用该 Markdown 模板,
  775 + 保证后续流程仍能给出结构化章节。
  776 + """
649 return """# 社会公共热点事件分析报告 777 return """# 社会公共热点事件分析报告
650 778
651 ## 执行摘要 779 ## 执行摘要
@@ -694,7 +822,12 @@ class ReportAgent: @@ -694,7 +822,12 @@ class ReportAgent:
694 """ 822 """
695 823
696 def _save_report(self, html_content: str, document_ir: Dict[str, Any], report_id: str) -> Dict[str, Any]: 824 def _save_report(self, html_content: str, document_ir: Dict[str, Any], report_id: str) -> Dict[str, Any]:
697 - """保存HTML与IR到文件并返回路径信息""" 825 + """
  826 + 保存HTML与IR到文件并返回路径信息。
  827 +
  828 + 生成基于查询和时间戳的易读文件名,同时也把运行态的
  829 + `ReportState` 写入 JSON,方便下游排障或断点续跑。
  830 + """
698 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 831 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
699 query_safe = "".join( 832 query_safe = "".join(
700 c for c in self.state.metadata.query if c.isalnum() or c in (" ", "-", "_") 833 c for c in self.state.metadata.query if c.isalnum() or c in (" ", "-", "_")
@@ -734,7 +867,12 @@ class ReportAgent: @@ -734,7 +867,12 @@ class ReportAgent:
734 } 867 }
735 868
736 def _save_document_ir(self, document_ir: Dict[str, Any], query_safe: str, timestamp: str) -> Path: 869 def _save_document_ir(self, document_ir: Dict[str, Any], query_safe: str, timestamp: str) -> Path:
737 - """将整本IR写入独立目录""" 870 + """
  871 + 将整本IR写入独立目录。
  872 +
  873 + `Document IR` 与 HTML 解耦保存,便于调试渲染差异以及
  874 + 在不重新跑 LLM 的情况下再次渲染或导出其他格式。
  875 + """
738 filename = f"report_ir_{query_safe}_{timestamp}.json" 876 filename = f"report_ir_{query_safe}_{timestamp}.json"
739 ir_path = Path(self.config.DOCUMENT_IR_OUTPUT_DIR) / filename 877 ir_path = Path(self.config.DOCUMENT_IR_OUTPUT_DIR) / filename
740 ir_path.write_text( 878 ir_path.write_text(
@@ -751,8 +889,9 @@ class ReportAgent: @@ -751,8 +889,9 @@ class ReportAgent:
751 template_overview: Dict[str, Any], 889 template_overview: Dict[str, Any],
752 ): 890 ):
753 """ 891 """
754 - 将文档设计稿、篇幅规划与模板概览另存成JSON 892 + 将文档设计稿、篇幅规划与模板概览另存成JSON
755 893
  894 + 这些中间件文件(document_layout/word_plan/template_overview)
756 方便在调试或复盘时快速定位:标题/目录/主题是如何确定的、 895 方便在调试或复盘时快速定位:标题/目录/主题是如何确定的、
757 字数分配有什么要求,以便后续人工校正。 896 字数分配有什么要求,以便后续人工校正。
758 """ 897 """
@@ -771,22 +910,22 @@ class ReportAgent: @@ -771,22 +910,22 @@ class ReportAgent:
771 logger.warning(f"写入{name}失败: {exc}") 910 logger.warning(f"写入{name}失败: {exc}")
772 911
773 def get_progress_summary(self) -> Dict[str, Any]: 912 def get_progress_summary(self) -> Dict[str, Any]:
774 - """获取进度摘要""" 913 + """获取进度摘要,直接返回可序列化的状态字典供API层查询。"""
775 return self.state.to_dict() 914 return self.state.to_dict()
776 915
777 def load_state(self, filepath: str): 916 def load_state(self, filepath: str):
778 - """从文件加载状态""" 917 + """从文件加载状态并覆盖当前state,便于断点恢复。"""
779 self.state = ReportState.load_from_file(filepath) 918 self.state = ReportState.load_from_file(filepath)
780 logger.info(f"状态已从 {filepath} 加载") 919 logger.info(f"状态已从 {filepath} 加载")
781 920
782 def save_state(self, filepath: str): 921 def save_state(self, filepath: str):
783 - """保存状态到文件""" 922 + """保存状态到文件,通常用于任务完成后的分析与备份。"""
784 self.state.save_to_file(filepath) 923 self.state.save_to_file(filepath)
785 logger.info(f"状态已保存到 {filepath}") 924 logger.info(f"状态已保存到 {filepath}")
786 925
787 def check_input_files(self, insight_dir: str, media_dir: str, query_dir: str, forum_log_path: str) -> Dict[str, Any]: 926 def check_input_files(self, insight_dir: str, media_dir: str, query_dir: str, forum_log_path: str) -> Dict[str, Any]:
788 """ 927 """
789 - 检查输入文件是否准备就绪(基于文件数量增加) 928 + 检查输入文件是否准备就绪(基于文件数量增加)
790 929
791 Args: 930 Args:
792 insight_dir: InsightEngine报告目录 931 insight_dir: InsightEngine报告目录
@@ -795,7 +934,7 @@ class ReportAgent: @@ -795,7 +934,7 @@ class ReportAgent:
795 forum_log_path: 论坛日志文件路径 934 forum_log_path: 论坛日志文件路径
796 935
797 Returns: 936 Returns:
798 - 检查结果字典 937 + 检查结果字典,包含文件计数、缺失列表、最新文件路径等
799 """ 938 """
800 # 检查各个报告目录的文件数量变化 939 # 检查各个报告目录的文件数量变化
801 directories = { 940 directories = {
@@ -853,7 +992,7 @@ class ReportAgent: @@ -853,7 +992,7 @@ class ReportAgent:
853 file_paths: 文件路径字典 992 file_paths: 文件路径字典
854 993
855 Returns: 994 Returns:
856 - 加载的内容字典 995 + 加载的内容字典,包含 `reports` 列表与 `forum_logs` 字符串
857 """ 996 """
858 content = { 997 content = {
859 'reports': [], 998 'reports': [],
@@ -887,13 +1026,15 @@ class ReportAgent: @@ -887,13 +1026,15 @@ class ReportAgent:
887 1026
888 def create_agent(config_file: Optional[str] = None) -> ReportAgent: 1027 def create_agent(config_file: Optional[str] = None) -> ReportAgent:
889 """ 1028 """
890 - 创建Report Agent实例的便捷函数 1029 + 创建Report Agent实例的便捷函数
891 1030
892 Args: 1031 Args:
893 config_file: 配置文件路径 1032 config_file: 配置文件路径
894 1033
895 Returns: 1034 Returns:
896 ReportAgent实例 1035 ReportAgent实例
  1036 +
  1037 + 目前以环境变量驱动 `Settings`,保留 `config_file` 参数便于未来扩展。
897 """ 1038 """
898 1039
899 config = Settings() # 以空配置初始化,而从从环境变量初始化 1040 config = Settings() # 以空配置初始化,而从从环境变量初始化
1 """ 1 """
2 Report Engine核心工具集合。 2 Report Engine核心工具集合。
3 3
4 -包含模板切片、章节存储等基础能力,供agent流水线复用。 4 +该包封装了模板切片、章节存储与章节装订三大基础能力,
  5 +所有上层节点都会复用这些工具保证结构一致。
5 """ 6 """
6 7
7 from .template_parser import TemplateSection, parse_template_sections 8 from .template_parser import TemplateSection, parse_template_sections
@@ -17,7 +17,12 @@ from typing import Dict, Generator, List, Optional @@ -17,7 +17,12 @@ from typing import Dict, Generator, List, Optional
17 17
18 @dataclass 18 @dataclass
19 class ChapterRecord: 19 class ChapterRecord:
20 - """manifest中记录的章节元数据""" 20 + """
  21 + manifest中记录的章节元数据。
  22 +
  23 + 该结构用于在 `manifest.json` 中追踪每章的状态、文件位置、
  24 + 以及可能的错误列表,方便前端或调试工具读取。
  25 + """
21 26
22 chapter_id: str 27 chapter_id: str
23 slug: str 28 slug: str
@@ -46,12 +51,10 @@ class ChapterStorage: @@ -46,12 +51,10 @@ class ChapterStorage:
46 """ 51 """
47 章节JSON写入与manifest管理器。 52 章节JSON写入与manifest管理器。
48 53
49 - 用法:  
50 - run_dir = storage.start_session(report_id, {...})  
51 - chapter_dir = storage.begin_chapter(run_dir, meta)  
52 - with storage.capture_stream(chapter_dir) as fp:  
53 - fp.write(chunk)  
54 - storage.persist_chapter(run_dir, meta, payload, errors) 54 + 负责:
  55 + - 为每次报告创建独立run目录与manifest快照;
  56 + - 在章节流式生成时即时写入 `stream.raw`;
  57 + - 校验通过后持久化 `chapter.json` 并更新manifest状态。
55 """ 58 """
56 59
57 def __init__(self, base_dir: str): 60 def __init__(self, base_dir: str):
@@ -68,7 +71,11 @@ class ChapterStorage: @@ -68,7 +71,11 @@ class ChapterStorage:
68 # ======== 会话 & manifest ======== 71 # ======== 会话 & manifest ========
69 72
70 def start_session(self, report_id: str, metadata: Dict[str, object]) -> Path: 73 def start_session(self, report_id: str, metadata: Dict[str, object]) -> Path:
71 - """为本次报告创建独立的章节输出目录与manifest""" 74 + """
  75 + 为本次报告创建独立的章节输出目录与manifest。
  76 +
  77 + 同时把全局metadata写入 `manifest.json`,供渲染/调试查询。
  78 + """
72 run_dir = self.base_dir / report_id 79 run_dir = self.base_dir / report_id
73 run_dir.mkdir(parents=True, exist_ok=True) 80 run_dir.mkdir(parents=True, exist_ok=True)
74 manifest = { 81 manifest = {
@@ -82,7 +89,11 @@ class ChapterStorage: @@ -82,7 +89,11 @@ class ChapterStorage:
82 return run_dir 89 return run_dir
83 90
84 def begin_chapter(self, run_dir: Path, chapter_meta: Dict[str, object]) -> Path: 91 def begin_chapter(self, run_dir: Path, chapter_meta: Dict[str, object]) -> Path:
85 - """创建章节子目录并在manifest中标记为streaming状态""" 92 + """
  93 + 创建章节子目录并在manifest中标记为streaming状态。
  94 +
  95 + 会生成 `order-slug` 风格的子目录,并提前登记 raw 文件路径。
  96 + """
86 slug_value = str( 97 slug_value = str(
87 chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" 98 chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
88 ) 99 )
@@ -109,7 +120,11 @@ class ChapterStorage: @@ -109,7 +120,11 @@ class ChapterStorage:
109 payload: Dict[str, object], 120 payload: Dict[str, object],
110 errors: Optional[List[str]] = None, 121 errors: Optional[List[str]] = None,
111 ) -> Path: 122 ) -> Path:
112 - """章节流式生成完毕后写入最终JSON并更新manifest状态""" 123 + """
  124 + 章节流式生成完毕后写入最终JSON并更新manifest状态。
  125 +
  126 + 若校验失败,错误信息会被写入manifest,供前端展示。
  127 + """
113 slug_value = str( 128 slug_value = str(
114 chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" 129 chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
115 ) 130 )
@@ -140,7 +155,11 @@ class ChapterStorage: @@ -140,7 +155,11 @@ class ChapterStorage:
140 return final_path 155 return final_path
141 156
142 def load_chapters(self, run_dir: Path) -> List[Dict[str, object]]: 157 def load_chapters(self, run_dir: Path) -> List[Dict[str, object]]:
143 - """从指定run目录读取全部chapter.json并按order排序返回""" 158 + """
  159 + 从指定run目录读取全部chapter.json并按order排序返回。
  160 +
  161 + 常用于 DocumentComposer 将多个章节装订成整本IR。
  162 + """
144 payloads: List[Dict[str, object]] = [] 163 payloads: List[Dict[str, object]] = []
145 for child in sorted(run_dir.iterdir()): 164 for child in sorted(run_dir.iterdir()):
146 if not child.is_dir(): 165 if not child.is_dir():
@@ -160,7 +179,11 @@ class ChapterStorage: @@ -160,7 +179,11 @@ class ChapterStorage:
160 179
161 @contextmanager 180 @contextmanager
162 def capture_stream(self, chapter_dir: Path) -> Generator: 181 def capture_stream(self, chapter_dir: Path) -> Generator:
163 - """将流式输出实时写入raw文件""" 182 + """
  183 + 将流式输出实时写入raw文件。
  184 +
  185 + 通过 contextmanager 暴露文件句柄,简化章节节点的写入逻辑。
  186 + """
164 raw_path = self._raw_stream_path(chapter_dir) 187 raw_path = self._raw_stream_path(chapter_dir)
165 raw_path.parent.mkdir(parents=True, exist_ok=True) 188 raw_path.parent.mkdir(parents=True, exist_ok=True)
166 with raw_path.open("w", encoding="utf-8") as fp: 189 with raw_path.open("w", encoding="utf-8") as fp:
@@ -169,7 +192,7 @@ class ChapterStorage: @@ -169,7 +192,7 @@ class ChapterStorage:
169 # ======== 内部工具 ======== 192 # ======== 内部工具 ========
170 193
171 def _chapter_dir(self, run_dir: Path, slug: str, order: int) -> Path: 194 def _chapter_dir(self, run_dir: Path, slug: str, order: int) -> Path:
172 - """根据slug/order生成稳定的章节目录,确保各章分隔存盘""" 195 + """根据slug/order生成稳定目录,确保各章分隔存盘且可排序。"""
173 safe_slug = self._safe_slug(slug) 196 safe_slug = self._safe_slug(slug)
174 folder = f"{order:03d}-{safe_slug}" 197 folder = f"{order:03d}-{safe_slug}"
175 path = run_dir / folder 198 path = run_dir / folder
@@ -177,38 +200,46 @@ class ChapterStorage: @@ -177,38 +200,46 @@ class ChapterStorage:
177 return path 200 return path
178 201
179 def _safe_slug(self, slug: str) -> str: 202 def _safe_slug(self, slug: str) -> str:
180 - """移除危险字符,避免生成非法文件夹名""" 203 + """移除危险字符,避免生成非法文件夹名"""
181 slug = slug.replace(" ", "-").replace("/", "-") 204 slug = slug.replace(" ", "-").replace("/", "-")
182 return slug or "section" 205 return slug or "section"
183 206
184 def _raw_stream_path(self, chapter_dir: Path) -> Path: 207 def _raw_stream_path(self, chapter_dir: Path) -> Path:
185 - """返回某章节流式输出对应的raw文件路径""" 208 + """返回某章节流式输出对应的raw文件路径"""
186 return chapter_dir / "stream.raw" 209 return chapter_dir / "stream.raw"
187 210
188 def _key(self, run_dir: Path) -> str: 211 def _key(self, run_dir: Path) -> str:
189 - """将run目录解析为字典缓存的键,避免重复读取磁盘""" 212 + """将run目录解析为字典缓存的键,避免重复读取磁盘"""
190 return str(run_dir.resolve()) 213 return str(run_dir.resolve())
191 214
192 def _manifest_path(self, run_dir: Path) -> Path: 215 def _manifest_path(self, run_dir: Path) -> Path:
193 - """获取manifest.json的实际文件路径""" 216 + """获取manifest.json的实际文件路径"""
194 return run_dir / "manifest.json" 217 return run_dir / "manifest.json"
195 218
196 def _write_manifest(self, run_dir: Path, manifest: Dict[str, object]): 219 def _write_manifest(self, run_dir: Path, manifest: Dict[str, object]):
197 - """将内存中的manifest快照全量写回磁盘""" 220 + """将内存中的manifest快照全量写回磁盘"""
198 self._manifest_path(run_dir).write_text( 221 self._manifest_path(run_dir).write_text(
199 json.dumps(manifest, ensure_ascii=False, indent=2), 222 json.dumps(manifest, ensure_ascii=False, indent=2),
200 encoding="utf-8", 223 encoding="utf-8",
201 ) 224 )
202 225
203 def _read_manifest(self, run_dir: Path) -> Dict[str, object]: 226 def _read_manifest(self, run_dir: Path) -> Dict[str, object]:
204 - """从磁盘读取已有manifest,用于进程重启或多实例协作""" 227 + """
  228 + 从磁盘读取已有manifest。
  229 +
  230 + 进程重启或多实例写盘时可借助它恢复上下文。
  231 + """
205 manifest_path = self._manifest_path(run_dir) 232 manifest_path = self._manifest_path(run_dir)
206 if manifest_path.exists(): 233 if manifest_path.exists():
207 return json.loads(manifest_path.read_text(encoding="utf-8")) 234 return json.loads(manifest_path.read_text(encoding="utf-8"))
208 return {"reportId": run_dir.name, "chapters": []} 235 return {"reportId": run_dir.name, "chapters": []}
209 236
210 def _upsert_record(self, run_dir: Path, record: ChapterRecord): 237 def _upsert_record(self, run_dir: Path, record: ChapterRecord):
211 - """更新或追加manifest中的章节记录,保证顺序一致""" 238 + """
  239 + 更新或追加manifest中的章节记录,保证顺序一致。
  240 +
  241 + 内部会自动排序并写回缓存+磁盘。
  242 + """
212 key = self._key(run_dir) 243 key = self._key(run_dir)
213 manifest = self._manifests.get(key) or self._read_manifest(run_dir) 244 manifest = self._manifests.get(key) or self._read_manifest(run_dir)
214 chapters: List[Dict[str, object]] = manifest.get("chapters", []) 245 chapters: List[Dict[str, object]] = manifest.get("chapters", [])
1 """ 1 """
2 章节装订器:负责把多个章节JSON合并为整本IR。 2 章节装订器:负责把多个章节JSON合并为整本IR。
  3 +
  4 +DocumentComposer 会注入缺失锚点、统一顺序,并补齐 IR 级元数据。
3 """ 5 """
4 6
5 from __future__ import annotations 7 from __future__ import annotations
@@ -13,6 +15,11 @@ from ..ir import IR_VERSION @@ -13,6 +15,11 @@ from ..ir import IR_VERSION
13 class DocumentComposer: 15 class DocumentComposer:
14 """ 16 """
15 将章节拼接成Document IR的简单装订器。 17 将章节拼接成Document IR的简单装订器。
  18 +
  19 + 作用:
  20 + - 按order排序章节,补充默认chapterId;
  21 + - 防止anchor重复,生成全局唯一锚点;
  22 + - 注入 IR 版本与生成时间戳。
16 """ 23 """
17 24
18 def __init__(self): 25 def __init__(self):
@@ -25,7 +32,11 @@ class DocumentComposer: @@ -25,7 +32,11 @@ class DocumentComposer:
25 metadata: Dict[str, object], 32 metadata: Dict[str, object],
26 chapters: List[Dict[str, object]], 33 chapters: List[Dict[str, object]],
27 ) -> Dict[str, object]: 34 ) -> Dict[str, object]:
28 - """把所有章节按order排序并注入唯一锚点,形成整本IR""" 35 + """
  36 + 把所有章节按order排序并注入唯一锚点,形成整本IR。
  37 +
  38 + 同时合并 metadata/themeTokens/assets,供渲染器直接消费。
  39 + """
29 ordered = sorted(chapters, key=lambda c: c.get("order", 0)) 40 ordered = sorted(chapters, key=lambda c: c.get("order", 0))
30 for idx, chapter in enumerate(ordered, start=1): 41 for idx, chapter in enumerate(ordered, start=1):
31 chapter.setdefault("chapterId", f"S{idx}") 42 chapter.setdefault("chapterId", f"S{idx}")
@@ -48,7 +59,7 @@ class DocumentComposer: @@ -48,7 +59,7 @@ class DocumentComposer:
48 return document 59 return document
49 60
50 def _ensure_unique_anchor(self, anchor: str) -> str: 61 def _ensure_unique_anchor(self, anchor: str) -> str:
51 - """若存在重复锚点则追加序号,确保全局唯一""" 62 + """若存在重复锚点则追加序号,确保全局唯一"""
52 base = anchor 63 base = anchor
53 counter = 2 64 counter = 2
54 while anchor in self._seen_anchors: 65 while anchor in self._seen_anchors:
@@ -18,7 +18,12 @@ SECTION_ORDER_STEP = 10 @@ -18,7 +18,12 @@ SECTION_ORDER_STEP = 10
18 18
19 @dataclass 19 @dataclass
20 class TemplateSection: 20 class TemplateSection:
21 - """模板章节实体""" 21 + """
  22 + 模板章节实体。
  23 +
  24 + 记录标题、slug、序号、层级、原始标题、章节编号与提纲,
  25 + 方便后续节点在提示词中引用并保持锚点一致。
  26 + """
22 27
23 title: str 28 title: str
24 slug: str 29 slug: str
@@ -30,7 +35,11 @@ class TemplateSection: @@ -30,7 +35,11 @@ class TemplateSection:
30 outline: List[str] = field(default_factory=list) 35 outline: List[str] = field(default_factory=list)
31 36
32 def to_dict(self) -> dict: 37 def to_dict(self) -> dict:
33 - """将章节实体序列化为字典,方便传给LLM或落盘""" 38 + """
  39 + 将章节实体序列化为字典。
  40 +
  41 + 该结构广泛用于提示词上下文以及 layout/word budget 节点的输入。
  42 + """
34 return { 43 return {
35 "title": self.title, 44 "title": self.title,
36 "slug": self.slug, 45 "slug": self.slug,
@@ -52,7 +61,8 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]: @@ -52,7 +61,8 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]:
52 将Markdown模板切分成章节列表(按大标题)。 61 将Markdown模板切分成章节列表(按大标题)。
53 62
54 返回的每个TemplateSection都携带slug/order/章节号, 63 返回的每个TemplateSection都携带slug/order/章节号,
55 - 方便后续分章调用与锚点生成。 64 + 方便后续分章调用与锚点生成。解析时会同时兼容
  65 + “# 标题”“无符号编号”“列表提纲”等不同写法。
56 """ 66 """
57 67
58 sections: List[TemplateSection] = [] 68 sections: List[TemplateSection] = []
@@ -98,7 +108,12 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]: @@ -98,7 +108,12 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]:
98 108
99 109
100 def _classify_line(stripped: str, indent: int) -> Optional[dict]: 110 def _classify_line(stripped: str, indent: int) -> Optional[dict]:
101 - """根据缩进与符号分类行""" 111 + """
  112 + 根据缩进与符号分类行。
  113 +
  114 + 借助正则判断当前行是章节标题、提纲还是普通列表项,
  115 + 并衍生 depth/slug/number 等派生信息。
  116 + """
102 117
103 heading_match = heading_pattern.match(stripped) 118 heading_match = heading_pattern.match(stripped)
104 if heading_match: 119 if heading_match:
@@ -154,14 +169,19 @@ def _classify_line(stripped: str, indent: int) -> Optional[dict]: @@ -154,14 +169,19 @@ def _classify_line(stripped: str, indent: int) -> Optional[dict]:
154 169
155 170
156 def _strip_markup(text: str) -> str: 171 def _strip_markup(text: str) -> str:
157 - """去除包裹的**、__等简单强调标记""" 172 + """去除包裹的**、__等强调标记,避免干扰标题匹配。"""
158 if text.startswith(("**", "__")) and text.endswith(("**", "__")) and len(text) > 4: 173 if text.startswith(("**", "__")) and text.endswith(("**", "__")) and len(text) > 4:
159 return text[2:-2].strip() 174 return text[2:-2].strip()
160 return text 175 return text
161 176
162 177
163 def _split_number(payload: str) -> dict: 178 def _split_number(payload: str) -> dict:
164 - """拆分编号与标题""" 179 + """
  180 + 拆分编号与标题。
  181 +
  182 + 例如 `1.2 市场趋势` 会被拆成 number=1.2、label=市场趋势,
  183 + 并提供 display 用于回填标题。
  184 + """
165 match = number_pattern.match(payload) 185 match = number_pattern.match(payload)
166 number = match.group("num") if match else "" 186 number = match.group("num") if match else ""
167 label = match.group("label") if match else payload 187 label = match.group("label") if match else payload
@@ -176,7 +196,7 @@ def _split_number(payload: str) -> dict: @@ -176,7 +196,7 @@ def _split_number(payload: str) -> dict:
176 196
177 197
178 def _build_slug(number: str, title: str) -> str: 198 def _build_slug(number: str, title: str) -> str:
179 - """根据编号/标题生成锚点""" 199 + """根据编号/标题生成锚点,优先复用编号,缺失时对标题slug化。"""
180 if number: 200 if number:
181 token = number.replace(".", "-") 201 token = number.replace(".", "-")
182 else: 202 else:
@@ -186,7 +206,11 @@ def _build_slug(number: str, title: str) -> str: @@ -186,7 +206,11 @@ def _build_slug(number: str, title: str) -> str:
186 206
187 207
188 def _slugify_text(text: str) -> str: 208 def _slugify_text(text: str) -> str:
189 - """对任意文本做降噪与转写,得到URL友好的slug片段""" 209 + """
  210 + 对任意文本做降噪与转写,得到URL友好的slug片段。
  211 +
  212 + 会规整大小写、移除特殊符号并保留汉字,确保锚点可读。
  213 + """
190 text = unicodedata.normalize("NFKD", text) 214 text = unicodedata.normalize("NFKD", text)
191 text = text.replace("·", "-").replace(" ", "-") 215 text = text.replace("·", "-").replace(" ", "-")
192 text = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff-]+", "-", text) 216 text = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff-]+", "-", text)
@@ -195,7 +219,11 @@ def _slugify_text(text: str) -> str: @@ -195,7 +219,11 @@ def _slugify_text(text: str) -> str:
195 219
196 220
197 def _ensure_unique_slug(slug: str, used: set) -> str: 221 def _ensure_unique_slug(slug: str, used: set) -> str:
198 - """若slug重复则自动追加序号,直到在used集合中唯一""" 222 + """
  223 + 若slug重复则自动追加序号,直到在used集合中唯一。
  224 +
  225 + 通过 `-2/-3...` 的方式保证相同标题不会产生重复锚点。
  226 + """
199 if slug not in used: 227 if slug not in used:
200 used.add(slug) 228 used.add(slug)
201 return slug 229 return slug
1 """ 1 """
2 -Report Engine Flask接口  
3 -提供HTTP API用于报告生成 2 +Report Engine Flask接口。
  3 +
  4 +该模块为前端/CLI提供统一HTTP/SSE入口,负责:
  5 +1. 初始化 ReportAgent 并串联后台线程;
  6 +2. 管理任务排队、进度查询、流式推送与日志下载;
  7 +3. 提供模板列表、输入文件检查等周边能力。
4 """ 8 """
5 9
6 import os 10 import os
@@ -35,7 +39,11 @@ tasks_registry: Dict[str, 'ReportTask'] = {} @@ -35,7 +39,11 @@ tasks_registry: Dict[str, 'ReportTask'] = {}
35 39
36 40
37 def _register_stream(task_id: str) -> Queue: 41 def _register_stream(task_id: str) -> Queue:
38 - """为指定任务注册一个事件队列,供SSE监听器消费。""" 42 + """
  43 + 为指定任务注册一个事件队列,供SSE监听器消费。
  44 +
  45 + 返回的 Queue 会存入 `stream_subscribers`,SSE 生成器将不断读取。
  46 + """
39 queue = Queue() 47 queue = Queue()
40 with stream_lock: 48 with stream_lock:
41 stream_subscribers[task_id].append(queue) 49 stream_subscribers[task_id].append(queue)
@@ -43,7 +51,11 @@ def _register_stream(task_id: str) -> Queue: @@ -43,7 +51,11 @@ def _register_stream(task_id: str) -> Queue:
43 51
44 52
45 def _unregister_stream(task_id: str, queue: Queue): 53 def _unregister_stream(task_id: str, queue: Queue):
46 - """安全移除事件队列,避免内存泄漏。""" 54 + """
  55 + 安全移除事件队列,避免内存泄漏。
  56 +
  57 + 需要在finally中调用,保证异常情况下资源也能释放。
  58 + """
47 with stream_lock: 59 with stream_lock:
48 listeners = stream_subscribers.get(task_id, []) 60 listeners = stream_subscribers.get(task_id, [])
49 if queue in listeners: 61 if queue in listeners:
@@ -53,7 +65,11 @@ def _unregister_stream(task_id: str, queue: Queue): @@ -53,7 +65,11 @@ def _unregister_stream(task_id: str, queue: Queue):
53 65
54 66
55 def _broadcast_event(task_id: str, event: Dict[str, Any]): 67 def _broadcast_event(task_id: str, event: Dict[str, Any]):
56 - """将事件推送给所有监听者,失败时做好异常捕获。""" 68 + """
  69 + 将事件推送给所有监听者,失败时做好异常捕获。
  70 +
  71 + 采用浅拷贝监听列表,防止并发移除导致遍历异常。
  72 + """
57 with stream_lock: 73 with stream_lock:
58 listeners = list(stream_subscribers.get(task_id, [])) 74 listeners = list(stream_subscribers.get(task_id, []))
59 for queue in listeners: 75 for queue in listeners:
@@ -64,7 +80,11 @@ def _broadcast_event(task_id: str, event: Dict[str, Any]): @@ -64,7 +80,11 @@ def _broadcast_event(task_id: str, event: Dict[str, Any]):
64 80
65 81
66 def _prune_task_history_locked(): 82 def _prune_task_history_locked():
67 - """在task_lock持有期间调用,清理过多的历史任务以控制内存。""" 83 + """
  84 + 在task_lock持有期间调用,清理过多的历史任务。
  85 +
  86 + 仅保留最近 `MAX_TASK_HISTORY` 个任务,避免长时间运行占用过多内存。
  87 + """
68 if len(tasks_registry) <= MAX_TASK_HISTORY: 88 if len(tasks_registry) <= MAX_TASK_HISTORY:
69 return 89 return
70 # 按创建时间排序,移除最旧的任务 90 # 按创建时间排序,移除最旧的任务
@@ -74,7 +94,11 @@ def _prune_task_history_locked(): @@ -74,7 +94,11 @@ def _prune_task_history_locked():
74 94
75 95
76 def _get_task(task_id: str) -> Optional['ReportTask']: 96 def _get_task(task_id: str) -> Optional['ReportTask']:
77 - """统一的任务查找方法,优先返回当前任务。""" 97 + """
  98 + 统一的任务查找方法,优先返回当前任务。
  99 +
  100 + 避免重复写锁逻辑,便于多个API共享。
  101 + """
78 with task_lock: 102 with task_lock:
79 if current_task and current_task.task_id == task_id: 103 if current_task and current_task.task_id == task_id:
80 return current_task 104 return current_task
@@ -82,7 +106,11 @@ def _get_task(task_id: str) -> Optional['ReportTask']: @@ -82,7 +106,11 @@ def _get_task(task_id: str) -> Optional['ReportTask']:
82 106
83 107
84 def _format_sse(event: Dict[str, Any]) -> str: 108 def _format_sse(event: Dict[str, Any]) -> str:
85 - """按SSE协议格式化消息。""" 109 + """
  110 + 按SSE协议格式化消息。
  111 +
  112 + 输出形如 `id:/event:/data:` 的三段文本,供浏览器端直接消费。
  113 + """
86 payload = json.dumps(event, ensure_ascii=False) 114 payload = json.dumps(event, ensure_ascii=False)
87 event_id = event.get('id', 0) 115 event_id = event.get('id', 0)
88 event_type = event.get('type', 'message') 116 event_type = event.get('type', 'message')
@@ -90,7 +118,11 @@ def _format_sse(event: Dict[str, Any]) -> str: @@ -90,7 +118,11 @@ def _format_sse(event: Dict[str, Any]) -> str:
90 118
91 119
92 def initialize_report_engine(): 120 def initialize_report_engine():
93 - """初始化Report Engine""" 121 + """
  122 + 初始化Report Engine。
  123 +
  124 + 单例化 ReportAgent,方便 API 启动后直接接收任务。
  125 + """
94 global report_agent 126 global report_agent
95 try: 127 try:
96 report_agent = create_agent() 128 report_agent = create_agent()
@@ -102,7 +134,12 @@ def initialize_report_engine(): @@ -102,7 +134,12 @@ def initialize_report_engine():
102 134
103 135
104 class ReportTask: 136 class ReportTask:
105 - """报告生成任务""" 137 + """
  138 + 报告生成任务。
  139 +
  140 + 该对象串联运行状态、进度、事件历史及最终文件路径,
  141 + 既供后台线程更新,也供HTTP接口读取。
  142 + """
106 143
107 def __init__(self, query: str, task_id: str, custom_template: str = ""): 144 def __init__(self, query: str, task_id: str, custom_template: str = ""):
108 """ 145 """
@@ -135,7 +172,11 @@ class ReportTask: @@ -135,7 +172,11 @@ class ReportTask:
135 self.last_event_id = 0 172 self.last_event_id = 0
136 173
137 def update_status(self, status: str, progress: int = None, error_message: str = ""): 174 def update_status(self, status: str, progress: int = None, error_message: str = ""):
138 - """更新任务状态""" 175 + """
  176 + 更新任务状态并广播事件。
  177 +
  178 + 会自动刷新 `updated_at`、错误信息,并触发 `status` 类型的 SSE。
  179 + """
139 self.status = status 180 self.status = status
140 if progress is not None: 181 if progress is not None:
141 self.progress = progress 182 self.progress = progress
@@ -155,7 +196,7 @@ class ReportTask: @@ -155,7 +196,7 @@ class ReportTask:
155 ) 196 )
156 197
157 def to_dict(self) -> Dict[str, Any]: 198 def to_dict(self) -> Dict[str, Any]:
158 - """转换为字典格式""" 199 + """转换为字典格式,方便直接返回给JSON API。"""
159 return { 200 return {
160 'task_id': self.task_id, 201 'task_id': self.task_id,
161 'query': self.query, 202 'query': self.query,
@@ -197,7 +238,12 @@ class ReportTask: @@ -197,7 +238,12 @@ class ReportTask:
197 238
198 239
199 def check_engines_ready() -> Dict[str, Any]: 240 def check_engines_ready() -> Dict[str, Any]:
200 - """检查三个子引擎是否都有新文件""" 241 + """
  242 + 检查三个子引擎是否都有新文件。
  243 +
  244 + 调用 ReportAgent 的基准检测逻辑,并附带论坛日志存在性,
  245 + 是 /status、/generate 的前置校验。
  246 + """
201 directories = { 247 directories = {
202 'insight': 'insight_engine_streamlit_reports', 248 'insight': 'insight_engine_streamlit_reports',
203 'media': 'media_engine_streamlit_reports', 249 'media': 'media_engine_streamlit_reports',
@@ -221,7 +267,12 @@ def check_engines_ready() -> Dict[str, Any]: @@ -221,7 +267,12 @@ def check_engines_ready() -> Dict[str, Any]:
221 267
222 268
223 def run_report_generation(task: ReportTask, query: str, custom_template: str = ""): 269 def run_report_generation(task: ReportTask, query: str, custom_template: str = ""):
224 - """在后台线程中运行报告生成""" 270 + """
  271 + 在后台线程中运行报告生成。
  272 +
  273 + 包括:检查输入→加载文档→调用ReportAgent→持久化输出→
  274 + 推送阶段性事件。出现错误会自动推送并写状态。
  275 + """
225 global current_task 276 global current_task
226 277
227 try: 278 try:
@@ -334,7 +385,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " @@ -334,7 +385,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = "
334 385
335 @report_bp.route('/status', methods=['GET']) 386 @report_bp.route('/status', methods=['GET'])
336 def get_status(): 387 def get_status():
337 - """获取Report Engine状态""" 388 + """获取Report Engine状态,包括引擎就绪情况与当前任务信息。"""
338 try: 389 try:
339 engines_status = check_engines_ready() 390 engines_status = check_engines_ready()
340 391
@@ -356,7 +407,11 @@ def get_status(): @@ -356,7 +407,11 @@ def get_status():
356 407
357 @report_bp.route('/generate', methods=['POST']) 408 @report_bp.route('/generate', methods=['POST'])
358 def generate_report(): 409 def generate_report():
359 - """开始生成报告""" 410 + """
  411 + 开始生成报告。
  412 +
  413 + 负责排队、创建后台线程、清空日志并返回SSE地址。
  414 + """
360 global current_task 415 global current_task
361 416
362 try: 417 try:
@@ -443,7 +498,7 @@ def generate_report(): @@ -443,7 +498,7 @@ def generate_report():
443 498
444 @report_bp.route('/progress/<task_id>', methods=['GET']) 499 @report_bp.route('/progress/<task_id>', methods=['GET'])
445 def get_progress(task_id: str): 500 def get_progress(task_id: str):
446 - """获取报告生成进度""" 501 + """获取报告生成进度,若任务被清理则返回一个完成态兜底。"""
447 try: 502 try:
448 task = _get_task(task_id) 503 task = _get_task(task_id)
449 if not task: 504 if not task:
@@ -479,7 +534,13 @@ def get_progress(task_id: str): @@ -479,7 +534,13 @@ def get_progress(task_id: str):
479 534
480 @report_bp.route('/stream/<task_id>', methods=['GET']) 535 @report_bp.route('/stream/<task_id>', methods=['GET'])
481 def stream_task(task_id: str): 536 def stream_task(task_id: str):
482 - """基于SSE的实时推送接口,向前端持续广播阶段事件。""" 537 + """
  538 + 基于SSE的实时推送接口。
  539 +
  540 + - 自动补发Last-Event-ID之后的历史事件;
  541 + - 周期性发送心跳以防代理中断;
  542 + - 任务结束后自动注销监听。
  543 + """
483 task = _get_task(task_id) 544 task = _get_task(task_id)
484 if not task: 545 if not task:
485 return jsonify({'success': False, 'error': '任务不存在'}), 404 546 return jsonify({'success': False, 'error': '任务不存在'}), 404
@@ -674,7 +735,7 @@ def cancel_task(task_id: str): @@ -674,7 +735,7 @@ def cancel_task(task_id: str):
674 735
675 @report_bp.route('/templates', methods=['GET']) 736 @report_bp.route('/templates', methods=['GET'])
676 def get_templates(): 737 def get_templates():
677 - """获取可用模板列表""" 738 + """获取可用模板列表,便于前端展示可选Markdown骨架。"""
678 try: 739 try:
679 if not report_agent: 740 if not report_agent:
680 return jsonify({ 741 return jsonify({
@@ -738,7 +799,7 @@ def internal_error(error): @@ -738,7 +799,7 @@ def internal_error(error):
738 799
739 800
740 def clear_report_log(): 801 def clear_report_log():
741 - """清空report.log文件""" 802 + """清空report.log文件,方便新任务只查看本次运行日志。"""
742 try: 803 try:
743 log_file = settings.LOG_FILE 804 log_file = settings.LOG_FILE
744 with open(log_file, 'w', encoding='utf-8') as f: 805 with open(log_file, 'w', encoding='utf-8') as f:
@@ -750,7 +811,7 @@ def clear_report_log(): @@ -750,7 +811,7 @@ def clear_report_log():
750 811
751 @report_bp.route('/log', methods=['GET']) 812 @report_bp.route('/log', methods=['GET'])
752 def get_report_log(): 813 def get_report_log():
753 - """获取report.log内容""" 814 + """获取report.log内容,并按行去除空白返回。"""
754 try: 815 try:
755 log_file = settings.LOG_FILE 816 log_file = settings.LOG_FILE
756 817
@@ -781,7 +842,7 @@ def get_report_log(): @@ -781,7 +842,7 @@ def get_report_log():
781 842
782 @report_bp.route('/log/clear', methods=['POST']) 843 @report_bp.route('/log/clear', methods=['POST'])
783 def clear_log(): 844 def clear_log():
784 - """手动清空日志""" 845 + """手动清空日志,提供REST入口供前端一键重置。"""
785 try: 846 try:
786 clear_report_log() 847 clear_report_log()
787 return jsonify({ 848 return jsonify({
@@ -20,6 +20,7 @@ class IRValidator: @@ -20,6 +20,7 @@ class IRValidator:
20 说明: 20 说明:
21 - validate_chapter返回(是否通过, 错误列表) 21 - validate_chapter返回(是否通过, 错误列表)
22 - 错误定位采用path语法,便于快速追踪 22 - 错误定位采用path语法,便于快速追踪
  23 + - 内置对heading/paragraph/list/table等所有区块的细粒度校验
23 """ 24 """
24 25
25 def __init__(self, schema_version: str = IR_VERSION): 26 def __init__(self, schema_version: str = IR_VERSION):
1 """ 1 """
2 -LLM module for the Report Engine. 2 +Report Engine LLM子模块。
  3 +
  4 +目前主要暴露 OpenAI 兼容的 `LLMClient` 封装。
3 """ 5 """
4 6
5 from .base import LLMClient 7 from .base import LLMClient
1 """ 1 """
2 -Report Engine 默认的OpenAI兼容LLM客户端封装,内置重试/流式能力。 2 +Report Engine 默认的OpenAI兼容LLM客户端封装。
  3 +
  4 +提供统一的非流式/流式调用、可选重试、字节安全拼接与模型元信息查询。
3 """ 5 """
4 6
5 import os 7 import os
@@ -107,7 +109,7 @@ class LLMClient: @@ -107,7 +109,7 @@ class LLMClient:
107 **kwargs: 额外参数(temperature, top_p等) 109 **kwargs: 额外参数(temperature, top_p等)
108 110
109 Yields: 111 Yields:
110 - 响应文本块(str) 112 + 响应文本块(str),调用方可边读边写入磁盘或透传到UI
111 """ 113 """
112 messages = [ 114 messages = [
113 {"role": "system", "content": system_prompt}, 115 {"role": "system", "content": system_prompt},
1 """ 1 """
2 -Report Engine节点处理模块  
3 -实现报告生成的各个处理步骤 2 +Report Engine节点处理模块。
  3 +
  4 +封装模板选择、章节生成、文档布局、篇幅规划等流水线节点。
4 """ 5 """
5 6
6 from .base_node import BaseNode, StateMutationNode 7 from .base_node import BaseNode, StateMutationNode
1 """ 1 """
2 -Report Engine节点基类  
3 -定义所有处理节点的基础接口 2 +Report Engine节点基类。
  3 +
  4 +所有高阶推理节点都继承于此,统一日志、输入校验与状态变更接口。
4 """ 5 """
5 6
6 from abc import ABC, abstractmethod 7 from abc import ABC, abstractmethod
@@ -10,7 +11,12 @@ from ..state.state import ReportState @@ -10,7 +11,12 @@ from ..state.state import ReportState
10 from loguru import logger 11 from loguru import logger
11 12
12 class BaseNode(ABC): 13 class BaseNode(ABC):
13 - """节点基类""" 14 + """
  15 + 节点基类。
  16 +
  17 + 统一实现日志工具、输入/输出钩子以及LLM客户端依赖注入,
  18 + 便于所有节点只专注业务逻辑。
  19 + """
14 20
15 def __init__(self, llm_client: LLMClient, node_name: str = ""): 21 def __init__(self, llm_client: LLMClient, node_name: str = ""):
16 """ 22 """
@@ -19,6 +25,8 @@ class BaseNode(ABC): @@ -19,6 +25,8 @@ class BaseNode(ABC):
19 Args: 25 Args:
20 llm_client: LLM客户端 26 llm_client: LLM客户端
21 node_name: 节点名称 27 node_name: 节点名称
  28 +
  29 + BaseNode 会保存节点名以便统一输出日志前缀。
22 """ 30 """
23 self.llm_client = llm_client 31 self.llm_client = llm_client
24 self.node_name = node_name or self.__class__.__name__ 32 self.node_name = node_name or self.__class__.__name__
@@ -39,7 +47,8 @@ class BaseNode(ABC): @@ -39,7 +47,8 @@ class BaseNode(ABC):
39 47
40 def validate_input(self, input_data: Any) -> bool: 48 def validate_input(self, input_data: Any) -> bool:
41 """ 49 """
42 - 验证输入数据 50 + 验证输入数据。
  51 + 默认直接通过,子类可按需覆写实现字段检查。
43 52
44 Args: 53 Args:
45 input_data: 输入数据 54 input_data: 输入数据
@@ -51,7 +60,8 @@ class BaseNode(ABC): @@ -51,7 +60,8 @@ class BaseNode(ABC):
51 60
52 def process_output(self, output: Any) -> Any: 61 def process_output(self, output: Any) -> Any:
53 """ 62 """
54 - 处理输出数据 63 + 处理输出数据。
  64 + 子类可覆写进行结构化或校验。
55 65
56 Args: 66 Args:
57 output: 原始输出 67 output: 原始输出
@@ -62,23 +72,29 @@ class BaseNode(ABC): @@ -62,23 +72,29 @@ class BaseNode(ABC):
62 return output 72 return output
63 73
64 def log_info(self, message: str): 74 def log_info(self, message: str):
65 - """记录信息日志""" 75 + """记录信息日志,并自动带上节点名作为前缀。"""
66 formatted_message = f"[{self.node_name}] {message}" 76 formatted_message = f"[{self.node_name}] {message}"
67 logger.info(formatted_message) 77 logger.info(formatted_message)
68 78
69 def log_error(self, message: str): 79 def log_error(self, message: str):
70 - """记录错误日志""" 80 + """记录错误日志,便于排障。"""
71 formatted_message = f"[{self.node_name}] {message}" 81 formatted_message = f"[{self.node_name}] {message}"
72 logger.error(formatted_message) 82 logger.error(formatted_message)
73 83
74 84
75 class StateMutationNode(BaseNode): 85 class StateMutationNode(BaseNode):
76 - """带状态修改功能的节点基类""" 86 + """
  87 + 带状态修改功能的节点基类。
  88 +
  89 + 适用于节点需要直接写入 ReportState 的场景。
  90 + """
77 91
78 @abstractmethod 92 @abstractmethod
79 def mutate_state(self, input_data: Any, state: ReportState, **kwargs) -> ReportState: 93 def mutate_state(self, input_data: Any, state: ReportState, **kwargs) -> ReportState:
80 """ 94 """
81 - 修改状态 95 + 修改状态。
  96 +
  97 + 子类需返回新的状态对象或在原地修改后回传,供流水线记录。
82 98
83 Args: 99 Args:
84 input_data: 输入数据 100 input_data: 输入数据
@@ -29,7 +29,7 @@ except ImportError: # pragma: no cover - optional dependency @@ -29,7 +29,7 @@ except ImportError: # pragma: no cover - optional dependency
29 29
30 30
31 class ChapterJsonParseError(ValueError): 31 class ChapterJsonParseError(ValueError):
32 - """Raised when the LLM output for a chapter cannot be parsed as valid JSON.""" 32 + """章节LLM输出无法解析为合法JSON时抛出的异常,附带原始文本方便排查。"""
33 33
34 def __init__(self, message: str, raw_text: Optional[str] = None): 34 def __init__(self, message: str, raw_text: Optional[str] = None):
35 super().__init__(message) 35 super().__init__(message)
@@ -37,7 +37,15 @@ class ChapterJsonParseError(ValueError): @@ -37,7 +37,15 @@ class ChapterJsonParseError(ValueError):
37 37
38 38
39 class ChapterGenerationNode(BaseNode): 39 class ChapterGenerationNode(BaseNode):
40 - """负责按章节调用LLM并校验JSON结构""" 40 + """
  41 + 负责按章节调用LLM并校验JSON结构。
  42 +
  43 + 核心能力:
  44 + - 构造章节级 payload 与提示词;
  45 + - 以流式形式写入 raw 文件并透传 delta;
  46 + - 尝试修复/解析LLM输出,并使用 IRValidator 校验;
  47 + - 对block结构做容错修复,确保最终JSON可渲染。
  48 + """
41 49
42 _COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=') 50 _COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=')
43 _LINE_BREAK_SENTINEL = "__LINE_BREAK__" 51 _LINE_BREAK_SENTINEL = "__LINE_BREAK__"
@@ -18,7 +18,11 @@ from .base_node import BaseNode @@ -18,7 +18,11 @@ from .base_node import BaseNode
18 18
19 19
20 class DocumentLayoutNode(BaseNode): 20 class DocumentLayoutNode(BaseNode):
21 - """负责生成全局标题、目录与Hero设计""" 21 + """
  22 + 负责生成全局标题、目录与Hero设计。
  23 +
  24 + 结合模板切片、报告摘要与论坛讨论,指导整本书的视觉与结构基调。
  25 + """
22 26
23 def __init__(self, llm_client): 27 def __init__(self, llm_client):
24 """记录LLM客户端并设置节点名字,供BaseNode日志使用""" 28 """记录LLM客户端并设置节点名字,供BaseNode日志使用"""
1 """ 1 """
2 -模板选择节点  
3 -根据查询内容和可用模板选择最合适的报告模板 2 +模板选择节点。
  3 +
  4 +综合用户查询、三引擎报告、论坛日志与本地模板库,
  5 +调用LLM挑选最合适的报告骨架。
4 """ 6 """
5 7
6 import os 8 import os
@@ -13,7 +15,12 @@ from ..prompts import SYSTEM_PROMPT_TEMPLATE_SELECTION @@ -13,7 +15,12 @@ from ..prompts import SYSTEM_PROMPT_TEMPLATE_SELECTION
13 15
14 16
15 class TemplateSelectionNode(BaseNode): 17 class TemplateSelectionNode(BaseNode):
16 - """模板选择处理节点""" 18 + """
  19 + 模板选择处理节点。
  20 +
  21 + 负责准备模板候选列表、构建提示词、解析LLM返回结果,
  22 + 并在失败时回退到内置模板。
  23 + """
17 24
18 def __init__(self, llm_client, template_dir: str = "ReportEngine/report_template"): 25 def __init__(self, llm_client, template_dir: str = "ReportEngine/report_template"):
19 """ 26 """
@@ -28,7 +35,7 @@ class TemplateSelectionNode(BaseNode): @@ -28,7 +35,7 @@ class TemplateSelectionNode(BaseNode):
28 35
29 def run(self, input_data: Dict[str, Any], **kwargs) -> Dict[str, Any]: 36 def run(self, input_data: Dict[str, Any], **kwargs) -> Dict[str, Any]:
30 """ 37 """
31 - 执行模板选择 38 + 执行模板选择
32 39
33 Args: 40 Args:
34 input_data: 包含查询和报告内容的字典 41 input_data: 包含查询和报告内容的字典
@@ -37,7 +44,7 @@ class TemplateSelectionNode(BaseNode): @@ -37,7 +44,7 @@ class TemplateSelectionNode(BaseNode):
37 - forum_logs: 论坛日志内容 44 - forum_logs: 论坛日志内容
38 45
39 Returns: 46 Returns:
40 - 选择的模板信息 47 + 选择的模板信息,包含名称、内容与选择理由
41 """ 48 """
42 logger.info("开始模板选择...") 49 logger.info("开始模板选择...")
43 50
@@ -67,7 +74,12 @@ class TemplateSelectionNode(BaseNode): @@ -67,7 +74,12 @@ class TemplateSelectionNode(BaseNode):
67 74
68 def _llm_template_selection(self, query: str, reports: List[Any], forum_logs: str, 75 def _llm_template_selection(self, query: str, reports: List[Any], forum_logs: str,
69 available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: 76 available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
70 - """使用LLM进行模板选择""" 77 + """
  78 + 使用LLM进行模板选择。
  79 +
  80 + 构造模板列表与报告摘要 → 调用LLM → 解析JSON →
  81 + 验证模板是否存在并返回标准结构。
  82 + """
71 logger.info("尝试使用LLM进行模板选择...") 83 logger.info("尝试使用LLM进行模板选择...")
72 84
73 # 构建模板列表 85 # 构建模板列表
@@ -150,7 +162,11 @@ class TemplateSelectionNode(BaseNode): @@ -150,7 +162,11 @@ class TemplateSelectionNode(BaseNode):
150 return self._extract_template_from_text(response, available_templates) 162 return self._extract_template_from_text(response, available_templates)
151 163
152 def _clean_llm_response(self, response: str) -> str: 164 def _clean_llm_response(self, response: str) -> str:
153 - """清理LLM响应""" 165 + """
  166 + 清理LLM响应。
  167 +
  168 + 去掉 ```json``` 包裹以及前后空白,方便 `json.loads`。
  169 + """
154 # 移除可能的markdown代码块标记 170 # 移除可能的markdown代码块标记
155 if '```json' in response: 171 if '```json' in response:
156 response = response.split('```json')[1].split('```')[0] 172 response = response.split('```json')[1].split('```')[0]
@@ -163,7 +179,11 @@ class TemplateSelectionNode(BaseNode): @@ -163,7 +179,11 @@ class TemplateSelectionNode(BaseNode):
163 return response 179 return response
164 180
165 def _extract_template_from_text(self, response: str, available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: 181 def _extract_template_from_text(self, response: str, available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
166 - """从文本响应中提取模板信息""" 182 + """
  183 + 从文本响应中提取模板信息。
  184 +
  185 + 当LLM未输出合法JSON时,尝试匹配模板名称关键字做降级。
  186 + """
167 logger.info("尝试从文本响应中提取模板信息") 187 logger.info("尝试从文本响应中提取模板信息")
168 188
169 # 查找响应中是否包含模板名称 189 # 查找响应中是否包含模板名称
@@ -186,7 +206,11 @@ class TemplateSelectionNode(BaseNode): @@ -186,7 +206,11 @@ class TemplateSelectionNode(BaseNode):
186 return None 206 return None
187 207
188 def _get_available_templates(self) -> List[Dict[str, Any]]: 208 def _get_available_templates(self) -> List[Dict[str, Any]]:
189 - """获取可用的模板列表""" 209 + """
  210 + 获取可用的模板列表。
  211 +
  212 + 枚举模板目录下的 `.md` 文件并读取内容与描述字段。
  213 + """
190 templates = [] 214 templates = []
191 215
192 if not os.path.exists(self.template_dir): 216 if not os.path.exists(self.template_dir):
@@ -216,7 +240,7 @@ class TemplateSelectionNode(BaseNode): @@ -216,7 +240,7 @@ class TemplateSelectionNode(BaseNode):
216 return templates 240 return templates
217 241
218 def _extract_template_description(self, template_name: str) -> str: 242 def _extract_template_description(self, template_name: str) -> str:
219 - """根据模板名称生成描述""" 243 + """根据模板名称生成描述,方便LLM理解模板定位。"""
220 if '企业品牌' in template_name: 244 if '企业品牌' in template_name:
221 return "适用于企业品牌声誉和形象分析" 245 return "适用于企业品牌声誉和形象分析"
222 elif '市场竞争' in template_name: 246 elif '市场竞争' in template_name:
@@ -235,7 +259,7 @@ class TemplateSelectionNode(BaseNode): @@ -235,7 +259,7 @@ class TemplateSelectionNode(BaseNode):
235 259
236 260
237 def _get_fallback_template(self) -> Dict[str, Any]: 261 def _get_fallback_template(self) -> Dict[str, Any]:
238 - """获取备用默认模板(空模板,让LLM自行发挥)""" 262 + """获取备用默认模板(空模板,让LLM自行发挥)"""
239 logger.info("未找到合适模板,使用空模板让LLM自行发挥") 263 logger.info("未找到合适模板,使用空模板让LLM自行发挥")
240 264
241 return { 265 return {
@@ -18,7 +18,11 @@ from .base_node import BaseNode @@ -18,7 +18,11 @@ from .base_node import BaseNode
18 18
19 19
20 class WordBudgetNode(BaseNode): 20 class WordBudgetNode(BaseNode):
21 - """规划各章节字数与重点""" 21 + """
  22 + 规划各章节字数与重点。
  23 +
  24 + 输出总字数、全局写作准则以及每章/小节的 target/min/max 字数约束。
  25 + """
22 26
23 def __init__(self, llm_client): 27 def __init__(self, llm_client):
24 """仅记录LLM客户端引用,方便run阶段发起请求""" 28 """仅记录LLM客户端引用,方便run阶段发起请求"""
1 """ 1 """
2 -Report Engine提示词模块  
3 -定义报告生成各个阶段使用的系统提示词 2 +Report Engine提示词模块。
  3 +
  4 +集中导出各阶段系统提示词与辅助函数,其他模块可直接from prompts import。
4 """ 5 """
5 6
6 from .prompts import ( 7 from .prompts import (
1 """ 1 """
2 -Report Engine 的所有提示词定义  
3 -参考MediaEngine的结构,专门用于报告生成 2 +Report Engine 的所有提示词定义。
  3 +
  4 +集中声明模板选择、章节JSON、文档布局、篇幅规划等阶段的系统提示词,
  5 +并提供输入输出Schema文本,方便LLM理解结构约束。
4 """ 6 """
5 7
6 import json 8 import json
@@ -359,15 +361,17 @@ SYSTEM_PROMPT_WORD_BUDGET = f""" @@ -359,15 +361,17 @@ SYSTEM_PROMPT_WORD_BUDGET = f"""
359 def build_chapter_user_prompt(payload: dict) -> str: 361 def build_chapter_user_prompt(payload: dict) -> str:
360 """ 362 """
361 将章节上下文序列化为提示词输入。 363 将章节上下文序列化为提示词输入。
  364 +
  365 + 统一使用 `json.dumps(..., indent=2, ensure_ascii=False)`,便于LLM读取。
362 """ 366 """
363 return json.dumps(payload, ensure_ascii=False, indent=2) 367 return json.dumps(payload, ensure_ascii=False, indent=2)
364 368
365 369
366 def build_document_layout_prompt(payload: dict) -> str: 370 def build_document_layout_prompt(payload: dict) -> str:
367 - """将文档设计所需的上下文序列化为JSON字符串""" 371 + """将文档设计所需的上下文序列化为JSON字符串,供布局节点发送给LLM。"""
368 return json.dumps(payload, ensure_ascii=False, indent=2) 372 return json.dumps(payload, ensure_ascii=False, indent=2)
369 373
370 374
371 def build_word_budget_prompt(payload: dict) -> str: 375 def build_word_budget_prompt(payload: dict) -> str:
372 - """将篇幅规划输入转为字符串,便于送入LLM""" 376 + """将篇幅规划输入转为字符串,便于送入LLM并保持字段精确。"""
373 return json.dumps(payload, ensure_ascii=False, indent=2) 377 return json.dumps(payload, ensure_ascii=False, indent=2)
1 """ 1 """
2 Report Engine渲染器集合。 2 Report Engine渲染器集合。
  3 +
  4 +目前仅提供 HTMLRenderer,未来可扩展为PDF/Markdown等输出。
3 """ 5 """
4 6
5 from .html_renderer import HTMLRenderer 7 from .html_renderer import HTMLRenderer
@@ -11,7 +11,13 @@ from typing import Any, Dict, List @@ -11,7 +11,13 @@ from typing import Any, Dict, List
11 11
12 12
13 class HTMLRenderer: 13 class HTMLRenderer:
14 - """Document IR → HTML 渲染器""" 14 + """
  15 + Document IR → HTML 渲染器。
  16 +
  17 + - 读取 IR metadata/chapters,将结构映射为响应式HTML;
  18 + - 动态构造目录、锚点、Chart.js脚本及互动逻辑;
  19 + - 提供主题变量、编号映射等辅助功能。
  20 + """
15 21
16 def __init__(self, config: Dict[str, Any] | None = None): 22 def __init__(self, config: Dict[str, Any] | None = None):
17 """初始化渲染器缓存并允许注入额外配置(如主题覆盖)""" 23 """初始化渲染器缓存并允许注入额外配置(如主题覆盖)"""
1 """ 1 """
2 -Report Engine状态管理模块  
3 -定义报告生成过程中的简化状态数据结构 2 +Report Engine状态管理模块。
  3 +
  4 +导出 ReportState/ReportMetadata,供Agent与Flask接口共享。
4 """ 5 """
5 6
6 from .state import ReportState, ReportMetadata 7 from .state import ReportState, ReportMetadata
@@ -29,7 +29,11 @@ class ReportMetadata: @@ -29,7 +29,11 @@ class ReportMetadata:
29 29
30 @dataclass 30 @dataclass
31 class ReportState: 31 class ReportState:
32 - """简化的报告状态管理""" 32 + """
  33 + 简化的报告状态管理。
  34 +
  35 + 存储任务基本信息、输入、输出与元数据,供Agent与Flask层共享。
  36 + """
33 # 基本信息 37 # 基本信息
34 task_id: str = "" # 任务ID 38 task_id: str = "" # 任务ID
35 query: str = "" # 原始查询 39 query: str = "" # 原始查询
@@ -55,24 +59,24 @@ class ReportState: @@ -55,24 +59,24 @@ class ReportState:
55 self.metadata.query = self.query 59 self.metadata.query = self.query
56 60
57 def mark_processing(self): 61 def mark_processing(self):
58 - """标记为处理中""" 62 + """标记为处理中,后台线程开始调度生成流程。"""
59 self.status = "processing" 63 self.status = "processing"
60 64
61 def mark_completed(self): 65 def mark_completed(self):
62 - """标记为完成""" 66 + """标记为完成,同时意味着 `html_content` 已可用。"""
63 self.status = "completed" 67 self.status = "completed"
64 68
65 def mark_failed(self, error_message: str = ""): 69 def mark_failed(self, error_message: str = ""):
66 - """标记为失败""" 70 + """标记为失败,并记录最后一次错误消息。"""
67 self.status = "failed" 71 self.status = "failed"
68 self.error_message = error_message 72 self.error_message = error_message
69 73
70 def is_completed(self) -> bool: 74 def is_completed(self) -> bool:
71 - """检查是否完成""" 75 + """检查是否完成,包括状态为completed且存在HTML内容。"""
72 return self.status == "completed" and bool(self.html_content) 76 return self.status == "completed" and bool(self.html_content)
73 77
74 def get_progress(self) -> float: 78 def get_progress(self) -> float:
75 - """获取进度百分比""" 79 + """获取进度百分比,按照模板/内容两个阶段粗略估算。"""
76 if self.status == "completed": 80 if self.status == "completed":
77 return 100.0 81 return 100.0
78 elif self.status == "processing": 82 elif self.status == "processing":
@@ -87,7 +91,7 @@ class ReportState: @@ -87,7 +91,7 @@ class ReportState:
87 return 0.0 91 return 0.0
88 92
89 def to_dict(self) -> Dict[str, Any]: 93 def to_dict(self) -> Dict[str, Any]:
90 - """转换为字典格式""" 94 + """转换为字典格式,方便序列化给前端。"""
91 return { 95 return {
92 "task_id": self.task_id, 96 "task_id": self.task_id,
93 "query": self.query, 97 "query": self.query,
@@ -100,7 +104,7 @@ class ReportState: @@ -100,7 +104,7 @@ class ReportState:
100 } 104 }
101 105
102 def save_to_file(self, file_path: str): 106 def save_to_file(self, file_path: str):
103 - """保存状态到文件""" 107 + """保存状态到文件,排除HTML正文以控制体积。"""
104 try: 108 try:
105 state_data = self.to_dict() 109 state_data = self.to_dict()
106 # 不保存完整的HTML内容到状态文件(太大) 110 # 不保存完整的HTML内容到状态文件(太大)
@@ -113,7 +117,7 @@ class ReportState: @@ -113,7 +117,7 @@ class ReportState:
113 117
114 @classmethod 118 @classmethod
115 def load_from_file(cls, file_path: str) -> Optional["ReportState"]: 119 def load_from_file(cls, file_path: str) -> Optional["ReportState"]:
116 - """从文件加载状态""" 120 + """从文件加载状态,仅恢复关键字段便于调试。"""
117 try: 121 try:
118 with open(file_path, 'r', encoding='utf-8') as f: 122 with open(file_path, 'r', encoding='utf-8') as f:
119 data = json.load(f) 123 data = json.load(f)
@@ -135,4 +139,4 @@ class ReportState: @@ -135,4 +139,4 @@ class ReportState:
135 139
136 except Exception as e: 140 except Exception as e:
137 print(f"加载状态文件失败: {str(e)}") 141 print(f"加载状态文件失败: {str(e)}")
138 - return None  
  142 + return None
1 """ 1 """
2 -Report Engine工具模块  
3 -包含配置管理 2 +Report Engine工具模块。
  3 +
  4 +当前主要暴露配置读取逻辑,后续可扩展更多通用工具。
4 """ 5 """
5 6
6 7