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?
-
支持层级:
#的数量自动判断标题等级 -
保留原始文本:
raw_title字段便于调试 -
容错性:跳过空行、处理异常格式
-
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 核心设计原则
-
IR解耦生成与渲染:输出格式标准化,易于扩展
-
流式生成+自动修复:平衡用户体验与输出质量
-
模板驱动:LLM不关心格式,只关心内容
-
节点化流水线:每个环节独立可控,易于调试
-
锚点去重:确保文档链接正确性
-
多格式支持:同一份IR可渲染为HTML、PDF、Markdown
8.2 四大技术创新
-
IR中间表示:标准化输出格式,Schema校验,版本控制
-
JSON自动修复:鲁棒解析器,自动修复常见LLM输出错误
-
模板切片系统:正则表达式支持多种Markdown格式,自动生成slug和锚点
-
章节装订器:唯一锚点管理,元数据合并,文档完整生成
8.3 适用场景
✅ 适合:
-
需要结构化报告的场景
-
需要多格式输出的场景(HTML/PDF/MD)
-
需要增量更新报告的场景
-
需要高可追溯性的场景
❌ 不适合:
-
需要实时性极高(<1秒)的流式输出
-
简单文本生成场景(IR设计过重)
下一章预告
在理解了报告生成引擎的基础上,下一章我们将深入解析分布式爬虫系统:
-
MindSpider的Playwright平台爬虫架构
-
BroadTopicExtraction和DeepSentimentCrawling的双阶段设计
-
配置化爬虫如何降低维护成本
-
异步访问与连接池管理
-
数据库Schema设计与ORM映射
关注公众号,不迷路 👉 BettaFish源码解析系列持续更新中…
夜雨聆风
