乐于分享
好东西不私藏

BettaFish源码解析(四):报告生成引擎

BettaFish源码解析(四):报告生成引擎

如何让AI写出结构化的专业报告?


前言

在前三篇文章中,我们分别讲解了BettaFish的整体架构、Agent论坛机制和GraphRAG知识图谱。这些都是为最终输出服务的”基础设施”。

现在,我们来到BettaFish的”产品”——报告生成引擎(Report Engine)。这是整个系统的输出端,负责将Agent们的讨论和分析结果,转化为一份结构化的专业报告。

本文将深入解析ReportEngine的设计,回答一个核心问题:

如何让AI写出结构化的专业报告(HTML/PDF/Markdown),且保证输出质量可控?


一、为什么需要IR中间表示?

1.1 传统报告生成的问题

在深入设计之前,先理解传统方案的痛点:

问题 传统方案(直接LLM调用) BettaFish方案(IR中间表示)
输出不可控 LLM直接生成,格式可能错乱 分离为生成+渲染,每步可控
无法增量更新 重新生成整篇报告 章节独立,可单独重试
格式耦合 Markdown/HTML混在提示词中 IR Schema定义,格式解耦
图表难管理 图表代码嵌入文本,难以提取 图表作为独立块类型
难以调试 出错了整篇都要重跑 章节级错误隔离
无法复用 每次报告都是新内容 IR可存储,支持引用和链接

1.2 BettaFish的方案:IR中间表示(Intermediate Representation)

# ReportEngine/ir/schema.pyIR_VERSION="1.0"# IR版本号# 块类型定义(5种)ALLOWED_BLOCK_TYPES= ["heading",      # 标题"paragraph",    # 段落"list",         # 列表"swotTable",   # SWOT表格"pestTable",    # PEST表格"engineQuote",  # 引擎引用"hr",           # 分隔线"figure",       # 图表# ... 更多类型]# 内联标记类型ALLOWED_INLINE_MARKS= ["bold", "italic", "underline", "code", "link","color", "font", "highlight"]

设计要点:

  • 块类型强约束:每个JSON块必须有明确的type字段

  • 内容与样式分离:text存纯文本,marks存样式标记

  • 版本号控制:IR_VERSION用于向后兼容


二、核心流水线:从模板到报告的完整链路

2.1 整体架构图

用户查询  ↓Report Agent (agent.py)  ↓模板选择节点(TemplateSelectionNode)  ↓布局设计节点(DocumentLayoutNode)  ↓篇幅规划节点(WordBudgetNode)  ↓N轮章节生成节点(ChapterGenerationNode)  ↓章节装订器(DocumentComposer/stitcher)  ↓IR中间表示(JSON)  ↓HTML/PDF渲染器(HTMLRenderer/PDFRenderer)  ↓最终报告(HTML/PDF/Markdown)

2.2 节点流水线的核心设计

每个环节都是一个独立节点(BaseNode),有自己的生命周期钩子:

# ReportEngine/nodes/base_node.pyclassBaseNode:"""所有节点的基类"""defexecute(self, state: State, **kwargs) ->Dict:"""执行节点逻辑"""deflog(self, message: str):"""统一的日志记录"""defon_start(self, state: State):"""节点开始时的钩子"""defon_complete(self, state: State):"""节点完成时的钩子"""defon_error(self, state: State, error: Exception):"""节点出错时的钩子"""

为什么是节点而不是函数?

特性 函数模式 节点模式
状态追踪 ❌ 手动传参 ✅ 节点有自己的生命周期
日志隔离 ❌ 难以区分来源 ✅ 节点自动带前缀
重试机制 ❌ 每个函数都要写 ✅ 基类可统一实现
组合能力 ❌ 硬编码if-else ✅ 节点可任意组合

三、模板系统:如何让LLM”按章调用”?

3.1 Markdown模板切片

BettaFish的报告基于Markdown模板(而不是硬编码提示词)。LLM只需要知道”这是第几章,写什么内容”。

# ReportEngine/core/template_parser.py@dataclassclassTemplateSection:"""模板章节实体"""title: strslug: str# URL友好的标识order: int# 章节顺序depth: int# 标题层级(1.0=一级,2.0=二级)raw_title: str# 原始标题文本number: str# 章节编号(如"1.1")chapter_id: str# 唯一章节IDoutline: List[str]  # 章节提纲defto_dict(self) ->dict:"""序列化为字典"""

