乐于分享
好东西不私藏

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(selfstateState**kwargs->Dict:"""执行节点逻辑"""deflog(selfmessagestr):"""统一的日志记录"""defon_start(selfstateState):"""节点开始时的钩子"""defon_complete(selfstateState):"""节点完成时的钩子"""defon_error(selfstateStateerrorException):"""节点出错时的钩子"""

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

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

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

3.1 Markdown模板切片

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

# ReportEngine/core/template_parser.py@dataclassclassTemplateSection:"""模板章节实体"""titlestrslugstr# URL友好的标识orderint# 章节顺序depthint# 标题层级(1.0=一级,2.0=二级)raw_titlestr# 原始标题文本numberstr# 章节编号(如"1.1")chapter_idstr# 唯一章节IDoutlineList[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(selfstateState**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_filechapter_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(selfraw_textstr->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(selffile_pathPath->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(selfchapterDict->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_anchorsSet[str=set()defbuild_document(selfreport_idstrmetadataDict,chaptersList[Dict]) ->Dict:"""把所有章节按order排序并注入唯一锚点"""# 1. 构建chapterId到toc anchor的映射toc_anchor_map=self._build_toc_anchor_map(metadata)# 2. 按order排序章节ordered=sorted(chapterskey=lambdacc.get("order"0))# 3. 注入锚点并确保唯一foridxchapterinenumerate(orderedstart=1):chapter_id=chapter.get("chapterId")anchor= (toc_anchor_map.get(chapter_idor# 优先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(selfdocumentDict->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(selfdocumentDict->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": [1200150018002100],"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(stateretry=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 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