Showing
23 changed files
with
499 additions
and
144 deletions
| 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 | -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 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 | -参考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) |
| @@ -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 | """初始化渲染器缓存并允许注入额外配置(如主题覆盖)""" |
| @@ -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) |
-
Please register or login to post a comment