关键正则表达式:

# 兼容多种标题格式heading_pattern=re.compile(r"""    (?P<marker>#{1,6})       # Markdown标题标记    [ \t]+                    # 必需的空白字符    (?P<title>[^\r\n]+)       # 标题文本""", re.VERBOSE)

为什么要用正则而不是简单split?

  1. 支持层级:#的数量自动判断标题等级

  2. 保留原始文本:raw_title字段便于调试

  3. 容错性:跳过空行、处理异常格式

  4. URL友好:slug字段自动生成(w-123 → slug-123)

3.2 模板示例

# ReportEngine/report_template/企业品牌声誉分析报告.md# 一级标题## 1. 舆情现状分析# 二级标题(会映射为TemplateSection)### 1.1 数据来源概述### 1.2 舆情时间线梳理# 三级标题#### 数据来源详细分析##### Query Engine贡献##### Media Engine贡献##### Insight Engine贡献

关键设计:

  • 模板与解耦:Markdown文件作为”数据源”,Agent只需选择模板ID

  • 章节自动编号:LLM不需要关心”这是第几章”

  • 层级感知:标题层级自动映射为章节深度


四、章节生成:如何控制LLM输出质量?

4.1 流式生成节点

这是ReportEngine最精妙的设计之一——流式写入+自动修复。

# ReportEngine/nodes/chapter_generation_node.pyclassChapterGenerationNode(BaseNode):"""章节级JSON生成节点"""defexecute(self, state: State, **kwargs) ->Dict:"""执行章节生成"""# 1. 构建Promptsystem_prompt=self._build_system_prompt()user_prompt=self._build_user_prompt()# 2. 调用LLM API(流式)response=self.llm_client.stream_chat(model=self.config.MODEL_NAME,messages=[{"role": "system", "content": system_prompt},                   {"role": "user", "content": user_prompt}]        )# 3. 流式写入Raw文件chapter_file=f"{self.config.CHAPTER_OUTPUT_DIR}/raw_{state.current_chapter_id}.json"withopen(chapter_file, 'w', encoding='utf-8') asf:forchunkinresponse:raw_file.write(chunk)  # 实时写入raw_file.flush()# 4. 解析并验证parsed=self._parse_and_validate(raw_file, chapter_file)return {'chapter': parsed,'raw_file': chapter_file,'word_count': self._count_words(parsed)        }

为什么是流式而不是等完整输出?

方式 优点 缺点
流式输出 ✅ 实时进度反馈 ❌ 无法校验完整性
完整输出 ✅ 易于校验 ❌ 用户体验差(长时间无输出)
BettaFish方案 两者结合 先流式写入Raw → 校验 → 转换为标准JSON

4.2 JSON自动修复机制

LLM输出的JSON可能损坏,BettaFish有完整的修复机制:

# ReportEngine/utils/json_parser.pyclassRobustJSONParser:"""鲁棒的JSON解析器"""defrepair_json(self, raw_text: str) ->Dict:"""自动修复常见JSON错误"""# 1. 修复不匹配的大括号text=raw_text.replace("{{", "{").replace("}}", "}")# 2. 修复不匹配的引号text=re.sub(r'(?<!")([^"]*)(?<!")', r'"\1" \2"', text)# 3. 修复尾随逗号text=text.rstrip(',').rstrip()# 4. 解析并验证try:returnjson.loads(text)exceptjson.JSONDecodeErrorase:raiseJSONParseError(f"JSON解析失败: {e}", raw_text)defparse_chapter_file(self, file_path: Path) ->Dict:"""解析章节文件并自动修复"""withopen(file_path, 'r', encoding='utf-8') asf:raw_text=f.read()# 尝试修复并解析try:parsed=self.repair_json(raw_text)self._validate_chapter(parsed)  # Schema校验returnparsedexceptExceptionase:# 修复失败则记录异常raiseJSONParseError(f"JSON修复失败: {e}", raw_text)

常见JSON错误:

