Showing
13 changed files
with
655 additions
and
61 deletions
| @@ -296,8 +296,19 @@ class ReportAgent: | @@ -296,8 +296,19 @@ class ReportAgent: | ||
| 296 | 4. 将章节装订成Document IR,再交给HTML渲染器生成成品; | 296 | 4. 将章节装订成Document IR,再交给HTML渲染器生成成品; |
| 297 | 5. 可选地将HTML/IR/状态落盘,并向外界回传路径信息。 | 297 | 5. 可选地将HTML/IR/状态落盘,并向外界回传路径信息。 |
| 298 | 298 | ||
| 299 | - Returns: | ||
| 300 | - dict: HTML内容以及保存的文件路径信息 | 299 | + 参数: |
| 300 | + query: 最终要生成的报告主题或提问语句。 | ||
| 301 | + reports: 来自 Query/Media/Insight 等分析引擎的原始输出,允许传入字符串或更复杂的对象。 | ||
| 302 | + forum_logs: 论坛/协同记录,供LLM理解多人讨论上下文。 | ||
| 303 | + custom_template: 用户指定的Markdown模板,如为空则交由模板节点自动挑选。 | ||
| 304 | + save_report: 是否在生成后自动将HTML、IR与状态写入磁盘。 | ||
| 305 | + stream_handler: 可选的流式事件回调,接收阶段标签与payload,用于UI实时展示。 | ||
| 306 | + | ||
| 307 | + 返回: | ||
| 308 | + dict: 包含 `html_content` 以及HTML/IR/状态文件路径的字典;若 `save_report=False` 则仅返回HTML字符串。 | ||
| 309 | + | ||
| 310 | + 异常: | ||
| 311 | + Exception: 任一子节点或渲染阶段失败时抛出,外层调用方负责兜底。 | ||
| 301 | """ | 312 | """ |
| 302 | start_time = datetime.now() | 313 | start_time = datetime.now() |
| 303 | report_id = f"report-{uuid4().hex[:8]}" | 314 | report_id = f"report-{uuid4().hex[:8]}" |
| @@ -538,6 +549,15 @@ class ReportAgent: | @@ -538,6 +549,15 @@ class ReportAgent: | ||
| 538 | 优先使用用户指定的模板;否则将查询、三引擎报告与论坛日志 | 549 | 优先使用用户指定的模板;否则将查询、三引擎报告与论坛日志 |
| 539 | 作为上下文交给 TemplateSelectionNode,由 LLM 返回最契合的 | 550 | 作为上下文交给 TemplateSelectionNode,由 LLM 返回最契合的 |
| 540 | 模板名称、内容及理由,并自动记录在状态中。 | 551 | 模板名称、内容及理由,并自动记录在状态中。 |
| 552 | + | ||
| 553 | + 参数: | ||
| 554 | + query: 报告主题,用于提示词聚焦行业/事件。 | ||
| 555 | + reports: 多来源报告原文,帮助LLM判断结构复杂度。 | ||
| 556 | + forum_logs: 对应论坛或协作讨论的文本,用于补充背景。 | ||
| 557 | + custom_template: CLI/前端传入的自定义Markdown模板,非空时直接采用。 | ||
| 558 | + | ||
| 559 | + 返回: | ||
| 560 | + dict: 包含 `template_name`、`template_content` 与 `selection_reason` 的结构化结果,供后续节点消费。 | ||
| 541 | """ | 561 | """ |
| 542 | logger.info("选择报告模板...") | 562 | logger.info("选择报告模板...") |
| 543 | 563 | ||
| @@ -584,6 +604,12 @@ class ReportAgent: | @@ -584,6 +604,12 @@ class ReportAgent: | ||
| 584 | 委托 `parse_template_sections` 将Markdown标题/编号解析为 | 604 | 委托 `parse_template_sections` 将Markdown标题/编号解析为 |
| 585 | `TemplateSection` 列表,确保后续章节生成有稳定的章节ID。 | 605 | `TemplateSection` 列表,确保后续章节生成有稳定的章节ID。 |
| 586 | 当模板格式异常时,会回退到内置的简单骨架避免崩溃。 | 606 | 当模板格式异常时,会回退到内置的简单骨架避免崩溃。 |
| 607 | + | ||
| 608 | + 参数: | ||
| 609 | + template_markdown: 完整的模板Markdown文本。 | ||
| 610 | + | ||
| 611 | + 返回: | ||
| 612 | + list[TemplateSection]: 解析后的章节序列;如解析失败则返回单章兜底结构。 | ||
| 587 | """ | 613 | """ |
| 588 | sections = parse_template_sections(template_markdown) | 614 | sections = parse_template_sections(template_markdown) |
| 589 | if sections: | 615 | if sections: |
| @@ -618,6 +644,19 @@ class ReportAgent: | @@ -618,6 +644,19 @@ class ReportAgent: | ||
| 618 | 将模板名称、布局设计、主题配色、篇幅规划、论坛日志等 | 644 | 将模板名称、布局设计、主题配色、篇幅规划、论坛日志等 |
| 619 | 一次性整合为 `generation_context`,后续每章调用 LLM 时 | 645 | 一次性整合为 `generation_context`,后续每章调用 LLM 时 |
| 620 | 直接复用,确保所有章节共享一致的语调和视觉约束。 | 646 | 直接复用,确保所有章节共享一致的语调和视觉约束。 |
| 647 | + | ||
| 648 | + 参数: | ||
| 649 | + query: 用户查询词。 | ||
| 650 | + reports: 归一化后的 query/media/insight 报告映射。 | ||
| 651 | + forum_logs: 三引擎讨论记录。 | ||
| 652 | + template_result: 模板节点返回的模板元信息。 | ||
| 653 | + layout_design: 文档布局节点产出的标题/目录/主题设计。 | ||
| 654 | + chapter_directives: 字数规划节点返回的章节指令映射。 | ||
| 655 | + word_plan: 篇幅规划原始结果,包含全局字数约束。 | ||
| 656 | + template_overview: 模板切片提炼的章节骨架摘要。 | ||
| 657 | + | ||
| 658 | + 返回: | ||
| 659 | + dict: LLM章节生成所需的全集上下文,包含主题色、布局、约束等键。 | ||
| 621 | """ | 660 | """ |
| 622 | # 优先使用设计稿定制的主题色,否则退回默认主题 | 661 | # 优先使用设计稿定制的主题色,否则退回默认主题 |
| 623 | theme_tokens = ( | 662 | theme_tokens = ( |
| @@ -650,6 +689,12 @@ class ReportAgent: | @@ -650,6 +689,12 @@ class ReportAgent: | ||
| 650 | 689 | ||
| 651 | 约定顺序为 Query/Media/Insight,引擎提供的对象可能是 | 690 | 约定顺序为 Query/Media/Insight,引擎提供的对象可能是 |
| 652 | 字典或自定义类型,因此统一走 `_stringify` 做容错。 | 691 | 字典或自定义类型,因此统一走 `_stringify` 做容错。 |
| 692 | + | ||
| 693 | + 参数: | ||
| 694 | + reports: 任意类型的报告列表,允许缺失或顺序混乱。 | ||
| 695 | + | ||
| 696 | + 返回: | ||
| 697 | + dict: 包含 `query_engine`/`media_engine`/`insight_engine` 三个字符串字段的映射。 | ||
| 653 | """ | 698 | """ |
| 654 | keys = ["query_engine", "media_engine", "insight_engine"] | 699 | keys = ["query_engine", "media_engine", "insight_engine"] |
| 655 | normalized: Dict[str, str] = {} | 700 | normalized: Dict[str, str] = {} |
| @@ -664,6 +709,12 @@ class ReportAgent: | @@ -664,6 +709,12 @@ class ReportAgent: | ||
| 664 | 709 | ||
| 665 | 当检测到供应商返回的错误包含特定关键词时,允许章节生成 | 710 | 当检测到供应商返回的错误包含特定关键词时,允许章节生成 |
| 666 | 重新尝试,以便绕过偶发的内容审查触发。 | 711 | 重新尝试,以便绕过偶发的内容审查触发。 |
| 712 | + | ||
| 713 | + 参数: | ||
| 714 | + error: LLM客户端抛出的异常对象。 | ||
| 715 | + | ||
| 716 | + 返回: | ||
| 717 | + bool: 若匹配到内容审查关键词则返回True,否则为False。 | ||
| 667 | """ | 718 | """ |
| 668 | message = str(error) if error else "" | 719 | message = str(error) if error else "" |
| 669 | if not message: | 720 | if not message: |
| @@ -683,6 +734,12 @@ class ReportAgent: | @@ -683,6 +734,12 @@ class ReportAgent: | ||
| 683 | 734 | ||
| 684 | - dict/list 统一序列化为格式化 JSON,便于提示词消费; | 735 | - dict/list 统一序列化为格式化 JSON,便于提示词消费; |
| 685 | - 其他类型走 `str()`,None 则返回空串,避免 None 传播。 | 736 | - 其他类型走 `str()`,None 则返回空串,避免 None 传播。 |
| 737 | + | ||
| 738 | + 参数: | ||
| 739 | + value: 任意Python对象。 | ||
| 740 | + | ||
| 741 | + 返回: | ||
| 742 | + str: 适配提示词/日志的字符串表现。 | ||
| 686 | """ | 743 | """ |
| 687 | if value is None: | 744 | if value is None: |
| 688 | return "" | 745 | return "" |
| @@ -700,6 +757,9 @@ class ReportAgent: | @@ -700,6 +757,9 @@ class ReportAgent: | ||
| 700 | 构造默认主题变量,供渲染器/LLM共用。 | 757 | 构造默认主题变量,供渲染器/LLM共用。 |
| 701 | 758 | ||
| 702 | 当布局节点未返回专属配色时使用该套色板,保持报告风格统一。 | 759 | 当布局节点未返回专属配色时使用该套色板,保持报告风格统一。 |
| 760 | + | ||
| 761 | + 返回: | ||
| 762 | + dict: 包含颜色、字体、间距、布尔开关等渲染参数的主题字典。 | ||
| 703 | """ | 763 | """ |
| 704 | return { | 764 | return { |
| 705 | "colors": { | 765 | "colors": { |
| @@ -735,6 +795,13 @@ class ReportAgent: | @@ -735,6 +795,13 @@ class ReportAgent: | ||
| 735 | 提取模板标题与章节骨架,供设计/篇幅规划统一引用。 | 795 | 提取模板标题与章节骨架,供设计/篇幅规划统一引用。 |
| 736 | 796 | ||
| 737 | 同时记录章节ID/slug/order等辅助字段,保证多节点对齐。 | 797 | 同时记录章节ID/slug/order等辅助字段,保证多节点对齐。 |
| 798 | + | ||
| 799 | + 参数: | ||
| 800 | + template_markdown: 模板原文,用于解析全局标题。 | ||
| 801 | + sections: `TemplateSection` 列表,作为章节骨架。 | ||
| 802 | + | ||
| 803 | + 返回: | ||
| 804 | + dict: 包含模板标题与章节元数据的概览结构。 | ||
| 738 | """ | 805 | """ |
| 739 | fallback_title = sections[0].title if sections else "" | 806 | fallback_title = sections[0].title if sections else "" |
| 740 | overview = { | 807 | overview = { |
| @@ -763,6 +830,13 @@ class ReportAgent: | @@ -763,6 +830,13 @@ class ReportAgent: | ||
| 763 | 830 | ||
| 764 | 优先返回首个 `#` 语法标题;如果模板首行就是正文,则回退到 | 831 | 优先返回首个 `#` 语法标题;如果模板首行就是正文,则回退到 |
| 765 | 第一行非空文本或调用方提供的 fallback。 | 832 | 第一行非空文本或调用方提供的 fallback。 |
| 833 | + | ||
| 834 | + 参数: | ||
| 835 | + template_markdown: 模板原文。 | ||
| 836 | + fallback: 备用标题,当文档缺少显式标题时使用。 | ||
| 837 | + | ||
| 838 | + 返回: | ||
| 839 | + str: 解析到的标题文本。 | ||
| 766 | """ | 840 | """ |
| 767 | for line in template_markdown.splitlines(): | 841 | for line in template_markdown.splitlines(): |
| 768 | stripped = line.strip() | 842 | stripped = line.strip() |
| @@ -834,6 +908,14 @@ class ReportAgent: | @@ -834,6 +908,14 @@ class ReportAgent: | ||
| 834 | 908 | ||
| 835 | 生成基于查询和时间戳的易读文件名,同时也把运行态的 | 909 | 生成基于查询和时间戳的易读文件名,同时也把运行态的 |
| 836 | `ReportState` 写入 JSON,方便下游排障或断点续跑。 | 910 | `ReportState` 写入 JSON,方便下游排障或断点续跑。 |
| 911 | + | ||
| 912 | + 参数: | ||
| 913 | + html_content: 渲染后的HTML正文。 | ||
| 914 | + document_ir: Document IR结构化数据。 | ||
| 915 | + report_id: 当前任务ID,用于创建独立文件名。 | ||
| 916 | + | ||
| 917 | + 返回: | ||
| 918 | + dict: 记录HTML/IR/State文件的绝对与相对路径信息。 | ||
| 837 | """ | 919 | """ |
| 838 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | 920 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| 839 | query_safe = "".join( | 921 | query_safe = "".join( |
| @@ -879,6 +961,14 @@ class ReportAgent: | @@ -879,6 +961,14 @@ class ReportAgent: | ||
| 879 | 961 | ||
| 880 | `Document IR` 与 HTML 解耦保存,便于调试渲染差异以及 | 962 | `Document IR` 与 HTML 解耦保存,便于调试渲染差异以及 |
| 881 | 在不重新跑 LLM 的情况下再次渲染或导出其他格式。 | 963 | 在不重新跑 LLM 的情况下再次渲染或导出其他格式。 |
| 964 | + | ||
| 965 | + 参数: | ||
| 966 | + document_ir: 整本报告的IR结构。 | ||
| 967 | + query_safe: 已清洗的查询短语,用于文件命名。 | ||
| 968 | + timestamp: 运行时间戳,保证文件名唯一。 | ||
| 969 | + | ||
| 970 | + 返回: | ||
| 971 | + Path: 指向保存后的IR文件路径。 | ||
| 882 | """ | 972 | """ |
| 883 | filename = f"report_ir_{query_safe}_{timestamp}.json" | 973 | filename = f"report_ir_{query_safe}_{timestamp}.json" |
| 884 | ir_path = Path(self.config.DOCUMENT_IR_OUTPUT_DIR) / filename | 974 | ir_path = Path(self.config.DOCUMENT_IR_OUTPUT_DIR) / filename |
| @@ -901,6 +991,12 @@ class ReportAgent: | @@ -901,6 +991,12 @@ class ReportAgent: | ||
| 901 | 这些中间件文件(document_layout/word_plan/template_overview) | 991 | 这些中间件文件(document_layout/word_plan/template_overview) |
| 902 | 方便在调试或复盘时快速定位:标题/目录/主题是如何确定的、 | 992 | 方便在调试或复盘时快速定位:标题/目录/主题是如何确定的、 |
| 903 | 字数分配有什么要求,以便后续人工校正。 | 993 | 字数分配有什么要求,以便后续人工校正。 |
| 994 | + | ||
| 995 | + 参数: | ||
| 996 | + run_dir: 章节输出根目录。 | ||
| 997 | + layout_design: 文档布局节点的原始输出。 | ||
| 998 | + word_plan: 篇幅规划节点输出。 | ||
| 999 | + template_overview: 模板概览JSON。 | ||
| 904 | """ | 1000 | """ |
| 905 | artifacts = { | 1001 | artifacts = { |
| 906 | "document_layout": layout_design, | 1002 | "document_layout": layout_design, |
| @@ -75,6 +75,13 @@ class ChapterStorage: | @@ -75,6 +75,13 @@ class ChapterStorage: | ||
| 75 | 为本次报告创建独立的章节输出目录与manifest。 | 75 | 为本次报告创建独立的章节输出目录与manifest。 |
| 76 | 76 | ||
| 77 | 同时把全局metadata写入 `manifest.json`,供渲染/调试查询。 | 77 | 同时把全局metadata写入 `manifest.json`,供渲染/调试查询。 |
| 78 | + | ||
| 79 | + 参数: | ||
| 80 | + report_id: 任务ID。 | ||
| 81 | + metadata: Report元数据(标题、主题等)。 | ||
| 82 | + | ||
| 83 | + 返回: | ||
| 84 | + Path: 新建的run目录。 | ||
| 78 | """ | 85 | """ |
| 79 | run_dir = self.base_dir / report_id | 86 | run_dir = self.base_dir / report_id |
| 80 | run_dir.mkdir(parents=True, exist_ok=True) | 87 | run_dir.mkdir(parents=True, exist_ok=True) |
| @@ -93,6 +100,13 @@ class ChapterStorage: | @@ -93,6 +100,13 @@ class ChapterStorage: | ||
| 93 | 创建章节子目录并在manifest中标记为streaming状态。 | 100 | 创建章节子目录并在manifest中标记为streaming状态。 |
| 94 | 101 | ||
| 95 | 会生成 `order-slug` 风格的子目录,并提前登记 raw 文件路径。 | 102 | 会生成 `order-slug` 风格的子目录,并提前登记 raw 文件路径。 |
| 103 | + | ||
| 104 | + 参数: | ||
| 105 | + run_dir: 会话根目录。 | ||
| 106 | + chapter_meta: 包含 chapterId/title/slug/order 的元数据。 | ||
| 107 | + | ||
| 108 | + 返回: | ||
| 109 | + Path: 章节目录。 | ||
| 96 | """ | 110 | """ |
| 97 | slug_value = str( | 111 | slug_value = str( |
| 98 | chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" | 112 | chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" |
| @@ -124,6 +138,15 @@ class ChapterStorage: | @@ -124,6 +138,15 @@ class ChapterStorage: | ||
| 124 | 章节流式生成完毕后写入最终JSON并更新manifest状态。 | 138 | 章节流式生成完毕后写入最终JSON并更新manifest状态。 |
| 125 | 139 | ||
| 126 | 若校验失败,错误信息会被写入manifest,供前端展示。 | 140 | 若校验失败,错误信息会被写入manifest,供前端展示。 |
| 141 | + | ||
| 142 | + 参数: | ||
| 143 | + run_dir: 会话根目录。 | ||
| 144 | + chapter_meta: 章节元信息。 | ||
| 145 | + payload: 校验通过的章节JSON。 | ||
| 146 | + errors: 可选的错误列表,用于标记invalid状态。 | ||
| 147 | + | ||
| 148 | + 返回: | ||
| 149 | + Path: 最终的 `chapter.json` 文件路径。 | ||
| 127 | """ | 150 | """ |
| 128 | slug_value = str( | 151 | slug_value = str( |
| 129 | chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" | 152 | chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" |
| @@ -159,6 +182,12 @@ class ChapterStorage: | @@ -159,6 +182,12 @@ class ChapterStorage: | ||
| 159 | 从指定run目录读取全部chapter.json并按order排序返回。 | 182 | 从指定run目录读取全部chapter.json并按order排序返回。 |
| 160 | 183 | ||
| 161 | 常用于 DocumentComposer 将多个章节装订成整本IR。 | 184 | 常用于 DocumentComposer 将多个章节装订成整本IR。 |
| 185 | + | ||
| 186 | + 参数: | ||
| 187 | + run_dir: 会话根目录。 | ||
| 188 | + | ||
| 189 | + 返回: | ||
| 190 | + list[dict]: 章节payload列表。 | ||
| 162 | """ | 191 | """ |
| 163 | payloads: List[Dict[str, object]] = [] | 192 | payloads: List[Dict[str, object]] = [] |
| 164 | for child in sorted(run_dir.iterdir()): | 193 | for child in sorted(run_dir.iterdir()): |
| @@ -183,6 +212,12 @@ class ChapterStorage: | @@ -183,6 +212,12 @@ class ChapterStorage: | ||
| 183 | 将流式输出实时写入raw文件。 | 212 | 将流式输出实时写入raw文件。 |
| 184 | 213 | ||
| 185 | 通过 contextmanager 暴露文件句柄,简化章节节点的写入逻辑。 | 214 | 通过 contextmanager 暴露文件句柄,简化章节节点的写入逻辑。 |
| 215 | + | ||
| 216 | + 参数: | ||
| 217 | + chapter_dir: 当前章节目录。 | ||
| 218 | + | ||
| 219 | + 返回: | ||
| 220 | + Generator[TextIO]: 作为上下文管理器使用的文件对象。 | ||
| 186 | """ | 221 | """ |
| 187 | raw_path = self._raw_stream_path(chapter_dir) | 222 | raw_path = self._raw_stream_path(chapter_dir) |
| 188 | raw_path.parent.mkdir(parents=True, exist_ok=True) | 223 | raw_path.parent.mkdir(parents=True, exist_ok=True) |
| @@ -36,6 +36,14 @@ class DocumentComposer: | @@ -36,6 +36,14 @@ class DocumentComposer: | ||
| 36 | 把所有章节按order排序并注入唯一锚点,形成整本IR。 | 36 | 把所有章节按order排序并注入唯一锚点,形成整本IR。 |
| 37 | 37 | ||
| 38 | 同时合并 metadata/themeTokens/assets,供渲染器直接消费。 | 38 | 同时合并 metadata/themeTokens/assets,供渲染器直接消费。 |
| 39 | + | ||
| 40 | + 参数: | ||
| 41 | + report_id: 本次报告ID。 | ||
| 42 | + metadata: 全局元信息(标题、主题、toc等)。 | ||
| 43 | + chapters: 章节payload列表。 | ||
| 44 | + | ||
| 45 | + 返回: | ||
| 46 | + dict: 满足渲染器需求的Document IR。 | ||
| 39 | """ | 47 | """ |
| 40 | ordered = sorted(chapters, key=lambda c: c.get("order", 0)) | 48 | ordered = sorted(chapters, key=lambda c: c.get("order", 0)) |
| 41 | for idx, chapter in enumerate(ordered, start=1): | 49 | for idx, chapter in enumerate(ordered, start=1): |
| @@ -63,6 +63,12 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]: | @@ -63,6 +63,12 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]: | ||
| 63 | 返回的每个TemplateSection都携带slug/order/章节号, | 63 | 返回的每个TemplateSection都携带slug/order/章节号, |
| 64 | 方便后续分章调用与锚点生成。解析时会同时兼容 | 64 | 方便后续分章调用与锚点生成。解析时会同时兼容 |
| 65 | “# 标题”“无符号编号”“列表提纲”等不同写法。 | 65 | “# 标题”“无符号编号”“列表提纲”等不同写法。 |
| 66 | + | ||
| 67 | + 参数: | ||
| 68 | + template_md: 模板Markdown全文。 | ||
| 69 | + | ||
| 70 | + 返回: | ||
| 71 | + list[TemplateSection]: 结构化的章节序列。 | ||
| 66 | """ | 72 | """ |
| 67 | 73 | ||
| 68 | sections: List[TemplateSection] = [] | 74 | sections: List[TemplateSection] = [] |
| @@ -113,6 +119,13 @@ def _classify_line(stripped: str, indent: int) -> Optional[dict]: | @@ -113,6 +119,13 @@ def _classify_line(stripped: str, indent: int) -> Optional[dict]: | ||
| 113 | 119 | ||
| 114 | 借助正则判断当前行是章节标题、提纲还是普通列表项, | 120 | 借助正则判断当前行是章节标题、提纲还是普通列表项, |
| 115 | 并衍生 depth/slug/number 等派生信息。 | 121 | 并衍生 depth/slug/number 等派生信息。 |
| 122 | + | ||
| 123 | + 参数: | ||
| 124 | + stripped: 去除前后空格后的原始行。 | ||
| 125 | + indent: 行首空格数量,用于区分层级。 | ||
| 126 | + | ||
| 127 | + 返回: | ||
| 128 | + dict | None: 识别后的元数据;无法识别时返回None。 | ||
| 116 | """ | 129 | """ |
| 117 | 130 | ||
| 118 | heading_match = heading_pattern.match(stripped) | 131 | heading_match = heading_pattern.match(stripped) |
| @@ -181,6 +194,12 @@ def _split_number(payload: str) -> dict: | @@ -181,6 +194,12 @@ def _split_number(payload: str) -> dict: | ||
| 181 | 194 | ||
| 182 | 例如 `1.2 市场趋势` 会被拆成 number=1.2、label=市场趋势, | 195 | 例如 `1.2 市场趋势` 会被拆成 number=1.2、label=市场趋势, |
| 183 | 并提供 display 用于回填标题。 | 196 | 并提供 display 用于回填标题。 |
| 197 | + | ||
| 198 | + 参数: | ||
| 199 | + payload: 原始标题字符串。 | ||
| 200 | + | ||
| 201 | + 返回: | ||
| 202 | + dict: 包含 number/title/display。 | ||
| 184 | """ | 203 | """ |
| 185 | match = number_pattern.match(payload) | 204 | match = number_pattern.match(payload) |
| 186 | number = match.group("num") if match else "" | 205 | number = match.group("num") if match else "" |
| @@ -196,7 +215,16 @@ def _split_number(payload: str) -> dict: | @@ -196,7 +215,16 @@ def _split_number(payload: str) -> dict: | ||
| 196 | 215 | ||
| 197 | 216 | ||
| 198 | def _build_slug(number: str, title: str) -> str: | 217 | def _build_slug(number: str, title: str) -> str: |
| 199 | - """根据编号/标题生成锚点,优先复用编号,缺失时对标题slug化。""" | 218 | + """ |
| 219 | + 根据编号/标题生成锚点,优先复用编号,缺失时对标题slug化。 | ||
| 220 | + | ||
| 221 | + 参数: | ||
| 222 | + number: 章节编号。 | ||
| 223 | + title: 标题文本。 | ||
| 224 | + | ||
| 225 | + 返回: | ||
| 226 | + str: 形如 `section-1-0` 的slug。 | ||
| 227 | + """ | ||
| 200 | if number: | 228 | if number: |
| 201 | token = number.replace(".", "-") | 229 | token = number.replace(".", "-") |
| 202 | else: | 230 | else: |
| @@ -223,6 +251,13 @@ def _ensure_unique_slug(slug: str, used: set) -> str: | @@ -223,6 +251,13 @@ def _ensure_unique_slug(slug: str, used: set) -> str: | ||
| 223 | 若slug重复则自动追加序号,直到在used集合中唯一。 | 251 | 若slug重复则自动追加序号,直到在used集合中唯一。 |
| 224 | 252 | ||
| 225 | 通过 `-2/-3...` 的方式保证相同标题不会产生重复锚点。 | 253 | 通过 `-2/-3...` 的方式保证相同标题不会产生重复锚点。 |
| 254 | + | ||
| 255 | + 参数: | ||
| 256 | + slug: 初始slug。 | ||
| 257 | + used: 已使用集合。 | ||
| 258 | + | ||
| 259 | + 返回: | ||
| 260 | + str: 去重后的slug。 | ||
| 226 | """ | 261 | """ |
| 227 | if slug not in used: | 262 | if slug not in used: |
| 228 | used.add(slug) | 263 | used.add(slug) |
| @@ -43,6 +43,12 @@ def _register_stream(task_id: str) -> Queue: | @@ -43,6 +43,12 @@ def _register_stream(task_id: str) -> Queue: | ||
| 43 | 为指定任务注册一个事件队列,供SSE监听器消费。 | 43 | 为指定任务注册一个事件队列,供SSE监听器消费。 |
| 44 | 44 | ||
| 45 | 返回的 Queue 会存入 `stream_subscribers`,SSE 生成器将不断读取。 | 45 | 返回的 Queue 会存入 `stream_subscribers`,SSE 生成器将不断读取。 |
| 46 | + | ||
| 47 | + 参数: | ||
| 48 | + task_id: 需要监听的任务ID。 | ||
| 49 | + | ||
| 50 | + 返回: | ||
| 51 | + Queue: 线程安全的事件队列。 | ||
| 46 | """ | 52 | """ |
| 47 | queue = Queue() | 53 | queue = Queue() |
| 48 | with stream_lock: | 54 | with stream_lock: |
| @@ -55,6 +61,10 @@ def _unregister_stream(task_id: str, queue: Queue): | @@ -55,6 +61,10 @@ def _unregister_stream(task_id: str, queue: Queue): | ||
| 55 | 安全移除事件队列,避免内存泄漏。 | 61 | 安全移除事件队列,避免内存泄漏。 |
| 56 | 62 | ||
| 57 | 需要在finally中调用,保证异常情况下资源也能释放。 | 63 | 需要在finally中调用,保证异常情况下资源也能释放。 |
| 64 | + | ||
| 65 | + 参数: | ||
| 66 | + task_id: 任务ID。 | ||
| 67 | + queue: 之前注册的事件队列。 | ||
| 58 | """ | 68 | """ |
| 59 | with stream_lock: | 69 | with stream_lock: |
| 60 | listeners = stream_subscribers.get(task_id, []) | 70 | listeners = stream_subscribers.get(task_id, []) |
| @@ -69,6 +79,10 @@ def _broadcast_event(task_id: str, event: Dict[str, Any]): | @@ -69,6 +79,10 @@ def _broadcast_event(task_id: str, event: Dict[str, Any]): | ||
| 69 | 将事件推送给所有监听者,失败时做好异常捕获。 | 79 | 将事件推送给所有监听者,失败时做好异常捕获。 |
| 70 | 80 | ||
| 71 | 采用浅拷贝监听列表,防止并发移除导致遍历异常。 | 81 | 采用浅拷贝监听列表,防止并发移除导致遍历异常。 |
| 82 | + | ||
| 83 | + 参数: | ||
| 84 | + task_id: 待推送的任务ID。 | ||
| 85 | + event: 结构化事件payload。 | ||
| 72 | """ | 86 | """ |
| 73 | with stream_lock: | 87 | with stream_lock: |
| 74 | listeners = list(stream_subscribers.get(task_id, [])) | 88 | listeners = list(stream_subscribers.get(task_id, [])) |
| @@ -84,6 +98,9 @@ def _prune_task_history_locked(): | @@ -84,6 +98,9 @@ def _prune_task_history_locked(): | ||
| 84 | 在task_lock持有期间调用,清理过多的历史任务。 | 98 | 在task_lock持有期间调用,清理过多的历史任务。 |
| 85 | 99 | ||
| 86 | 仅保留最近 `MAX_TASK_HISTORY` 个任务,避免长时间运行占用过多内存。 | 100 | 仅保留最近 `MAX_TASK_HISTORY` 个任务,避免长时间运行占用过多内存。 |
| 101 | + | ||
| 102 | + 说明: | ||
| 103 | + 该函数假设调用方已获取 `task_lock`,否则存在竞态风险。 | ||
| 87 | """ | 104 | """ |
| 88 | if len(tasks_registry) <= MAX_TASK_HISTORY: | 105 | if len(tasks_registry) <= MAX_TASK_HISTORY: |
| 89 | return | 106 | return |
| @@ -98,6 +115,12 @@ def _get_task(task_id: str) -> Optional['ReportTask']: | @@ -98,6 +115,12 @@ def _get_task(task_id: str) -> Optional['ReportTask']: | ||
| 98 | 统一的任务查找方法,优先返回当前任务。 | 115 | 统一的任务查找方法,优先返回当前任务。 |
| 99 | 116 | ||
| 100 | 避免重复写锁逻辑,便于多个API共享。 | 117 | 避免重复写锁逻辑,便于多个API共享。 |
| 118 | + | ||
| 119 | + 参数: | ||
| 120 | + task_id: 任务ID。 | ||
| 121 | + | ||
| 122 | + 返回: | ||
| 123 | + ReportTask | None: 命中时返回任务实例,否则为None。 | ||
| 101 | """ | 124 | """ |
| 102 | with task_lock: | 125 | with task_lock: |
| 103 | if current_task and current_task.task_id == task_id: | 126 | if current_task and current_task.task_id == task_id: |
| @@ -110,6 +133,12 @@ def _format_sse(event: Dict[str, Any]) -> str: | @@ -110,6 +133,12 @@ def _format_sse(event: Dict[str, Any]) -> str: | ||
| 110 | 按SSE协议格式化消息。 | 133 | 按SSE协议格式化消息。 |
| 111 | 134 | ||
| 112 | 输出形如 `id:/event:/data:` 的三段文本,供浏览器端直接消费。 | 135 | 输出形如 `id:/event:/data:` 的三段文本,供浏览器端直接消费。 |
| 136 | + | ||
| 137 | + 参数: | ||
| 138 | + event: 事件payload,至少包含 id/type。 | ||
| 139 | + | ||
| 140 | + 返回: | ||
| 141 | + str: SSE协议要求的字符串。 | ||
| 113 | """ | 142 | """ |
| 114 | payload = json.dumps(event, ensure_ascii=False) | 143 | payload = json.dumps(event, ensure_ascii=False) |
| 115 | event_id = event.get('id', 0) | 144 | event_id = event.get('id', 0) |
| @@ -122,6 +151,9 @@ def initialize_report_engine(): | @@ -122,6 +151,9 @@ def initialize_report_engine(): | ||
| 122 | 初始化Report Engine。 | 151 | 初始化Report Engine。 |
| 123 | 152 | ||
| 124 | 单例化 ReportAgent,方便 API 启动后直接接收任务。 | 153 | 单例化 ReportAgent,方便 API 启动后直接接收任务。 |
| 154 | + | ||
| 155 | + 返回: | ||
| 156 | + bool: 初始化成功返回True,异常时返回False。 | ||
| 125 | """ | 157 | """ |
| 126 | global report_agent | 158 | global report_agent |
| 127 | try: | 159 | try: |
| @@ -176,6 +208,11 @@ class ReportTask: | @@ -176,6 +208,11 @@ class ReportTask: | ||
| 176 | 更新任务状态并广播事件。 | 208 | 更新任务状态并广播事件。 |
| 177 | 209 | ||
| 178 | 会自动刷新 `updated_at`、错误信息,并触发 `status` 类型的 SSE。 | 210 | 会自动刷新 `updated_at`、错误信息,并触发 `status` 类型的 SSE。 |
| 211 | + | ||
| 212 | + 参数: | ||
| 213 | + status: 任务阶段(pending/running/completed/error/cancelled)。 | ||
| 214 | + progress: 可选的进度百分比。 | ||
| 215 | + error_message: 出错时的人类可读说明。 | ||
| 179 | """ | 216 | """ |
| 180 | self.status = status | 217 | self.status = status |
| 181 | if progress is not None: | 218 | if progress is not None: |
| @@ -214,7 +251,13 @@ class ReportTask: | @@ -214,7 +251,13 @@ class ReportTask: | ||
| 214 | } | 251 | } |
| 215 | 252 | ||
| 216 | def publish_event(self, event_type: str, payload: Dict[str, Any]) -> None: | 253 | def publish_event(self, event_type: str, payload: Dict[str, Any]) -> None: |
| 217 | - """将任意事件放入缓存并广播,所有新增逻辑均配套中文说明。""" | 254 | + """ |
| 255 | + 将任意事件放入缓存并广播,所有新增逻辑均配套中文说明。 | ||
| 256 | + | ||
| 257 | + 参数: | ||
| 258 | + event_type: SSE中的event名称。 | ||
| 259 | + payload: 实际业务数据。 | ||
| 260 | + """ | ||
| 218 | timestamp = datetime.utcnow().isoformat() + 'Z' | 261 | timestamp = datetime.utcnow().isoformat() + 'Z' |
| 219 | event: Dict[str, Any] = { | 262 | event: Dict[str, Any] = { |
| 220 | 'id': 0, | 263 | 'id': 0, |
| @@ -230,7 +273,15 @@ class ReportTask: | @@ -230,7 +273,15 @@ class ReportTask: | ||
| 230 | _broadcast_event(self.task_id, event) | 273 | _broadcast_event(self.task_id, event) |
| 231 | 274 | ||
| 232 | def history_since(self, last_event_id: Optional[int]) -> List[Dict[str, Any]]: | 275 | def history_since(self, last_event_id: Optional[int]) -> List[Dict[str, Any]]: |
| 233 | - """根据Last-Event-ID补发历史事件,确保断线重连无遗漏。""" | 276 | + """ |
| 277 | + 根据Last-Event-ID补发历史事件,确保断线重连无遗漏。 | ||
| 278 | + | ||
| 279 | + 参数: | ||
| 280 | + last_event_id: SSE客户端记录的最后一个事件ID。 | ||
| 281 | + | ||
| 282 | + 返回: | ||
| 283 | + list[dict]: 从 last_event_id 之后的事件列表。 | ||
| 284 | + """ | ||
| 234 | with self._event_lock: | 285 | with self._event_lock: |
| 235 | if last_event_id is None: | 286 | if last_event_id is None: |
| 236 | return list(self.event_history) | 287 | return list(self.event_history) |
| @@ -272,6 +323,11 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " | @@ -272,6 +323,11 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " | ||
| 272 | 323 | ||
| 273 | 包括:检查输入→加载文档→调用ReportAgent→持久化输出→ | 324 | 包括:检查输入→加载文档→调用ReportAgent→持久化输出→ |
| 274 | 推送阶段性事件。出现错误会自动推送并写状态。 | 325 | 推送阶段性事件。出现错误会自动推送并写状态。 |
| 326 | + | ||
| 327 | + 参数: | ||
| 328 | + task: 本次任务对象,内部持有事件队列。 | ||
| 329 | + query: 报告主题。 | ||
| 330 | + custom_template: 可选的自定义模板字符串。 | ||
| 275 | """ | 331 | """ |
| 276 | global current_task | 332 | global current_task |
| 277 | 333 | ||
| @@ -385,7 +441,12 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " | @@ -385,7 +441,12 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " | ||
| 385 | 441 | ||
| 386 | @report_bp.route('/status', methods=['GET']) | 442 | @report_bp.route('/status', methods=['GET']) |
| 387 | def get_status(): | 443 | def get_status(): |
| 388 | - """获取Report Engine状态,包括引擎就绪情况与当前任务信息。""" | 444 | + """ |
| 445 | + 获取Report Engine状态,包括引擎就绪情况与当前任务信息。 | ||
| 446 | + | ||
| 447 | + 返回: | ||
| 448 | + Response: JSON结构包含initialized/engines_ready/当前任务等。 | ||
| 449 | + """ | ||
| 389 | try: | 450 | try: |
| 390 | engines_status = check_engines_ready() | 451 | engines_status = check_engines_ready() |
| 391 | 452 | ||
| @@ -411,6 +472,13 @@ def generate_report(): | @@ -411,6 +472,13 @@ def generate_report(): | ||
| 411 | 开始生成报告。 | 472 | 开始生成报告。 |
| 412 | 473 | ||
| 413 | 负责排队、创建后台线程、清空日志并返回SSE地址。 | 474 | 负责排队、创建后台线程、清空日志并返回SSE地址。 |
| 475 | + | ||
| 476 | + 请求体: | ||
| 477 | + query: 报告主题(可选)。 | ||
| 478 | + custom_template: 自定义模板字符串(可选)。 | ||
| 479 | + | ||
| 480 | + 返回: | ||
| 481 | + Response: JSON,包含 task_id 与 SSE stream url。 | ||
| 414 | """ | 482 | """ |
| 415 | global current_task | 483 | global current_task |
| 416 | 484 | ||
| @@ -498,7 +566,15 @@ def generate_report(): | @@ -498,7 +566,15 @@ def generate_report(): | ||
| 498 | 566 | ||
| 499 | @report_bp.route('/progress/<task_id>', methods=['GET']) | 567 | @report_bp.route('/progress/<task_id>', methods=['GET']) |
| 500 | def get_progress(task_id: str): | 568 | def get_progress(task_id: str): |
| 501 | - """获取报告生成进度,若任务被清理则返回一个完成态兜底。""" | 569 | + """ |
| 570 | + 获取报告生成进度,若任务被清理则返回一个完成态兜底。 | ||
| 571 | + | ||
| 572 | + 参数: | ||
| 573 | + task_id: 任务唯一标识。 | ||
| 574 | + | ||
| 575 | + 返回: | ||
| 576 | + Response: JSON包含任务当前状态。 | ||
| 577 | + """ | ||
| 502 | try: | 578 | try: |
| 503 | task = _get_task(task_id) | 579 | task = _get_task(task_id) |
| 504 | if not task: | 580 | if not task: |
| @@ -540,6 +616,12 @@ def stream_task(task_id: str): | @@ -540,6 +616,12 @@ def stream_task(task_id: str): | ||
| 540 | - 自动补发Last-Event-ID之后的历史事件; | 616 | - 自动补发Last-Event-ID之后的历史事件; |
| 541 | - 周期性发送心跳以防代理中断; | 617 | - 周期性发送心跳以防代理中断; |
| 542 | - 任务结束后自动注销监听。 | 618 | - 任务结束后自动注销监听。 |
| 619 | + | ||
| 620 | + 参数: | ||
| 621 | + task_id: 任务唯一标识。 | ||
| 622 | + | ||
| 623 | + 返回: | ||
| 624 | + Response: `text/event-stream` 类型响应。 | ||
| 543 | """ | 625 | """ |
| 544 | task = _get_task(task_id) | 626 | task = _get_task(task_id) |
| 545 | if not task: | 627 | if not task: |
| @@ -592,7 +674,15 @@ def stream_task(task_id: str): | @@ -592,7 +674,15 @@ def stream_task(task_id: str): | ||
| 592 | 674 | ||
| 593 | @report_bp.route('/result/<task_id>', methods=['GET']) | 675 | @report_bp.route('/result/<task_id>', methods=['GET']) |
| 594 | def get_result(task_id: str): | 676 | def get_result(task_id: str): |
| 595 | - """获取报告生成结果""" | 677 | + """ |
| 678 | + 获取报告生成结果。 | ||
| 679 | + | ||
| 680 | + 参数: | ||
| 681 | + task_id: 任务ID。 | ||
| 682 | + | ||
| 683 | + 返回: | ||
| 684 | + Response: JSON,包含HTML预览与文件路径。 | ||
| 685 | + """ | ||
| 596 | try: | 686 | try: |
| 597 | task = _get_task(task_id) | 687 | task = _get_task(task_id) |
| 598 | if not task: | 688 | if not task: |
| @@ -655,7 +745,15 @@ def get_result_json(task_id: str): | @@ -655,7 +745,15 @@ def get_result_json(task_id: str): | ||
| 655 | 745 | ||
| 656 | @report_bp.route('/download/<task_id>', methods=['GET']) | 746 | @report_bp.route('/download/<task_id>', methods=['GET']) |
| 657 | def download_report(task_id: str): | 747 | def download_report(task_id: str): |
| 658 | - """下载已生成的报告HTML文件""" | 748 | + """ |
| 749 | + 下载已生成的报告HTML文件。 | ||
| 750 | + | ||
| 751 | + 参数: | ||
| 752 | + task_id: 任务ID。 | ||
| 753 | + | ||
| 754 | + 返回: | ||
| 755 | + Response: HTML文件的附件下载响应。 | ||
| 756 | + """ | ||
| 659 | try: | 757 | try: |
| 660 | task = _get_task(task_id) | 758 | task = _get_task(task_id) |
| 661 | if not task: | 759 | if not task: |
| @@ -694,7 +792,15 @@ def download_report(task_id: str): | @@ -694,7 +792,15 @@ def download_report(task_id: str): | ||
| 694 | 792 | ||
| 695 | @report_bp.route('/cancel/<task_id>', methods=['POST']) | 793 | @report_bp.route('/cancel/<task_id>', methods=['POST']) |
| 696 | def cancel_task(task_id: str): | 794 | def cancel_task(task_id: str): |
| 697 | - """取消报告生成任务""" | 795 | + """ |
| 796 | + 取消报告生成任务。 | ||
| 797 | + | ||
| 798 | + 参数: | ||
| 799 | + task_id: 需要被取消的任务ID。 | ||
| 800 | + | ||
| 801 | + 返回: | ||
| 802 | + Response: JSON,包含取消结果或错误信息。 | ||
| 803 | + """ | ||
| 698 | global current_task | 804 | global current_task |
| 699 | 805 | ||
| 700 | try: | 806 | try: |
| @@ -735,7 +841,12 @@ def cancel_task(task_id: str): | @@ -735,7 +841,12 @@ def cancel_task(task_id: str): | ||
| 735 | 841 | ||
| 736 | @report_bp.route('/templates', methods=['GET']) | 842 | @report_bp.route('/templates', methods=['GET']) |
| 737 | def get_templates(): | 843 | def get_templates(): |
| 738 | - """获取可用模板列表,便于前端展示可选Markdown骨架。""" | 844 | + """ |
| 845 | + 获取可用模板列表,便于前端展示可选Markdown骨架。 | ||
| 846 | + | ||
| 847 | + 返回: | ||
| 848 | + Response: JSON,列出模板名称/描述/大小。 | ||
| 849 | + """ | ||
| 739 | try: | 850 | try: |
| 740 | if not report_agent: | 851 | if not report_agent: |
| 741 | return jsonify({ | 852 | return jsonify({ |
| @@ -799,7 +910,12 @@ def internal_error(error): | @@ -799,7 +910,12 @@ def internal_error(error): | ||
| 799 | 910 | ||
| 800 | 911 | ||
| 801 | def clear_report_log(): | 912 | def clear_report_log(): |
| 802 | - """清空report.log文件,方便新任务只查看本次运行日志。""" | 913 | + """ |
| 914 | + 清空report.log文件,方便新任务只查看本次运行日志。 | ||
| 915 | + | ||
| 916 | + 返回: | ||
| 917 | + None | ||
| 918 | + """ | ||
| 803 | try: | 919 | try: |
| 804 | log_file = settings.LOG_FILE | 920 | log_file = settings.LOG_FILE |
| 805 | with open(log_file, 'w', encoding='utf-8') as f: | 921 | with open(log_file, 'w', encoding='utf-8') as f: |
| @@ -811,7 +927,12 @@ def clear_report_log(): | @@ -811,7 +927,12 @@ def clear_report_log(): | ||
| 811 | 927 | ||
| 812 | @report_bp.route('/log', methods=['GET']) | 928 | @report_bp.route('/log', methods=['GET']) |
| 813 | def get_report_log(): | 929 | def get_report_log(): |
| 814 | - """获取report.log内容,并按行去除空白返回。""" | 930 | + """ |
| 931 | + 获取report.log内容,并按行去除空白返回。 | ||
| 932 | + | ||
| 933 | + 返回: | ||
| 934 | + Response: JSON,包含最新日志行数组。 | ||
| 935 | + """ | ||
| 815 | try: | 936 | try: |
| 816 | log_file = settings.LOG_FILE | 937 | log_file = settings.LOG_FILE |
| 817 | 938 | ||
| @@ -842,7 +963,12 @@ def get_report_log(): | @@ -842,7 +963,12 @@ def get_report_log(): | ||
| 842 | 963 | ||
| 843 | @report_bp.route('/log/clear', methods=['POST']) | 964 | @report_bp.route('/log/clear', methods=['POST']) |
| 844 | def clear_log(): | 965 | def clear_log(): |
| 845 | - """手动清空日志,提供REST入口供前端一键重置。""" | 966 | + """ |
| 967 | + 手动清空日志,提供REST入口供前端一键重置。 | ||
| 968 | + | ||
| 969 | + 返回: | ||
| 970 | + Response: JSON,标记是否清理成功。 | ||
| 971 | + """ | ||
| 846 | try: | 972 | try: |
| 847 | clear_report_log() | 973 | clear_report_log() |
| 848 | return jsonify({ | 974 | return jsonify({ |
| @@ -101,15 +101,15 @@ class LLMClient: | @@ -101,15 +101,15 @@ class LLMClient: | ||
| 101 | 101 | ||
| 102 | def stream_invoke(self, system_prompt: str, user_prompt: str, **kwargs) -> Generator[str, None, None]: | 102 | def stream_invoke(self, system_prompt: str, user_prompt: str, **kwargs) -> Generator[str, None, None]: |
| 103 | """ | 103 | """ |
| 104 | - 流式调用LLM,逐步返回响应内容 | 104 | + 流式调用LLM,逐步返回响应内容。 |
| 105 | 105 | ||
| 106 | - Args: | ||
| 107 | - system_prompt: 系统提示词 | ||
| 108 | - user_prompt: 用户提示词 | ||
| 109 | - **kwargs: 额外参数(temperature, top_p等) | 106 | + 参数: |
| 107 | + system_prompt: 系统提示词。 | ||
| 108 | + user_prompt: 用户提示词。 | ||
| 109 | + **kwargs: 采样参数(temperature、top_p等)。 | ||
| 110 | 110 | ||
| 111 | - Yields: | ||
| 112 | - 响应文本块(str),调用方可边读边写入磁盘或透传到UI | 111 | + 产出: |
| 112 | + str: 每次yield一段delta文本,方便上层实时渲染。 | ||
| 113 | """ | 113 | """ |
| 114 | messages = [ | 114 | messages = [ |
| 115 | {"role": "system", "content": system_prompt}, | 115 | {"role": "system", "content": system_prompt}, |
| @@ -143,15 +143,15 @@ class LLMClient: | @@ -143,15 +143,15 @@ class LLMClient: | ||
| 143 | @with_retry(LLM_RETRY_CONFIG) | 143 | @with_retry(LLM_RETRY_CONFIG) |
| 144 | def stream_invoke_to_string(self, system_prompt: str, user_prompt: str, **kwargs) -> str: | 144 | def stream_invoke_to_string(self, system_prompt: str, user_prompt: str, **kwargs) -> str: |
| 145 | """ | 145 | """ |
| 146 | - 流式调用LLM并安全地拼接为完整字符串(避免UTF-8多字节字符截断) | 146 | + 流式调用LLM并安全地拼接为完整字符串(避免UTF-8多字节字符截断)。 |
| 147 | 147 | ||
| 148 | - Args: | ||
| 149 | - system_prompt: 系统提示词 | ||
| 150 | - user_prompt: 用户提示词 | ||
| 151 | - **kwargs: 额外参数(temperature, top_p等) | 148 | + 参数: |
| 149 | + system_prompt: 系统提示词。 | ||
| 150 | + user_prompt: 用户提示词。 | ||
| 151 | + **kwargs: 采样或超时配置。 | ||
| 152 | 152 | ||
| 153 | - Returns: | ||
| 154 | - 完整的响应字符串 | 153 | + 返回: |
| 154 | + str: 将所有delta拼接后的完整响应。 | ||
| 155 | """ | 155 | """ |
| 156 | # 以字节形式收集所有块 | 156 | # 以字节形式收集所有块 |
| 157 | byte_chunks = [] | 157 | byte_chunks = [] |
| @@ -107,7 +107,23 @@ class ChapterGenerationNode(BaseNode): | @@ -107,7 +107,23 @@ class ChapterGenerationNode(BaseNode): | ||
| 107 | stream_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None, | 107 | stream_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None, |
| 108 | **kwargs, | 108 | **kwargs, |
| 109 | ) -> Dict[str, Any]: | 109 | ) -> Dict[str, Any]: |
| 110 | - """针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果""" | 110 | + """ |
| 111 | + 针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果。 | ||
| 112 | + | ||
| 113 | + 参数: | ||
| 114 | + section: 模板切片生成的章节对象,包含标题/顺序/slug。 | ||
| 115 | + context: Agent构造的共享上下文(主题、篇幅、布局等)。 | ||
| 116 | + run_dir: 章节存盘目录,由 `ChapterStorage.start_session` 返回。 | ||
| 117 | + stream_callback: 可选流式回调,将LLM delta 推送给前端。 | ||
| 118 | + **kwargs: 透传温度、top_p等采样参数。 | ||
| 119 | + | ||
| 120 | + 返回: | ||
| 121 | + dict: 通过IR校验的章节JSON。 | ||
| 122 | + | ||
| 123 | + 异常: | ||
| 124 | + ChapterJsonParseError: 多次尝试后仍无法解析合法JSON。 | ||
| 125 | + ChapterContentError: 正文密度不足或只有标题,需要触发重试。 | ||
| 126 | + """ | ||
| 111 | chapter_meta = { | 127 | chapter_meta = { |
| 112 | "chapterId": section.chapter_id, | 128 | "chapterId": section.chapter_id, |
| 113 | "slug": section.slug, | 129 | "slug": section.slug, |
| @@ -167,7 +183,16 @@ class ChapterGenerationNode(BaseNode): | @@ -167,7 +183,16 @@ class ChapterGenerationNode(BaseNode): | ||
| 167 | # ====== 内部方法 ====== | 183 | # ====== 内部方法 ====== |
| 168 | 184 | ||
| 169 | def _build_payload(self, section: TemplateSection, context: Dict[str, Any]) -> Dict[str, Any]: | 185 | def _build_payload(self, section: TemplateSection, context: Dict[str, Any]) -> Dict[str, Any]: |
| 170 | - """构造LLM输入payload""" | 186 | + """ |
| 187 | + 构造LLM输入payload。 | ||
| 188 | + | ||
| 189 | + 参数: | ||
| 190 | + section: 当前要生成的章节,提供标题/编号/提纲。 | ||
| 191 | + context: 全局上下文字典,包含主题、三引擎报告、篇幅规划等。 | ||
| 192 | + | ||
| 193 | + 返回: | ||
| 194 | + dict: 可以直接序列化进提示词的payload,兼顾章节信息与全局约束。 | ||
| 195 | + """ | ||
| 171 | reports = context.get("reports", {}) | 196 | reports = context.get("reports", {}) |
| 172 | # 章节篇幅规划(来自WordBudgetNode),用于指导字数与强调点 | 197 | # 章节篇幅规划(来自WordBudgetNode),用于指导字数与强调点 |
| 173 | chapter_plan_map = context.get("chapter_directives", {}) | 198 | chapter_plan_map = context.get("chapter_directives", {}) |
| @@ -233,7 +258,19 @@ class ChapterGenerationNode(BaseNode): | @@ -233,7 +258,19 @@ class ChapterGenerationNode(BaseNode): | ||
| 233 | section_meta: Optional[Dict[str, Any]] = None, | 258 | section_meta: Optional[Dict[str, Any]] = None, |
| 234 | **kwargs, | 259 | **kwargs, |
| 235 | ) -> str: | 260 | ) -> str: |
| 236 | - """流式调用LLM并实时写入raw文件,同时通过回调将delta抛出。""" | 261 | + """ |
| 262 | + 流式调用LLM并实时写入raw文件,同时通过回调将delta抛出。 | ||
| 263 | + | ||
| 264 | + 参数: | ||
| 265 | + user_message: 拼装好的用户提示词。 | ||
| 266 | + chapter_dir: 章节的本地缓存目录,用于存放 stream.raw。 | ||
| 267 | + stream_callback: SSE流式推送的回调函数。 | ||
| 268 | + section_meta: 附带的章节ID/标题,用于回调payload。 | ||
| 269 | + **kwargs: 透传温度、top_p等参数。 | ||
| 270 | + | ||
| 271 | + 返回: | ||
| 272 | + str: 将所有delta拼接后的原始文本。 | ||
| 273 | + """ | ||
| 237 | chunks: List[str] = [] | 274 | chunks: List[str] = [] |
| 238 | with self.storage.capture_stream(chapter_dir) as stream_fp: | 275 | with self.storage.capture_stream(chapter_dir) as stream_fp: |
| 239 | stream = self.llm_client.stream_invoke( | 276 | stream = self.llm_client.stream_invoke( |
| @@ -254,7 +291,18 @@ class ChapterGenerationNode(BaseNode): | @@ -254,7 +291,18 @@ class ChapterGenerationNode(BaseNode): | ||
| 254 | return "".join(chunks) | 291 | return "".join(chunks) |
| 255 | 292 | ||
| 256 | def _parse_chapter(self, raw_text: str) -> Dict[str, Any]: | 293 | def _parse_chapter(self, raw_text: str) -> Dict[str, Any]: |
| 257 | - """清洗LLM输出并解析JSON""" | 294 | + """ |
| 295 | + 清洗LLM输出并解析JSON。 | ||
| 296 | + | ||
| 297 | + 参数: | ||
| 298 | + raw_text: LLM原始输出(可能包含```包裹或额外说明)。 | ||
| 299 | + | ||
| 300 | + 返回: | ||
| 301 | + dict: 章节JSON对象,至少包含 chapterId/title/blocks。 | ||
| 302 | + | ||
| 303 | + 异常: | ||
| 304 | + ChapterJsonParseError: 多种修复策略仍无法解析合法JSON。 | ||
| 305 | + """ | ||
| 258 | cleaned = raw_text.strip() | 306 | cleaned = raw_text.strip() |
| 259 | if cleaned.startswith("```json"): | 307 | if cleaned.startswith("```json"): |
| 260 | cleaned = cleaned[7:] | 308 | cleaned = cleaned[7:] |
| @@ -304,7 +352,15 @@ class ChapterGenerationNode(BaseNode): | @@ -304,7 +352,15 @@ class ChapterGenerationNode(BaseNode): | ||
| 304 | raise ValueError("章节JSON缺少chapter字段") | 352 | raise ValueError("章节JSON缺少chapter字段") |
| 305 | 353 | ||
| 306 | def _repair_llm_json(self, text: str) -> str: | 354 | def _repair_llm_json(self, text: str) -> str: |
| 307 | - """处理常见的LLM错误(如\":=导致的非法JSON)""" | 355 | + """ |
| 356 | + 处理常见的LLM错误(如":=导致的非法JSON)。 | ||
| 357 | + | ||
| 358 | + 参数: | ||
| 359 | + text: 原始章节JSON文本。 | ||
| 360 | + | ||
| 361 | + 返回: | ||
| 362 | + str: 修复后的文本;若未做改动则返回原内容。 | ||
| 363 | + """ | ||
| 308 | repaired = text | 364 | repaired = text |
| 309 | mutated = False | 365 | mutated = False |
| 310 | 366 | ||
| @@ -482,7 +538,12 @@ class ChapterGenerationNode(BaseNode): | @@ -482,7 +538,12 @@ class ChapterGenerationNode(BaseNode): | ||
| 482 | return fixed | 538 | return fixed |
| 483 | 539 | ||
| 484 | def _sanitize_chapter_blocks(self, chapter: Dict[str, Any]): | 540 | def _sanitize_chapter_blocks(self, chapter: Dict[str, Any]): |
| 485 | - """修正常见的结构性错误(例如list.items嵌套过深)""" | 541 | + """ |
| 542 | + 修正常见的结构性错误(例如list.items嵌套过深)。 | ||
| 543 | + | ||
| 544 | + 参数: | ||
| 545 | + chapter: 章节JSON对象,会在原地被清理和规整。 | ||
| 546 | + """ | ||
| 486 | 547 | ||
| 487 | def walk(blocks: List[Dict[str, Any]] | None): | 548 | def walk(blocks: List[Dict[str, Any]] | None): |
| 488 | """递归检查并修复嵌套结构,保证每个block合法""" | 549 | """递归检查并修复嵌套结构,保证每个block合法""" |
| @@ -527,6 +588,12 @@ class ChapterGenerationNode(BaseNode): | @@ -527,6 +588,12 @@ class ChapterGenerationNode(BaseNode): | ||
| 527 | 588 | ||
| 528 | 若blocks缺失、除标题外无有效区块,或正文字符数低于阈值, | 589 | 若blocks缺失、除标题外无有效区块,或正文字符数低于阈值, |
| 529 | 则视为章节内容异常,触发ChapterContentError以便上游重试。 | 590 | 则视为章节内容异常,触发ChapterContentError以便上游重试。 |
| 591 | + | ||
| 592 | + 参数: | ||
| 593 | + chapter: 当前章节JSON。 | ||
| 594 | + | ||
| 595 | + 异常: | ||
| 596 | + ChapterContentError: 当正文区块数量或字符数达不到下限时抛出。 | ||
| 530 | """ | 597 | """ |
| 531 | blocks = chapter.get("blocks") | 598 | blocks = chapter.get("blocks") |
| 532 | if not isinstance(blocks, list) or not blocks: | 599 | if not isinstance(blocks, list) or not blocks: |
| @@ -552,6 +619,12 @@ class ChapterGenerationNode(BaseNode): | @@ -552,6 +619,12 @@ class ChapterGenerationNode(BaseNode): | ||
| 552 | - 忽略heading/divider/widget等非正文类型; | 619 | - 忽略heading/divider/widget等非正文类型; |
| 553 | - 对paragraph/list/table/callout等结构抽取嵌套文本; | 620 | - 对paragraph/list/table/callout等结构抽取嵌套文本; |
| 554 | - 仅用于粗粒度判断篇幅是否合理。 | 621 | - 仅用于粗粒度判断篇幅是否合理。 |
| 622 | + | ||
| 623 | + 参数: | ||
| 624 | + blocks: 章节的 blocks 列表或子树。 | ||
| 625 | + | ||
| 626 | + 返回: | ||
| 627 | + int: 估算的正文字符数量。 | ||
| 555 | """ | 628 | """ |
| 556 | 629 | ||
| 557 | def walk(node: Any) -> int: | 630 | def walk(node: Any) -> int: |
| @@ -37,7 +37,20 @@ class DocumentLayoutNode(BaseNode): | @@ -37,7 +37,20 @@ class DocumentLayoutNode(BaseNode): | ||
| 37 | query: str, | 37 | query: str, |
| 38 | template_overview: Dict[str, Any] | None = None, | 38 | template_overview: Dict[str, Any] | None = None, |
| 39 | ) -> Dict[str, Any]: | 39 | ) -> Dict[str, Any]: |
| 40 | - """综合模板+多源内容,生成全书的标题、目录结构与主题色板""" | 40 | + """ |
| 41 | + 综合模板+多源内容,生成全书的标题、目录结构与主题色板。 | ||
| 42 | + | ||
| 43 | + 参数: | ||
| 44 | + sections: 模板切片后的章节列表。 | ||
| 45 | + template_markdown: 模板原文,用于LLM理解上下文。 | ||
| 46 | + reports: 三个引擎的内容映射。 | ||
| 47 | + forum_logs: 论坛讨论摘要。 | ||
| 48 | + query: 用户查询词。 | ||
| 49 | + template_overview: 预生成的模板概览,可复用以减少提示词长度。 | ||
| 50 | + | ||
| 51 | + 返回: | ||
| 52 | + dict: 包含 title/subtitle/toc/hero/themeTokens 等设计信息的字典。 | ||
| 53 | + """ | ||
| 41 | # 将模板原文、切片结构与多源报告一并喂给LLM,便于其理解层级与素材 | 54 | # 将模板原文、切片结构与多源报告一并喂给LLM,便于其理解层级与素材 |
| 42 | payload = { | 55 | payload = { |
| 43 | "query": query, | 56 | "query": query, |
| @@ -66,7 +79,18 @@ class DocumentLayoutNode(BaseNode): | @@ -66,7 +79,18 @@ class DocumentLayoutNode(BaseNode): | ||
| 66 | return design | 79 | return design |
| 67 | 80 | ||
| 68 | def _parse_response(self, raw: str) -> Dict[str, Any]: | 81 | def _parse_response(self, raw: str) -> Dict[str, Any]: |
| 69 | - """解析LLM返回的JSON文本,若失败则抛出友好错误""" | 82 | + """ |
| 83 | + 解析LLM返回的JSON文本,若失败则抛出友好错误。 | ||
| 84 | + | ||
| 85 | + 参数: | ||
| 86 | + raw: LLM原始返回字符串,允许带```包裹。 | ||
| 87 | + | ||
| 88 | + 返回: | ||
| 89 | + dict: 结构化的设计稿。 | ||
| 90 | + | ||
| 91 | + 异常: | ||
| 92 | + ValueError: 当响应为空或JSON解析失败时抛出。 | ||
| 93 | + """ | ||
| 70 | cleaned = raw.strip() | 94 | cleaned = raw.strip() |
| 71 | if cleaned.startswith("```json"): | 95 | if cleaned.startswith("```json"): |
| 72 | cleaned = cleaned[7:] | 96 | cleaned = cleaned[7:] |
| @@ -79,6 +79,15 @@ class TemplateSelectionNode(BaseNode): | @@ -79,6 +79,15 @@ class TemplateSelectionNode(BaseNode): | ||
| 79 | 79 | ||
| 80 | 构造模板列表与报告摘要 → 调用LLM → 解析JSON → | 80 | 构造模板列表与报告摘要 → 调用LLM → 解析JSON → |
| 81 | 验证模板是否存在并返回标准结构。 | 81 | 验证模板是否存在并返回标准结构。 |
| 82 | + | ||
| 83 | + 参数: | ||
| 84 | + query: 用户输入的主题词。 | ||
| 85 | + reports: 多个分析引擎的报告内容。 | ||
| 86 | + forum_logs: 论坛日志,可能为空。 | ||
| 87 | + available_templates: 本地可用模板清单。 | ||
| 88 | + | ||
| 89 | + 返回: | ||
| 90 | + dict | None: 若LLM成功返回合法结果则包含模板信息,否则为None。 | ||
| 82 | """ | 91 | """ |
| 83 | logger.info("尝试使用LLM进行模板选择...") | 92 | logger.info("尝试使用LLM进行模板选择...") |
| 84 | 93 | ||
| @@ -166,6 +175,12 @@ class TemplateSelectionNode(BaseNode): | @@ -166,6 +175,12 @@ class TemplateSelectionNode(BaseNode): | ||
| 166 | 清理LLM响应。 | 175 | 清理LLM响应。 |
| 167 | 176 | ||
| 168 | 去掉 ```json``` 包裹以及前后空白,方便 `json.loads`。 | 177 | 去掉 ```json``` 包裹以及前后空白,方便 `json.loads`。 |
| 178 | + | ||
| 179 | + 参数: | ||
| 180 | + response: LLM原始响应。 | ||
| 181 | + | ||
| 182 | + 返回: | ||
| 183 | + str: 适合直接做JSON解析的纯文本。 | ||
| 169 | """ | 184 | """ |
| 170 | # 移除可能的markdown代码块标记 | 185 | # 移除可能的markdown代码块标记 |
| 171 | if '```json' in response: | 186 | if '```json' in response: |
| @@ -183,6 +198,13 @@ class TemplateSelectionNode(BaseNode): | @@ -183,6 +198,13 @@ class TemplateSelectionNode(BaseNode): | ||
| 183 | 从文本响应中提取模板信息。 | 198 | 从文本响应中提取模板信息。 |
| 184 | 199 | ||
| 185 | 当LLM未输出合法JSON时,尝试匹配模板名称关键字做降级。 | 200 | 当LLM未输出合法JSON时,尝试匹配模板名称关键字做降级。 |
| 201 | + | ||
| 202 | + 参数: | ||
| 203 | + response: 非结构化的LLM文本。 | ||
| 204 | + available_templates: 可选模板列表。 | ||
| 205 | + | ||
| 206 | + 返回: | ||
| 207 | + dict | None: 匹配成功时返回模板详情,否则为None。 | ||
| 186 | """ | 208 | """ |
| 187 | logger.info("尝试从文本响应中提取模板信息") | 209 | logger.info("尝试从文本响应中提取模板信息") |
| 188 | 210 | ||
| @@ -210,6 +232,9 @@ class TemplateSelectionNode(BaseNode): | @@ -210,6 +232,9 @@ class TemplateSelectionNode(BaseNode): | ||
| 210 | 获取可用的模板列表。 | 232 | 获取可用的模板列表。 |
| 211 | 233 | ||
| 212 | 枚举模板目录下的 `.md` 文件并读取内容与描述字段。 | 234 | 枚举模板目录下的 `.md` 文件并读取内容与描述字段。 |
| 235 | + | ||
| 236 | + 返回: | ||
| 237 | + list[dict]: 每项包含 name/path/content/description。 | ||
| 213 | """ | 238 | """ |
| 214 | templates = [] | 239 | templates = [] |
| 215 | 240 | ||
| @@ -259,7 +284,12 @@ class TemplateSelectionNode(BaseNode): | @@ -259,7 +284,12 @@ class TemplateSelectionNode(BaseNode): | ||
| 259 | 284 | ||
| 260 | 285 | ||
| 261 | def _get_fallback_template(self) -> Dict[str, Any]: | 286 | def _get_fallback_template(self) -> Dict[str, Any]: |
| 262 | - """获取备用默认模板(空模板,让LLM自行发挥)。""" | 287 | + """ |
| 288 | + 获取备用默认模板(空模板,让LLM自行发挥)。 | ||
| 289 | + | ||
| 290 | + 返回: | ||
| 291 | + dict: 结构体字段与LLM返回一致,方便直接替换。 | ||
| 292 | + """ | ||
| 263 | logger.info("未找到合适模板,使用空模板让LLM自行发挥") | 293 | logger.info("未找到合适模板,使用空模板让LLM自行发挥") |
| 264 | 294 | ||
| 265 | return { | 295 | return { |
| @@ -37,7 +37,20 @@ class WordBudgetNode(BaseNode): | @@ -37,7 +37,20 @@ class WordBudgetNode(BaseNode): | ||
| 37 | query: str, | 37 | query: str, |
| 38 | template_overview: Dict[str, Any] | None = None, | 38 | template_overview: Dict[str, Any] | None = None, |
| 39 | ) -> Dict[str, Any]: | 39 | ) -> Dict[str, Any]: |
| 40 | - """根据设计稿和所有素材规划章节字数,让LLM写作时有明确篇幅目标""" | 40 | + """ |
| 41 | + 根据设计稿和所有素材规划章节字数,让LLM写作时有明确篇幅目标。 | ||
| 42 | + | ||
| 43 | + 参数: | ||
| 44 | + sections: 模板章节列表。 | ||
| 45 | + design: 布局节点返回的设计稿(title/toc/hero等)。 | ||
| 46 | + reports: 三引擎报告映射。 | ||
| 47 | + forum_logs: 论坛日志原文。 | ||
| 48 | + query: 用户查询词。 | ||
| 49 | + template_overview: 可选的模板概览,含章节元信息。 | ||
| 50 | + | ||
| 51 | + 返回: | ||
| 52 | + dict: 章节篇幅规划结果,包含 `totalWords`、`globalGuidelines` 与逐章 `chapters`。 | ||
| 53 | + """ | ||
| 41 | # 输入中除了章节骨架外,还包含布局节点输出,方便约束篇幅时参考视觉主次 | 54 | # 输入中除了章节骨架外,还包含布局节点输出,方便约束篇幅时参考视觉主次 |
| 42 | payload = { | 55 | payload = { |
| 43 | "query": query, | 56 | "query": query, |
| @@ -63,7 +76,18 @@ class WordBudgetNode(BaseNode): | @@ -63,7 +76,18 @@ class WordBudgetNode(BaseNode): | ||
| 63 | return plan | 76 | return plan |
| 64 | 77 | ||
| 65 | def _parse_response(self, raw: str) -> Dict[str, Any]: | 78 | def _parse_response(self, raw: str) -> Dict[str, Any]: |
| 66 | - """将LLM输出的JSON文本转为字典,失败时提示规划异常""" | 79 | + """ |
| 80 | + 将LLM输出的JSON文本转为字典,失败时提示规划异常。 | ||
| 81 | + | ||
| 82 | + 参数: | ||
| 83 | + raw: LLM返回值,可能包含```包裹。 | ||
| 84 | + | ||
| 85 | + 返回: | ||
| 86 | + dict: 合法的篇幅规划JSON。 | ||
| 87 | + | ||
| 88 | + 异常: | ||
| 89 | + ValueError: 当响应为空或JSON解析失败时抛出。 | ||
| 90 | + """ | ||
| 67 | cleaned = raw.strip() | 91 | cleaned = raw.strip() |
| 68 | if cleaned.startswith("```json"): | 92 | if cleaned.startswith("```json"): |
| 69 | cleaned = cleaned[7:] | 93 | cleaned = cleaned[7:] |
| @@ -62,7 +62,15 @@ class HTMLRenderer: | @@ -62,7 +62,15 @@ class HTMLRenderer: | ||
| 62 | # ====== 公共入口 ====== | 62 | # ====== 公共入口 ====== |
| 63 | 63 | ||
| 64 | def render(self, document_ir: Dict[str, Any]) -> str: | 64 | def render(self, document_ir: Dict[str, Any]) -> str: |
| 65 | - """接收Document IR,重置内部状态并输出完整HTML""" | 65 | + """ |
| 66 | + 接收Document IR,重置内部状态并输出完整HTML。 | ||
| 67 | + | ||
| 68 | + 参数: | ||
| 69 | + document_ir: 由 DocumentComposer 生成的整本报告数据。 | ||
| 70 | + | ||
| 71 | + 返回: | ||
| 72 | + str: 可直接写入磁盘的完整HTML文档。 | ||
| 73 | + """ | ||
| 66 | self.document = document_ir or {} | 74 | self.document = document_ir or {} |
| 67 | self.widget_scripts = [] | 75 | self.widget_scripts = [] |
| 68 | self.chart_counter = 0 | 76 | self.chart_counter = 0 |
| @@ -89,7 +97,16 @@ class HTMLRenderer: | @@ -89,7 +97,16 @@ class HTMLRenderer: | ||
| 89 | # ====== Head / Body ====== | 97 | # ====== Head / Body ====== |
| 90 | 98 | ||
| 91 | def _render_head(self, title: str, theme_tokens: Dict[str, Any]) -> str: | 99 | def _render_head(self, title: str, theme_tokens: Dict[str, Any]) -> str: |
| 92 | - """渲染<head>部分,加载主题CSS与必要的脚本依赖""" | 100 | + """ |
| 101 | + 渲染<head>部分,加载主题CSS与必要的脚本依赖。 | ||
| 102 | + | ||
| 103 | + 参数: | ||
| 104 | + title: 页面title标签内容。 | ||
| 105 | + theme_tokens: 主题变量,用于注入CSS。 | ||
| 106 | + | ||
| 107 | + 返回: | ||
| 108 | + str: head片段HTML。 | ||
| 109 | + """ | ||
| 93 | css = self._build_css(theme_tokens) | 110 | css = self._build_css(theme_tokens) |
| 94 | return f""" | 111 | return f""" |
| 95 | <head> | 112 | <head> |
| @@ -124,7 +141,12 @@ class HTMLRenderer: | @@ -124,7 +141,12 @@ class HTMLRenderer: | ||
| 124 | </head>""".strip() | 141 | </head>""".strip() |
| 125 | 142 | ||
| 126 | def _render_body(self) -> str: | 143 | def _render_body(self) -> str: |
| 127 | - """拼装<body>结构,包含头部、导航、章节和脚本""" | 144 | + """ |
| 145 | + 拼装<body>结构,包含头部、导航、章节和脚本。 | ||
| 146 | + | ||
| 147 | + 返回: | ||
| 148 | + str: body片段HTML。 | ||
| 149 | + """ | ||
| 128 | header = self._render_header() | 150 | header = self._render_header() |
| 129 | cover = self._render_cover() | 151 | cover = self._render_cover() |
| 130 | hero = self._render_hero() | 152 | hero = self._render_hero() |
| @@ -152,7 +174,12 @@ class HTMLRenderer: | @@ -152,7 +174,12 @@ class HTMLRenderer: | ||
| 152 | # ====== Header / Meta / TOC ====== | 174 | # ====== Header / Meta / TOC ====== |
| 153 | 175 | ||
| 154 | def _render_header(self) -> str: | 176 | def _render_header(self) -> str: |
| 155 | - """渲染吸顶头部,包含标题、副标题与功能按钮""" | 177 | + """ |
| 178 | + 渲染吸顶头部,包含标题、副标题与功能按钮。 | ||
| 179 | + | ||
| 180 | + 返回: | ||
| 181 | + str: header HTML。 | ||
| 182 | + """ | ||
| 156 | metadata = self.metadata | 183 | metadata = self.metadata |
| 157 | title = metadata.get("title") or "智能舆情分析报告" | 184 | title = metadata.get("title") or "智能舆情分析报告" |
| 158 | subtitle = metadata.get("subtitle") or metadata.get("templateName") or "自动生成" | 185 | subtitle = metadata.get("subtitle") or metadata.get("templateName") or "自动生成" |
| @@ -172,14 +199,24 @@ class HTMLRenderer: | @@ -172,14 +199,24 @@ class HTMLRenderer: | ||
| 172 | """.strip() | 199 | """.strip() |
| 173 | 200 | ||
| 174 | def _render_tagline(self) -> str: | 201 | def _render_tagline(self) -> str: |
| 175 | - """渲染标题下方的标语,如无标语则返回空字符串""" | 202 | + """ |
| 203 | + 渲染标题下方的标语,如无标语则返回空字符串。 | ||
| 204 | + | ||
| 205 | + 返回: | ||
| 206 | + str: tagline HTML或空串。 | ||
| 207 | + """ | ||
| 176 | tagline = self.metadata.get("tagline") | 208 | tagline = self.metadata.get("tagline") |
| 177 | if not tagline: | 209 | if not tagline: |
| 178 | return "" | 210 | return "" |
| 179 | return f'<p class="tagline">{self._escape_html(tagline)}</p>' | 211 | return f'<p class="tagline">{self._escape_html(tagline)}</p>' |
| 180 | 212 | ||
| 181 | def _render_cover(self) -> str: | 213 | def _render_cover(self) -> str: |
| 182 | - """文章开头的封面区,居中展示标题与“文章总览”提示""" | 214 | + """ |
| 215 | + 文章开头的封面区,居中展示标题与“文章总览”提示。 | ||
| 216 | + | ||
| 217 | + 返回: | ||
| 218 | + str: cover section HTML。 | ||
| 219 | + """ | ||
| 183 | title = self.metadata.get("title") or "智能舆情报告" | 220 | title = self.metadata.get("title") or "智能舆情报告" |
| 184 | subtitle = self.metadata.get("subtitle") or self.metadata.get("templateName") or "" | 221 | subtitle = self.metadata.get("subtitle") or self.metadata.get("templateName") or "" |
| 185 | overview_hint = "文章总览" | 222 | overview_hint = "文章总览" |
| @@ -192,7 +229,12 @@ class HTMLRenderer: | @@ -192,7 +229,12 @@ class HTMLRenderer: | ||
| 192 | """.strip() | 229 | """.strip() |
| 193 | 230 | ||
| 194 | def _render_hero(self) -> str: | 231 | def _render_hero(self) -> str: |
| 195 | - """根据layout中的hero字段输出摘要/KPI/亮点区""" | 232 | + """ |
| 233 | + 根据layout中的hero字段输出摘要/KPI/亮点区。 | ||
| 234 | + | ||
| 235 | + 返回: | ||
| 236 | + str: hero区HTML,若无数据则为空字符串。 | ||
| 237 | + """ | ||
| 196 | hero = self.metadata.get("hero") or {} | 238 | hero = self.metadata.get("hero") or {} |
| 197 | if not hero: | 239 | if not hero: |
| 198 | return "" | 240 | return "" |
| @@ -239,7 +281,12 @@ class HTMLRenderer: | @@ -239,7 +281,12 @@ class HTMLRenderer: | ||
| 239 | return "" | 281 | return "" |
| 240 | 282 | ||
| 241 | def _render_toc_section(self) -> str: | 283 | def _render_toc_section(self) -> str: |
| 242 | - """生成目录模块,如无目录数据则返回空字符串""" | 284 | + """ |
| 285 | + 生成目录模块,如无目录数据则返回空字符串。 | ||
| 286 | + | ||
| 287 | + 返回: | ||
| 288 | + str: toc HTML结构。 | ||
| 289 | + """ | ||
| 243 | if not self.toc_entries: | 290 | if not self.toc_entries: |
| 244 | return "" | 291 | return "" |
| 245 | toc_config = self.metadata.get("toc") or {} | 292 | toc_config = self.metadata.get("toc") or {} |
| @@ -258,7 +305,15 @@ class HTMLRenderer: | @@ -258,7 +305,15 @@ class HTMLRenderer: | ||
| 258 | """.strip() | 305 | """.strip() |
| 259 | 306 | ||
| 260 | def _collect_toc_entries(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | 307 | def _collect_toc_entries(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
| 261 | - """根据metadata中的tocPlan或章节heading收集目录项""" | 308 | + """ |
| 309 | + 根据metadata中的tocPlan或章节heading收集目录项。 | ||
| 310 | + | ||
| 311 | + 参数: | ||
| 312 | + chapters: Document IR中的章节数组。 | ||
| 313 | + | ||
| 314 | + 返回: | ||
| 315 | + list[dict]: 规范化后的目录条目,包含level/text/anchor。 | ||
| 316 | + """ | ||
| 262 | metadata = self.metadata | 317 | metadata = self.metadata |
| 263 | toc_config = metadata.get("toc") or {} | 318 | toc_config = metadata.get("toc") or {} |
| 264 | custom_entries = toc_config.get("customEntries") | 319 | custom_entries = toc_config.get("customEntries") |
| @@ -296,7 +351,15 @@ class HTMLRenderer: | @@ -296,7 +351,15 @@ class HTMLRenderer: | ||
| 296 | return entries | 351 | return entries |
| 297 | 352 | ||
| 298 | def _format_toc_entry(self, entry: Dict[str, Any]) -> str: | 353 | def _format_toc_entry(self, entry: Dict[str, Any]) -> str: |
| 299 | - """将单个目录项转为带描述的HTML行""" | 354 | + """ |
| 355 | + 将单个目录项转为带描述的HTML行。 | ||
| 356 | + | ||
| 357 | + 参数: | ||
| 358 | + entry: 目录条目,需包含 `text` 与 `anchor`。 | ||
| 359 | + | ||
| 360 | + 返回: | ||
| 361 | + str: `<li>` 形式的HTML。 | ||
| 362 | + """ | ||
| 300 | desc = entry.get("description") | 363 | desc = entry.get("description") |
| 301 | desc_html = f'<p class="toc-desc">{self._escape_html(desc)}</p>' if desc else "" | 364 | desc_html = f'<p class="toc-desc">{self._escape_html(desc)}</p>' if desc else "" |
| 302 | level = entry.get("level", 2) | 365 | level = entry.get("level", 2) |
| @@ -304,7 +367,15 @@ class HTMLRenderer: | @@ -304,7 +367,15 @@ class HTMLRenderer: | ||
| 304 | return f'<li class="level-{css_level}"><a href="#{self._escape_attr(entry["anchor"])}">{self._escape_html(entry["text"])}</a>{desc_html}</li>' | 367 | return f'<li class="level-{css_level}"><a href="#{self._escape_attr(entry["anchor"])}">{self._escape_html(entry["text"])}</a>{desc_html}</li>' |
| 305 | 368 | ||
| 306 | def _compute_heading_labels(self, chapters: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: | 369 | def _compute_heading_labels(self, chapters: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: |
| 307 | - """预计算各级标题的编号(章:一、二;节:1.1;小节:1.1.1)""" | 370 | + """ |
| 371 | + 预计算各级标题的编号(章:一、二;节:1.1;小节:1.1.1)。 | ||
| 372 | + | ||
| 373 | + 参数: | ||
| 374 | + chapters: Document IR中的章节数组。 | ||
| 375 | + | ||
| 376 | + 返回: | ||
| 377 | + dict: 锚点到编号/描述的映射,方便TOC与正文引用。 | ||
| 378 | + """ | ||
| 308 | label_map: Dict[str, Dict[str, Any]] = {} | 379 | label_map: Dict[str, Dict[str, Any]] = {} |
| 309 | 380 | ||
| 310 | for chap_idx, chapter in enumerate(chapters or [], start=1): | 381 | for chap_idx, chapter in enumerate(chapters or [], start=1): |
| @@ -394,17 +465,41 @@ class HTMLRenderer: | @@ -394,17 +465,41 @@ class HTMLRenderer: | ||
| 394 | # ====== 章节 & Block 渲染 ====== | 465 | # ====== 章节 & Block 渲染 ====== |
| 395 | 466 | ||
| 396 | def _render_chapter(self, chapter: Dict[str, Any]) -> str: | 467 | def _render_chapter(self, chapter: Dict[str, Any]) -> str: |
| 397 | - """将章节blocks包裹进<section>,便于CSS控制""" | 468 | + """ |
| 469 | + 将章节blocks包裹进<section>,便于CSS控制。 | ||
| 470 | + | ||
| 471 | + 参数: | ||
| 472 | + chapter: 单个章节JSON。 | ||
| 473 | + | ||
| 474 | + 返回: | ||
| 475 | + str: section包裹的HTML。 | ||
| 476 | + """ | ||
| 398 | section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}") | 477 | section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}") |
| 399 | blocks_html = self._render_blocks(chapter.get("blocks", [])) | 478 | blocks_html = self._render_blocks(chapter.get("blocks", [])) |
| 400 | return f'<section id="{section_id}" class="chapter">\n{blocks_html}\n</section>' | 479 | return f'<section id="{section_id}" class="chapter">\n{blocks_html}\n</section>' |
| 401 | 480 | ||
| 402 | def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str: | 481 | def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str: |
| 403 | - """顺序渲染章节内所有block""" | 482 | + """ |
| 483 | + 顺序渲染章节内所有block。 | ||
| 484 | + | ||
| 485 | + 参数: | ||
| 486 | + blocks: 章节内部的block数组。 | ||
| 487 | + | ||
| 488 | + 返回: | ||
| 489 | + str: 拼接后的HTML。 | ||
| 490 | + """ | ||
| 404 | return "".join(self._render_block(block) for block in blocks or []) | 491 | return "".join(self._render_block(block) for block in blocks or []) |
| 405 | 492 | ||
| 406 | def _render_block(self, block: Dict[str, Any]) -> str: | 493 | def _render_block(self, block: Dict[str, Any]) -> str: |
| 407 | - """根据block.type分派到不同的渲染函数""" | 494 | + """ |
| 495 | + 根据block.type分派到不同的渲染函数。 | ||
| 496 | + | ||
| 497 | + 参数: | ||
| 498 | + block: 单个block对象。 | ||
| 499 | + | ||
| 500 | + 返回: | ||
| 501 | + str: 渲染后的HTML,未知类型会输出JSON调试信息。 | ||
| 502 | + """ | ||
| 408 | block_type = block.get("type") | 503 | block_type = block.get("type") |
| 409 | handlers = { | 504 | handlers = { |
| 410 | "heading": self._render_heading, | 505 | "heading": self._render_heading, |
| @@ -468,7 +563,15 @@ class HTMLRenderer: | @@ -468,7 +563,15 @@ class HTMLRenderer: | ||
| 468 | return f'<{tag}{class_attr}>{items_html}</{tag}>' | 563 | return f'<{tag}{class_attr}>{items_html}</{tag}>' |
| 469 | 564 | ||
| 470 | def _render_table(self, block: Dict[str, Any]) -> str: | 565 | def _render_table(self, block: Dict[str, Any]) -> str: |
| 471 | - """渲染表格,同时保留caption与单元格属性""" | 566 | + """ |
| 567 | + 渲染表格,同时保留caption与单元格属性。 | ||
| 568 | + | ||
| 569 | + 参数: | ||
| 570 | + block: table类型的block。 | ||
| 571 | + | ||
| 572 | + 返回: | ||
| 573 | + str: 包含<table>结构的HTML。 | ||
| 574 | + """ | ||
| 472 | rows = self._normalize_table_rows(block.get("rows") or []) | 575 | rows = self._normalize_table_rows(block.get("rows") or []) |
| 473 | rows_html = "" | 576 | rows_html = "" |
| 474 | for row in rows: | 577 | for row in rows: |
| @@ -491,7 +594,15 @@ class HTMLRenderer: | @@ -491,7 +594,15 @@ class HTMLRenderer: | ||
| 491 | return f'<div class="table-wrap"><table>{caption_html}<tbody>{rows_html}</tbody></table></div>' | 594 | return f'<div class="table-wrap"><table>{caption_html}<tbody>{rows_html}</tbody></table></div>' |
| 492 | 595 | ||
| 493 | def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | 596 | def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
| 494 | - """检测并修正仅有单列的竖排表,转换为标准网格""" | 597 | + """ |
| 598 | + 检测并修正仅有单列的竖排表,转换为标准网格。 | ||
| 599 | + | ||
| 600 | + 参数: | ||
| 601 | + rows: 原始表格行。 | ||
| 602 | + | ||
| 603 | + 返回: | ||
| 604 | + list[dict]: 若检测到竖排表则返回转置后的行,否则原样返回。 | ||
| 605 | + """ | ||
| 495 | if not rows: | 606 | if not rows: |
| 496 | return [] | 607 | return [] |
| 497 | if not all(len((row.get("cells") or [])) == 1 for row in rows): | 608 | if not all(len((row.get("cells") or [])) == 1 for row in rows): |
| @@ -611,7 +722,15 @@ class HTMLRenderer: | @@ -611,7 +722,15 @@ class HTMLRenderer: | ||
| 611 | return f'<div class="figure-placeholder">{self._escape_html(caption)}</div>' | 722 | return f'<div class="figure-placeholder">{self._escape_html(caption)}</div>' |
| 612 | 723 | ||
| 613 | def _render_callout(self, block: Dict[str, Any]) -> str: | 724 | def _render_callout(self, block: Dict[str, Any]) -> str: |
| 614 | - """渲染高亮提示盒,tone决定颜色""" | 725 | + """ |
| 726 | + 渲染高亮提示盒,tone决定颜色。 | ||
| 727 | + | ||
| 728 | + 参数: | ||
| 729 | + block: callout类型的block。 | ||
| 730 | + | ||
| 731 | + 返回: | ||
| 732 | + str: callout HTML,若内部包含不允许的块会被拆分。 | ||
| 733 | + """ | ||
| 615 | tone = block.get("tone", "info") | 734 | tone = block.get("tone", "info") |
| 616 | title = block.get("title") | 735 | title = block.get("title") |
| 617 | safe_blocks, trailing_blocks = self._split_callout_content(block.get("blocks")) | 736 | safe_blocks, trailing_blocks = self._split_callout_content(block.get("blocks")) |
| @@ -689,7 +808,15 @@ class HTMLRenderer: | @@ -689,7 +808,15 @@ class HTMLRenderer: | ||
| 689 | return f'<div class="kpi-grid">{cards}</div>' | 808 | return f'<div class="kpi-grid">{cards}</div>' |
| 690 | 809 | ||
| 691 | def _render_widget(self, block: Dict[str, Any]) -> str: | 810 | def _render_widget(self, block: Dict[str, Any]) -> str: |
| 692 | - """渲染Chart.js等交互组件的占位容器,并记录配置JSON""" | 811 | + """ |
| 812 | + 渲染Chart.js等交互组件的占位容器,并记录配置JSON。 | ||
| 813 | + | ||
| 814 | + 参数: | ||
| 815 | + block: widget类型的block,包含widgetId/props/data。 | ||
| 816 | + | ||
| 817 | + 返回: | ||
| 818 | + str: 含canvas与配置脚本的HTML。 | ||
| 819 | + """ | ||
| 693 | self.chart_counter += 1 | 820 | self.chart_counter += 1 |
| 694 | canvas_id = f"chart-{self.chart_counter}" | 821 | canvas_id = f"chart-{self.chart_counter}" |
| 695 | config_id = f"chart-config-{self.chart_counter}" | 822 | config_id = f"chart-config-{self.chart_counter}" |
| @@ -830,7 +957,15 @@ class HTMLRenderer: | @@ -830,7 +957,15 @@ class HTMLRenderer: | ||
| 830 | return payload | 957 | return payload |
| 831 | 958 | ||
| 832 | def _render_inline(self, run: Dict[str, Any]) -> str: | 959 | def _render_inline(self, run: Dict[str, Any]) -> str: |
| 833 | - """渲染单个inline run,支持多种marks叠加""" | 960 | + """ |
| 961 | + 渲染单个inline run,支持多种marks叠加。 | ||
| 962 | + | ||
| 963 | + 参数: | ||
| 964 | + run: 含 text 与 marks 的内联节点。 | ||
| 965 | + | ||
| 966 | + 返回: | ||
| 967 | + str: 已包裹标签/样式的HTML片段。 | ||
| 968 | + """ | ||
| 834 | text_value, marks = self._normalize_inline_payload(run) | 969 | text_value, marks = self._normalize_inline_payload(run) |
| 835 | math_mark = next((mark for mark in marks if mark.get("type") == "math"), None) | 970 | math_mark = next((mark for mark in marks if mark.get("type") == "math"), None) |
| 836 | if math_mark: | 971 | if math_mark: |
| @@ -47,7 +47,12 @@ settings = Settings() | @@ -47,7 +47,12 @@ settings = Settings() | ||
| 47 | 47 | ||
| 48 | 48 | ||
| 49 | def print_config(config: Settings): | 49 | def print_config(config: Settings): |
| 50 | - """将当前配置项按人类可读格式输出到日志,方便排障""" | 50 | + """ |
| 51 | + 将当前配置项按人类可读格式输出到日志,方便排障。 | ||
| 52 | + | ||
| 53 | + 参数: | ||
| 54 | + config: Settings实例,通常为全局settings。 | ||
| 55 | + """ | ||
| 51 | message = "" | 56 | message = "" |
| 52 | message += "\n=== Report Engine 配置 ===\n" | 57 | message += "\n=== Report Engine 配置 ===\n" |
| 53 | message += f"LLM 模型: {config.REPORT_ENGINE_MODEL_NAME}\n" | 58 | message += f"LLM 模型: {config.REPORT_ENGINE_MODEL_NAME}\n" |
| @@ -1081,6 +1081,7 @@ | @@ -1081,6 +1081,7 @@ | ||
| 1081 | </style> | 1081 | </style> |
| 1082 | </head> | 1082 | </head> |
| 1083 | <body> | 1083 | <body> |
| 1084 | + <!-- 顶层容器:同时包裹搜索区、双列主工作区与状态栏 --> | ||
| 1084 | <div class="container"> | 1085 | <div class="container"> |
| 1085 | <!-- 搜索框区域 --> | 1086 | <!-- 搜索框区域 --> |
| 1086 | <div class="search-section"> | 1087 | <div class="search-section"> |
| @@ -1158,13 +1159,14 @@ | @@ -1158,13 +1159,14 @@ | ||
| 1158 | </div> | 1159 | </div> |
| 1159 | </div> | 1160 | </div> |
| 1160 | 1161 | ||
| 1161 | - <!-- 状态栏 --> | 1162 | + <!-- 状态栏:实时展示WebSocket连接状态与系统时钟 --> |
| 1162 | <div class="status-bar"> | 1163 | <div class="status-bar"> |
| 1163 | <span id="connectionStatus">连接中...</span> | 1164 | <span id="connectionStatus">连接中...</span> |
| 1164 | <span id="systemTime"></span> | 1165 | <span id="systemTime"></span> |
| 1165 | </div> | 1166 | </div> |
| 1166 | </div> | 1167 | </div> |
| 1167 | 1168 | ||
| 1169 | + <!-- 配置弹窗:与后端.env互通,允许在线修改LLM参数 --> | ||
| 1168 | <div class="config-modal-overlay" id="configModal"> | 1170 | <div class="config-modal-overlay" id="configModal"> |
| 1169 | <div class="config-modal"> | 1171 | <div class="config-modal"> |
| 1170 | <div class="config-modal-header"> | 1172 | <div class="config-modal-header"> |
| @@ -1187,9 +1189,10 @@ | @@ -1187,9 +1189,10 @@ | ||
| 1187 | </div> | 1189 | </div> |
| 1188 | </div> | 1190 | </div> |
| 1189 | 1191 | ||
| 1190 | - <!-- 消息提示 --> | 1192 | + <!-- 消息提示:右上角滑出式成功/错误提醒 --> |
| 1191 | <div class="message" id="message"></div> | 1193 | <div class="message" id="message"></div> |
| 1192 | 1194 | ||
| 1195 | + <!-- 前端业务脚本:维护Socket连接、引擎启动状态与Report Engine交互 --> | ||
| 1193 | <script> | 1196 | <script> |
| 1194 | // 全局变量 | 1197 | // 全局变量 |
| 1195 | let socket; | 1198 | let socket; |
-
Please register or login to post a comment