错误类型 示例 修复策略
不匹配的引号 {"text": "中文"} 转换为{"text": "中文"}
不匹配的大括号 {{"content": "xxx"}} 转换为单大括号
尾随逗号 {"key": "value",} 删除末尾逗号
截断的JSON {... 记录异常,重试

4.3 Schema校验

# ReportEngine/ir/validator.pyclassIRValidator:"""IR Schema校验器"""defvalidate_chapter(self, chapter: Dict) ->bool:"""校验章节是否符合IR规范"""# 检查必需字段if"type"notinchapter:raiseChapterValidationError("缺少type字段")# 检查块类型是否允许ifchapter["type"] notinALLOWED_BLOCK_TYPES:raiseChapterValidationError(f"未知块类型: {chapter['type']}")# 检查必需字段是否存在ifchapter["type"] in ["paragraph", "list"] and"text"notinchapter:raiseChapterValidationError("缺少text字段")returnTrue

五、章节装订:如何合并为整份报告?

5.1 锚点去重

章节独立生成后,需要合并为整份,但锚点(anchor)必须唯一。

# ReportEngine/core/stitcher.pyclassDocumentComposer:"""章节装订器"""def__init__(self):# 记录已使用的锚点,避免重复self._seen_anchors: Set[str] =set()defbuild_document(self, report_id: str, metadata: Dict,chapters: List[Dict]) ->Dict:"""把所有章节按order排序并注入唯一锚点"""# 1. 构建chapterId到toc anchor的映射toc_anchor_map=self._build_toc_anchor_map(metadata)# 2. 按order排序章节ordered=sorted(chapters, key=lambdac: c.get("order", 0))# 3. 注入锚点并确保唯一foridx, chapterinenumerate(ordered, start=1):chapter_id=chapter.get("chapterId")anchor= (toc_anchor_map.get(chapter_id) or# 优先1chapter.get("anchor") or# 优先2f"section-{idx}"# 兜底            )# 若重复则追加序号chapter["anchor"] =self._ensure_unique_anchor(anchor)# 4. 返回完整Document IRdocument= {"version": IR_VERSION,"reportId": report_id,"metadata": {"generatedAt": metadata.get("generatedAt") ordatetime.utcnow().isoformat() +"Z",# 合并metadata/themeTokens/assets            },"chapters": ordered        }returndocument

锚点生成优先级:

优先级 来源 说明
1 TOC配置 用户在模板中定义的目录锚点
2 章节自带 章节生成时指定的锚点
3 默认兜底 section-{idx}(确保不重复)

5.2 元数据合并

# DocumentComposer构建的完整IR{"version": "1.0","reportId": "final_report__20250303_123456","metadata": {"generatedAt": "2025-03-03T12:34:56.789Z","title": "武汉大学品牌声誉深度分析报告","theme": "light","themeTokens": {...},  # 主题颜色配置"assets": {"logo": "/static/image/logo.png","cover_image": "/static/image/cover.jpg"    }  },"chapters": [    {"type": "heading","text": "1. 舆情现状分析","anchor": "toc-overview","order": 10    },    {"type": "section","slug": "data-sources","chapterId": "S1","anchor": "section-data-sources","order": 11"blocks": [...]    }  ]}

六、IR渲染:如何从JSON到HTML/PDF?

6.1 HTML渲染器

# ReportEngine/renderers/html_renderer.pyclassHTMLRenderer:"""Document IR → 交互式HTML"""defrender(self, document: Dict) ->str:"""渲染HTML报告"""html_content= []html_content.append("<!DOCTYPE html>")html_content.append("<html lang='zh-CN'>")html_content.append("<head>")html_content.append(self._build_head(document))html_content.append("</head>")html_content.append("<body>")html_content.append(self._build_body(document))html_content.append("</body>")html_content.append("</html>")return"\n".join(html_content)def_build_body(self, document: Dict) ->str:"""根据IR块类型渲染不同HTML元素"""body_parts= []forchapterindocument["chapters"]:forblockinchapter.get("blocks", []):ifblock["type"] =="heading":body_parts.append(f"<h2 id='{block['anchor']}'>{block['text']}</h2>")elifblock["type"] =="paragraph":body_parts.append(f"<p>{self._apply_marks(block)}</p>")elifblock["type"] =="swotTable":body_parts.append(self._render_swot_table(block))elifblock["type"] =="figure":body_parts.append(self._render_figure(block))return"\n".join(body_parts)

6.2 图表渲染

BettaFish支持多种图表类型,每个图表都是一个独立的IR块:

{"type": "figure","id": "fig-trend-analysis","title": "舆情趋势分析","figureType": "line",  # line/bar/pie/scatter"data": {"x": ["2024-01", "2024-02", "2024-03"],"y": [1200, 1500, 1800, 2100],"x_label": "时间","y_label": "评论数量"  }}

关键设计点:

  • 图表即IR块:不是嵌入文本,而是独立的数据结构

  • 多格式支持:同一份IR可渲染为HTML、PDF、Markdown

  • 可替换性:更换图表库只需修改渲染层


七、完整的生成流程图

7.1 从状态到报告的端到端

用户查询  ↓ReportAgent初始化  ↓[步骤1] 模板选择节点  ├─→ LLM筛选最合适模板  ├─→ 更新state.template_id  └─→ 写入日志  ↓[步骤2] 文档布局节点  ├─→ LLM设计标题、目录、主题  ├─→ 更新state.layout  └─→ 写入日志  ↓[步骤3] 篇幅规划节点  ├─→ LLM计算总字数  ├─→ 分配各章节字数  └─→ 更新state.word_budget  ↓[步骤4] N轮章节生成(核心)  ├─→ 每个章节:  │   ├─→ LLM生成JSON(流式写入Raw)  │   ├─→ 解析并验证JSON  │   ├─→ 写入标准JSON(parsed/)  │   └─→ 更新state  ↓[步骤5] GraphRAG增强(可选)  ├─→ 每个章节查询知识图谱  ├─→ 将结果嵌入Prompt  └─→ 增强章节生成质量  ↓[步骤6] 章节装订  ├─→ 合并所有章节JSON  ├─→ 注入唯一锚点  ├─→ 添加元数据(theme、assets)  └─→ 生成Document IR  ↓[步骤7] 渲染  ├─→ HTMLRenderer: Document IR → HTML  ├─→ PDFRenderer: HTML → PDF(可选)  └─→ MarkdownRenderer: IR → Markdown(可选)  ↓[步骤8] 落盘  ├─→ HTML: final_reports/final_report__{timestamp}.html  ├─→ PDF: final_reports/pdf/ (如果启用)  └─→ IR: final_reports/ir/ (用于后续重渲染)

7.2 错误处理与重试机制

# ReportEngine/nodes/chapter_generation_node.pyclassChapterContentError(ValueError):"""章节内容稀疏异常"""# 当LLM仅输出标题或正文不足以支撑一章时触发classChapterValidationError(ValueError):"""章节验证异常"""# 当JSON不符合IR规范时触发# 在节点执行时的错误处理try:result=node.execute(state)exceptChapterContentErrorase:# 驱动重试:生成更详细的章节logger.warning(f"章节内容不足,触发重试: {e}")returnnode.execute(state, retry=True)exceptChapterValidationErrorase:# 兜底:使用默认模板章节logger.error(f"章节验证失败,使用默认: {e}")returnself._get_fallback_chapter()

八、总结:BettaFish报告生成的设计思想

8.1 核心设计原则

  1. IR解耦生成与渲染:输出格式标准化,易于扩展

  2. 流式生成+自动修复:平衡用户体验与输出质量

  3. 模板驱动:LLM不关心格式,只关心内容

  4. 节点化流水线:每个环节独立可控,易于调试

  5. 锚点去重:确保文档链接正确性

  6. 多格式支持:同一份IR可渲染为HTML、PDF、Markdown

8.2 四大技术创新

  1. IR中间表示:标准化输出格式,Schema校验,版本控制

  2. JSON自动修复:鲁棒解析器,自动修复常见LLM输出错误

  3. 模板切片系统:正则表达式支持多种Markdown格式,自动生成slug和锚点

  4. 章节装订器:唯一锚点管理,元数据合并,文档完整生成

8.3 适用场景

✅ 适合:

  • 需要结构化报告的场景

  • 需要多格式输出的场景(HTML/PDF/MD)

  • 需要增量更新报告的场景

  • 需要高可追溯性的场景

❌ 不适合:

  • 需要实时性极高(<1秒)的流式输出

  • 简单文本生成场景(IR设计过重)


下一章预告

在理解了报告生成引擎的基础上,下一章我们将深入解析分布式爬虫系统:

  • MindSpider的Playwright平台爬虫架构

  • BroadTopicExtraction和DeepSentimentCrawling的双阶段设计

  • 配置化爬虫如何降低维护成本

  • 异步访问与连接池管理

  • 数据库Schema设计与ORM映射

关注公众号,不迷路 👉 BettaFish源码解析系列持续更新中…

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » BettaFish源码解析(四):报告生成引擎

评论 抢沙发

1 + 8 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