Add Comments

This commit is contained in:
马一丁
2025-11-13 22:49:59 +08:00
parent 82152547e1
commit e267b1fc04
23 changed files with 500 additions and 145 deletions
+4 -3
View File
@@ -1,7 +1,8 @@
""" """
Report Engine Report Engine
一个智能报告生成AI代理实现
基于三个子agent的输出和论坛日志生成综合HTML报告 一个智能报告生成AI代理实现,聚合 Query/Media/Insight 三个子引擎的
Markdown 与论坛讨论,最终落地结构化HTML报告。
""" """
from .agent import ReportAgent, create_agent from .agent import ReportAgent, create_agent
+179 -38
View File
@@ -1,6 +1,11 @@
""" """
Report Agent主类 Report Agent主类
整合所有模块,实现完整的报告生成流程
该模块串联模板选择、布局设计、章节生成、IR装订与HTML渲染等
所有子流程,是Report Engine的总调度中心。核心职责包括:
1. 管理输入数据与状态,协调三个分析引擎、论坛日志与模板;
2. 按节点顺序驱动模板选择→布局生成→篇幅规划→章节写作→装订渲染;
3. 负责错误兜底、流式事件分发、落盘清单与最终成果保存。
""" """
import json import json
@@ -33,15 +38,32 @@ from .utils.config import settings, Settings
class FileCountBaseline: class FileCountBaseline:
"""文件数量基准管理器""" """
文件数量基准管理器。
该工具用于:
- 在任务启动时记录 Insight/Media/Query 三个引擎导出的 Markdown 数量;
- 在后续轮询中快速判断是否有新报告落地;
- 为 Flask 层提供“输入是否准备完毕”的依据。
"""
def __init__(self): def __init__(self):
"""在初始化阶段加载或创建文件数量基准快照""" """
初始化时优先尝试读取既有的基准快照。
若 `logs/report_baseline.json` 不存在则会自动创建一份空快照,
以便后续 `initialize_baseline` 在首次运行时写入真实基准。
"""
self.baseline_file = 'logs/report_baseline.json' self.baseline_file = 'logs/report_baseline.json'
self.baseline_data = self._load_baseline() self.baseline_data = self._load_baseline()
def _load_baseline(self) -> Dict[str, int]: def _load_baseline(self) -> Dict[str, int]:
"""加载基准数据""" """
加载基准数据。
- 当快照文件存在时直接解析JSON;
- 捕获所有加载异常并返回空字典,保证调用方逻辑简洁。
"""
try: try:
if os.path.exists(self.baseline_file): if os.path.exists(self.baseline_file):
with open(self.baseline_file, 'r', encoding='utf-8') as f: with open(self.baseline_file, 'r', encoding='utf-8') as f:
@@ -51,7 +73,12 @@ class FileCountBaseline:
return {} return {}
def _save_baseline(self): def _save_baseline(self):
"""保存基准数据""" """
将当前基准写入磁盘。
采用 `ensure_ascii=False` + 缩进格式,方便人工查看;
若目标目录缺失则自动创建。
"""
try: try:
os.makedirs(os.path.dirname(self.baseline_file), exist_ok=True) os.makedirs(os.path.dirname(self.baseline_file), exist_ok=True)
with open(self.baseline_file, 'w', encoding='utf-8') as f: with open(self.baseline_file, 'w', encoding='utf-8') as f:
@@ -60,7 +87,12 @@ class FileCountBaseline:
logger.exception(f"保存基准数据失败: {e}") logger.exception(f"保存基准数据失败: {e}")
def initialize_baseline(self, directories: Dict[str, str]) -> Dict[str, int]: def initialize_baseline(self, directories: Dict[str, str]) -> Dict[str, int]:
"""初始化文件数量基准""" """
初始化文件数量基准。
遍历每个引擎目录并统计 `.md` 文件数量,将结果持久化为
初始基准。后续 `check_new_files` 会据此对比增量。
"""
current_counts = {} current_counts = {}
for engine, directory in directories.items(): for engine, directory in directories.items():
@@ -78,7 +110,13 @@ class FileCountBaseline:
return current_counts return current_counts
def check_new_files(self, directories: Dict[str, str]) -> Dict[str, Any]: def check_new_files(self, directories: Dict[str, str]) -> Dict[str, Any]:
"""检查是否有新文件""" """
检查是否有新文件。
对比当前目录文件数与基准:
- 统计新增数量,并判定是否所有引擎都已准备就绪;
- 返回详细计数、缺失列表,供 Web 层提示给用户。
"""
current_counts = {} current_counts = {}
new_files_found = {} new_files_found = {}
all_have_new = True all_have_new = True
@@ -108,7 +146,12 @@ class FileCountBaseline:
} }
def get_latest_files(self, directories: Dict[str, str]) -> Dict[str, str]: def get_latest_files(self, directories: Dict[str, str]) -> Dict[str, str]:
"""获取每个目录的最新文件""" """
获取每个目录的最新文件。
通过 `os.path.getmtime` 找出最近写入的 Markdown
以确保生成流程永远使用最新一版三引擎报告。
"""
latest_files = {} latest_files = {}
for engine, directory in directories.items(): for engine, directory in directories.items():
@@ -122,14 +165,27 @@ class FileCountBaseline:
class ReportAgent: class ReportAgent:
"""Report Agent主类""" """
Report Agent主类。
负责集成:
- LLM客户端及其上层四个推理节点;
- 章节存储、IR装订、渲染器等产出链路;
- 状态管理、日志、输入输出校验与持久化。
"""
def __init__(self, config: Optional[Settings] = None): def __init__(self, config: Optional[Settings] = None):
""" """
初始化Report Agent 初始化Report Agent
Args: Args:
config: 配置对象,如果不提供则自动加载 config: 配置对象,如果不提供则自动加载
步骤概览:
1. 解析配置并接入日志/LLM/渲染等核心组件;
2. 构造四个推理节点(模板、布局、篇幅、章节);
3. 初始化文件基准与章节落盘目录;
4. 构建可序列化的状态容器,供外部服务查询。
""" """
# 加载配置 # 加载配置
self.config = config or settings self.config = config or settings
@@ -166,7 +222,13 @@ class ReportAgent:
logger.info(f"使用LLM: {self.llm_client.get_model_info()}") logger.info(f"使用LLM: {self.llm_client.get_model_info()}")
def _setup_logging(self): def _setup_logging(self):
"""设置日志""" """
设置日志。
- 确保日志目录存在;
- 使用独立的 loguru sink 写入 Report Engine 专属 log 文件,
避免与其他子系统混淆。
"""
# 确保日志目录存在 # 确保日志目录存在
log_dir = os.path.dirname(self.config.LOG_FILE) log_dir = os.path.dirname(self.config.LOG_FILE)
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
@@ -175,7 +237,12 @@ class ReportAgent:
logger.add(self.config.LOG_FILE, level="INFO") logger.add(self.config.LOG_FILE, level="INFO")
def _initialize_file_baseline(self): def _initialize_file_baseline(self):
"""初始化文件数量基准""" """
初始化文件数量基准。
将 Insight/Media/Query 三个目录传入 `FileCountBaseline`
生成一次性的参考值,之后按增量判断三引擎是否产出新报告。
"""
directories = { directories = {
'insight': 'insight_engine_streamlit_reports', 'insight': 'insight_engine_streamlit_reports',
'media': 'media_engine_streamlit_reports', 'media': 'media_engine_streamlit_reports',
@@ -184,7 +251,12 @@ class ReportAgent:
self.file_baseline.initialize_baseline(directories) self.file_baseline.initialize_baseline(directories)
def _initialize_llm(self) -> LLMClient: def _initialize_llm(self) -> LLMClient:
"""初始化LLM客户端""" """
初始化LLM客户端。
利用配置中的 API Key / 模型 / Base URL 构建统一的
`LLMClient` 实例,为所有节点提供复用的推理入口。
"""
return LLMClient( return LLMClient(
api_key=self.config.REPORT_ENGINE_API_KEY, api_key=self.config.REPORT_ENGINE_API_KEY,
model_name=self.config.REPORT_ENGINE_MODEL_NAME, model_name=self.config.REPORT_ENGINE_MODEL_NAME,
@@ -192,7 +264,12 @@ class ReportAgent:
) )
def _initialize_nodes(self): def _initialize_nodes(self):
"""初始化处理节点""" """
初始化处理节点。
顺序实例化模板选择、文档布局、篇幅规划、章节生成四个节点,
其中章节节点额外依赖 IR 校验器与章节存储器。
"""
self.template_selection_node = TemplateSelectionNode( self.template_selection_node = TemplateSelectionNode(
self.llm_client, self.llm_client,
self.config.TEMPLATE_DIR self.config.TEMPLATE_DIR
@@ -209,7 +286,14 @@ class ReportAgent:
custom_template: str = "", save_report: bool = True, custom_template: str = "", save_report: bool = True,
stream_handler: Optional[Callable[[str, Dict[str, Any]], None]] = None) -> str: stream_handler: Optional[Callable[[str, Dict[str, Any]], None]] = None) -> str:
""" """
生成综合报告(章节JSON → IR → HTML 生成综合报告(章节JSON → IR → HTML
主要阶段:
1. 归一化三引擎报告 + 论坛日志,并输出流式事件;
2. 模板选择 → 模板切片 → 文档布局 → 篇幅规划;
3. 结合篇幅目标逐章调用LLM,遇到解析错误会自动重试;
4. 将章节装订成Document IR,再交给HTML渲染器生成成品;
5. 可选地将HTML/IR/状态落盘,并向外界回传路径信息。
Returns: Returns:
dict: HTML内容以及保存的文件路径信息 dict: HTML内容以及保存的文件路径信息
@@ -441,7 +525,13 @@ class ReportAgent:
raise raise
def _select_template(self, query: str, reports: List[Any], forum_logs: str, custom_template: str): def _select_template(self, query: str, reports: List[Any], forum_logs: str, custom_template: str):
"""选择报告模板""" """
选择报告模板。
优先使用用户指定的模板;否则将查询、三引擎报告与论坛日志
作为上下文交给 TemplateSelectionNode,由 LLM 返回最契合的
模板名称、内容及理由,并自动记录在状态中。
"""
logger.info("选择报告模板...") logger.info("选择报告模板...")
# 如果用户提供了自定义模板,直接使用 # 如果用户提供了自定义模板,直接使用
@@ -481,7 +571,13 @@ class ReportAgent:
return fallback_template return fallback_template
def _slice_template(self, template_markdown: str) -> List[TemplateSection]: def _slice_template(self, template_markdown: str) -> List[TemplateSection]:
"""将模板切成章节列表,若为空则提供fallback""" """
将模板切成章节列表,若为空则提供fallback。
委托 `parse_template_sections` 将Markdown标题/编号解析为
`TemplateSection` 列表,确保后续章节生成有稳定的章节ID。
当模板格式异常时,会回退到内置的简单骨架避免崩溃。
"""
sections = parse_template_sections(template_markdown) sections = parse_template_sections(template_markdown)
if sections: if sections:
return sections return sections
@@ -510,10 +606,11 @@ class ReportAgent:
template_overview: Dict[str, Any], template_overview: Dict[str, Any],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
构造章节生成所需的共享上下文 构造章节生成所需的共享上下文
这里把“全书设计稿”“章节篇幅约束”“统一主题配色”等一次性整理好, 将模板名称、布局设计、主题配色、篇幅规划、论坛日志等
避免每次章节调用都重新拼装上下文。 一次性整合为 `generation_context`,后续每章调用 LLM 时
直接复用,确保所有章节共享一致的语调和视觉约束。
""" """
# 优先使用设计稿定制的主题色,否则退回默认主题 # 优先使用设计稿定制的主题色,否则退回默认主题
theme_tokens = ( theme_tokens = (
@@ -541,7 +638,12 @@ class ReportAgent:
} }
def _normalize_reports(self, reports: List[Any]) -> Dict[str, str]: def _normalize_reports(self, reports: List[Any]) -> Dict[str, str]:
"""将不同来源的报告统一转为字符串""" """
将不同来源的报告统一转为字符串。
约定顺序为 Query/Media/Insight,引擎提供的对象可能是
字典或自定义类型,因此统一走 `_stringify` 做容错。
"""
keys = ["query_engine", "media_engine", "insight_engine"] keys = ["query_engine", "media_engine", "insight_engine"]
normalized: Dict[str, str] = {} normalized: Dict[str, str] = {}
for idx, key in enumerate(keys): for idx, key in enumerate(keys):
@@ -551,7 +653,10 @@ class ReportAgent:
def _should_retry_inappropriate_content_error(self, error: Exception) -> bool: def _should_retry_inappropriate_content_error(self, error: Exception) -> bool:
""" """
判断LLM异常是否由内容安全/不当内容导致,满足时允许重新生成整章 判断LLM异常是否由内容安全/不当内容导致。
当检测到供应商返回的错误包含特定关键词时,允许章节生成
重新尝试,以便绕过偶发的内容审查触发。
""" """
message = str(error) if error else "" message = str(error) if error else ""
if not message: if not message:
@@ -566,7 +671,12 @@ class ReportAgent:
return any(keyword in normalized for keyword in keywords) return any(keyword in normalized for keyword in keywords)
def _stringify(self, value: Any) -> str: def _stringify(self, value: Any) -> str:
"""安全地将对象转成字符串""" """
安全地将对象转成字符串。
- dict/list 统一序列化为格式化 JSON,便于提示词消费;
- 其他类型走 `str()`None 则返回空串,避免 None 传播。
"""
if value is None: if value is None:
return "" return ""
if isinstance(value, str): if isinstance(value, str):
@@ -579,7 +689,11 @@ class ReportAgent:
return str(value) return str(value)
def _default_theme_tokens(self) -> Dict[str, Any]: def _default_theme_tokens(self) -> Dict[str, Any]:
"""默认的主题变量,供渲染器/LLM共用""" """
构造默认主题变量,供渲染器/LLM共用。
当布局节点未返回专属配色时使用该套色板,保持报告风格统一。
"""
return { return {
"colors": { "colors": {
"bg": "#f8f9fa", "bg": "#f8f9fa",
@@ -610,7 +724,11 @@ class ReportAgent:
template_markdown: str, template_markdown: str,
sections: List[TemplateSection], sections: List[TemplateSection],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""提取模板标题与章节骨架,供设计/篇幅规划统一引用""" """
提取模板标题与章节骨架,供设计/篇幅规划统一引用。
同时记录章节ID/slug/order等辅助字段,保证多节点对齐。
"""
fallback_title = sections[0].title if sections else "" fallback_title = sections[0].title if sections else ""
overview = { overview = {
"title": self._extract_template_title(template_markdown, fallback_title), "title": self._extract_template_title(template_markdown, fallback_title),
@@ -633,7 +751,12 @@ class ReportAgent:
@staticmethod @staticmethod
def _extract_template_title(template_markdown: str, fallback: str = "") -> str: def _extract_template_title(template_markdown: str, fallback: str = "") -> str:
"""尝试从Markdown中提取首个标题,找不到时使用fallback""" """
尝试从Markdown中提取首个标题。
优先返回首个 `#` 语法标题;如果模板首行就是正文,则回退到
第一行非空文本或调用方提供的 fallback。
"""
for line in template_markdown.splitlines(): for line in template_markdown.splitlines():
stripped = line.strip() stripped = line.strip()
if not stripped: if not stripped:
@@ -645,7 +768,12 @@ class ReportAgent:
return fallback or "智能舆情分析报告" return fallback or "智能舆情分析报告"
def _get_fallback_template_content(self) -> str: def _get_fallback_template_content(self) -> str:
"""获取备用模板内容""" """
获取备用模板内容。
当模板目录不可用或LLM选择失败时使用该 Markdown 模板,
保证后续流程仍能给出结构化章节。
"""
return """# 社会公共热点事件分析报告 return """# 社会公共热点事件分析报告
## 执行摘要 ## 执行摘要
@@ -694,7 +822,12 @@ class ReportAgent:
""" """
def _save_report(self, html_content: str, document_ir: Dict[str, Any], report_id: str) -> Dict[str, Any]: def _save_report(self, html_content: str, document_ir: Dict[str, Any], report_id: str) -> Dict[str, Any]:
"""保存HTML与IR到文件并返回路径信息""" """
保存HTML与IR到文件并返回路径信息。
生成基于查询和时间戳的易读文件名,同时也把运行态的
`ReportState` 写入 JSON,方便下游排障或断点续跑。
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
query_safe = "".join( query_safe = "".join(
c for c in self.state.metadata.query if c.isalnum() or c in (" ", "-", "_") c for c in self.state.metadata.query if c.isalnum() or c in (" ", "-", "_")
@@ -734,7 +867,12 @@ class ReportAgent:
} }
def _save_document_ir(self, document_ir: Dict[str, Any], query_safe: str, timestamp: str) -> Path: def _save_document_ir(self, document_ir: Dict[str, Any], query_safe: str, timestamp: str) -> Path:
"""将整本IR写入独立目录""" """
将整本IR写入独立目录。
`Document IR` 与 HTML 解耦保存,便于调试渲染差异以及
在不重新跑 LLM 的情况下再次渲染或导出其他格式。
"""
filename = f"report_ir_{query_safe}_{timestamp}.json" filename = f"report_ir_{query_safe}_{timestamp}.json"
ir_path = Path(self.config.DOCUMENT_IR_OUTPUT_DIR) / filename ir_path = Path(self.config.DOCUMENT_IR_OUTPUT_DIR) / filename
ir_path.write_text( ir_path.write_text(
@@ -751,8 +889,9 @@ class ReportAgent:
template_overview: Dict[str, Any], template_overview: Dict[str, Any],
): ):
""" """
将文档设计稿、篇幅规划与模板概览另存成JSON 将文档设计稿、篇幅规划与模板概览另存成JSON
这些中间件文件(document_layout/word_plan/template_overview
方便在调试或复盘时快速定位:标题/目录/主题是如何确定的、 方便在调试或复盘时快速定位:标题/目录/主题是如何确定的、
字数分配有什么要求,以便后续人工校正。 字数分配有什么要求,以便后续人工校正。
""" """
@@ -771,22 +910,22 @@ class ReportAgent:
logger.warning(f"写入{name}失败: {exc}") logger.warning(f"写入{name}失败: {exc}")
def get_progress_summary(self) -> Dict[str, Any]: def get_progress_summary(self) -> Dict[str, Any]:
"""获取进度摘要""" """获取进度摘要,直接返回可序列化的状态字典供API层查询。"""
return self.state.to_dict() return self.state.to_dict()
def load_state(self, filepath: str): def load_state(self, filepath: str):
"""从文件加载状态""" """从文件加载状态并覆盖当前state,便于断点恢复。"""
self.state = ReportState.load_from_file(filepath) self.state = ReportState.load_from_file(filepath)
logger.info(f"状态已从 {filepath} 加载") logger.info(f"状态已从 {filepath} 加载")
def save_state(self, filepath: str): def save_state(self, filepath: str):
"""保存状态到文件""" """保存状态到文件,通常用于任务完成后的分析与备份。"""
self.state.save_to_file(filepath) self.state.save_to_file(filepath)
logger.info(f"状态已保存到 {filepath}") logger.info(f"状态已保存到 {filepath}")
def check_input_files(self, insight_dir: str, media_dir: str, query_dir: str, forum_log_path: str) -> Dict[str, Any]: def check_input_files(self, insight_dir: str, media_dir: str, query_dir: str, forum_log_path: str) -> Dict[str, Any]:
""" """
检查输入文件是否准备就绪(基于文件数量增加) 检查输入文件是否准备就绪(基于文件数量增加)
Args: Args:
insight_dir: InsightEngine报告目录 insight_dir: InsightEngine报告目录
@@ -795,7 +934,7 @@ class ReportAgent:
forum_log_path: 论坛日志文件路径 forum_log_path: 论坛日志文件路径
Returns: Returns:
检查结果字典 检查结果字典,包含文件计数、缺失列表、最新文件路径等
""" """
# 检查各个报告目录的文件数量变化 # 检查各个报告目录的文件数量变化
directories = { directories = {
@@ -853,7 +992,7 @@ class ReportAgent:
file_paths: 文件路径字典 file_paths: 文件路径字典
Returns: Returns:
加载的内容字典 加载的内容字典,包含 `reports` 列表与 `forum_logs` 字符串
""" """
content = { content = {
'reports': [], 'reports': [],
@@ -887,13 +1026,15 @@ class ReportAgent:
def create_agent(config_file: Optional[str] = None) -> ReportAgent: def create_agent(config_file: Optional[str] = None) -> ReportAgent:
""" """
创建Report Agent实例的便捷函数 创建Report Agent实例的便捷函数
Args: Args:
config_file: 配置文件路径 config_file: 配置文件路径
Returns: Returns:
ReportAgent实例 ReportAgent实例
目前以环境变量驱动 `Settings`,保留 `config_file` 参数便于未来扩展。
""" """
config = Settings() # 以空配置初始化,而从从环境变量初始化 config = Settings() # 以空配置初始化,而从从环境变量初始化
+2 -1
View File
@@ -1,7 +1,8 @@
""" """
Report Engine核心工具集合。 Report Engine核心工具集合。
包含模板切片、章节存储等基础能力,供agent流水线复用。 该包封装了模板切片、章节存储与章节装订三大基础能力,
所有上层节点都会复用这些工具保证结构一致。
""" """
from .template_parser import TemplateSection, parse_template_sections from .template_parser import TemplateSection, parse_template_sections
+51 -20
View File
@@ -17,7 +17,12 @@ from typing import Dict, Generator, List, Optional
@dataclass @dataclass
class ChapterRecord: class ChapterRecord:
"""manifest中记录的章节元数据""" """
manifest中记录的章节元数据。
该结构用于在 `manifest.json` 中追踪每章的状态、文件位置、
以及可能的错误列表,方便前端或调试工具读取。
"""
chapter_id: str chapter_id: str
slug: str slug: str
@@ -46,12 +51,10 @@ class ChapterStorage:
""" """
章节JSON写入与manifest管理器。 章节JSON写入与manifest管理器。
用法 负责
run_dir = storage.start_session(report_id, {...}) - 为每次报告创建独立run目录与manifest快照;
chapter_dir = storage.begin_chapter(run_dir, meta) - 在章节流式生成时即时写入 `stream.raw`
with storage.capture_stream(chapter_dir) as fp: - 校验通过后持久化 `chapter.json` 并更新manifest状态。
fp.write(chunk)
storage.persist_chapter(run_dir, meta, payload, errors)
""" """
def __init__(self, base_dir: str): def __init__(self, base_dir: str):
@@ -68,7 +71,11 @@ class ChapterStorage:
# ======== 会话 & manifest ======== # ======== 会话 & manifest ========
def start_session(self, report_id: str, metadata: Dict[str, object]) -> Path: def start_session(self, report_id: str, metadata: Dict[str, object]) -> Path:
"""为本次报告创建独立的章节输出目录与manifest""" """
为本次报告创建独立的章节输出目录与manifest。
同时把全局metadata写入 `manifest.json`,供渲染/调试查询。
"""
run_dir = self.base_dir / report_id run_dir = self.base_dir / report_id
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
manifest = { manifest = {
@@ -82,7 +89,11 @@ class ChapterStorage:
return run_dir return run_dir
def begin_chapter(self, run_dir: Path, chapter_meta: Dict[str, object]) -> Path: def begin_chapter(self, run_dir: Path, chapter_meta: Dict[str, object]) -> Path:
"""创建章节子目录并在manifest中标记为streaming状态""" """
创建章节子目录并在manifest中标记为streaming状态。
会生成 `order-slug` 风格的子目录,并提前登记 raw 文件路径。
"""
slug_value = str( slug_value = str(
chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
) )
@@ -109,7 +120,11 @@ class ChapterStorage:
payload: Dict[str, object], payload: Dict[str, object],
errors: Optional[List[str]] = None, errors: Optional[List[str]] = None,
) -> Path: ) -> Path:
"""章节流式生成完毕后写入最终JSON并更新manifest状态""" """
章节流式生成完毕后写入最终JSON并更新manifest状态。
若校验失败,错误信息会被写入manifest,供前端展示。
"""
slug_value = str( slug_value = str(
chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section" chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
) )
@@ -140,7 +155,11 @@ class ChapterStorage:
return final_path return final_path
def load_chapters(self, run_dir: Path) -> List[Dict[str, object]]: def load_chapters(self, run_dir: Path) -> List[Dict[str, object]]:
"""从指定run目录读取全部chapter.json并按order排序返回""" """
从指定run目录读取全部chapter.json并按order排序返回。
常用于 DocumentComposer 将多个章节装订成整本IR。
"""
payloads: List[Dict[str, object]] = [] payloads: List[Dict[str, object]] = []
for child in sorted(run_dir.iterdir()): for child in sorted(run_dir.iterdir()):
if not child.is_dir(): if not child.is_dir():
@@ -160,7 +179,11 @@ class ChapterStorage:
@contextmanager @contextmanager
def capture_stream(self, chapter_dir: Path) -> Generator: def capture_stream(self, chapter_dir: Path) -> Generator:
"""将流式输出实时写入raw文件""" """
将流式输出实时写入raw文件。
通过 contextmanager 暴露文件句柄,简化章节节点的写入逻辑。
"""
raw_path = self._raw_stream_path(chapter_dir) raw_path = self._raw_stream_path(chapter_dir)
raw_path.parent.mkdir(parents=True, exist_ok=True) raw_path.parent.mkdir(parents=True, exist_ok=True)
with raw_path.open("w", encoding="utf-8") as fp: with raw_path.open("w", encoding="utf-8") as fp:
@@ -169,7 +192,7 @@ class ChapterStorage:
# ======== 内部工具 ======== # ======== 内部工具 ========
def _chapter_dir(self, run_dir: Path, slug: str, order: int) -> Path: def _chapter_dir(self, run_dir: Path, slug: str, order: int) -> Path:
"""根据slug/order生成稳定的章节目录,确保各章分隔存盘""" """根据slug/order生成稳定目录,确保各章分隔存盘且可排序。"""
safe_slug = self._safe_slug(slug) safe_slug = self._safe_slug(slug)
folder = f"{order:03d}-{safe_slug}" folder = f"{order:03d}-{safe_slug}"
path = run_dir / folder path = run_dir / folder
@@ -177,38 +200,46 @@ class ChapterStorage:
return path return path
def _safe_slug(self, slug: str) -> str: def _safe_slug(self, slug: str) -> str:
"""移除危险字符,避免生成非法文件夹名""" """移除危险字符,避免生成非法文件夹名"""
slug = slug.replace(" ", "-").replace("/", "-") slug = slug.replace(" ", "-").replace("/", "-")
return slug or "section" return slug or "section"
def _raw_stream_path(self, chapter_dir: Path) -> Path: def _raw_stream_path(self, chapter_dir: Path) -> Path:
"""返回某章节流式输出对应的raw文件路径""" """返回某章节流式输出对应的raw文件路径"""
return chapter_dir / "stream.raw" return chapter_dir / "stream.raw"
def _key(self, run_dir: Path) -> str: def _key(self, run_dir: Path) -> str:
"""将run目录解析为字典缓存的键,避免重复读取磁盘""" """将run目录解析为字典缓存的键,避免重复读取磁盘"""
return str(run_dir.resolve()) return str(run_dir.resolve())
def _manifest_path(self, run_dir: Path) -> Path: def _manifest_path(self, run_dir: Path) -> Path:
"""获取manifest.json的实际文件路径""" """获取manifest.json的实际文件路径"""
return run_dir / "manifest.json" return run_dir / "manifest.json"
def _write_manifest(self, run_dir: Path, manifest: Dict[str, object]): def _write_manifest(self, run_dir: Path, manifest: Dict[str, object]):
"""将内存中的manifest快照全量写回磁盘""" """将内存中的manifest快照全量写回磁盘"""
self._manifest_path(run_dir).write_text( self._manifest_path(run_dir).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8", encoding="utf-8",
) )
def _read_manifest(self, run_dir: Path) -> Dict[str, object]: def _read_manifest(self, run_dir: Path) -> Dict[str, object]:
"""从磁盘读取已有manifest,用于进程重启或多实例协作""" """
从磁盘读取已有manifest。
进程重启或多实例写盘时可借助它恢复上下文。
"""
manifest_path = self._manifest_path(run_dir) manifest_path = self._manifest_path(run_dir)
if manifest_path.exists(): if manifest_path.exists():
return json.loads(manifest_path.read_text(encoding="utf-8")) return json.loads(manifest_path.read_text(encoding="utf-8"))
return {"reportId": run_dir.name, "chapters": []} return {"reportId": run_dir.name, "chapters": []}
def _upsert_record(self, run_dir: Path, record: ChapterRecord): def _upsert_record(self, run_dir: Path, record: ChapterRecord):
"""更新或追加manifest中的章节记录,保证顺序一致""" """
更新或追加manifest中的章节记录,保证顺序一致。
内部会自动排序并写回缓存+磁盘。
"""
key = self._key(run_dir) key = self._key(run_dir)
manifest = self._manifests.get(key) or self._read_manifest(run_dir) manifest = self._manifests.get(key) or self._read_manifest(run_dir)
chapters: List[Dict[str, object]] = manifest.get("chapters", []) chapters: List[Dict[str, object]] = manifest.get("chapters", [])
+13 -2
View File
@@ -1,5 +1,7 @@
""" """
章节装订器负责把多个章节JSON合并为整本IR 章节装订器负责把多个章节JSON合并为整本IR
DocumentComposer 会注入缺失锚点统一顺序并补齐 IR 级元数据
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,6 +15,11 @@ from ..ir import IR_VERSION
class DocumentComposer: class DocumentComposer:
""" """
将章节拼接成Document IR的简单装订器 将章节拼接成Document IR的简单装订器
作用
- 按order排序章节补充默认chapterId
- 防止anchor重复生成全局唯一锚点
- 注入 IR 版本与生成时间戳
""" """
def __init__(self): def __init__(self):
@@ -25,7 +32,11 @@ class DocumentComposer:
metadata: Dict[str, object], metadata: Dict[str, object],
chapters: List[Dict[str, object]], chapters: List[Dict[str, object]],
) -> Dict[str, object]: ) -> Dict[str, object]:
"""把所有章节按order排序并注入唯一锚点,形成整本IR""" """
把所有章节按order排序并注入唯一锚点形成整本IR
同时合并 metadata/themeTokens/assets供渲染器直接消费
"""
ordered = sorted(chapters, key=lambda c: c.get("order", 0)) ordered = sorted(chapters, key=lambda c: c.get("order", 0))
for idx, chapter in enumerate(ordered, start=1): for idx, chapter in enumerate(ordered, start=1):
chapter.setdefault("chapterId", f"S{idx}") chapter.setdefault("chapterId", f"S{idx}")
@@ -48,7 +59,7 @@ class DocumentComposer:
return document return document
def _ensure_unique_anchor(self, anchor: str) -> str: def _ensure_unique_anchor(self, anchor: str) -> str:
"""若存在重复锚点则追加序号,确保全局唯一""" """若存在重复锚点则追加序号,确保全局唯一"""
base = anchor base = anchor
counter = 2 counter = 2
while anchor in self._seen_anchors: while anchor in self._seen_anchors:
+37 -9
View File
@@ -18,7 +18,12 @@ SECTION_ORDER_STEP = 10
@dataclass @dataclass
class TemplateSection: class TemplateSection:
"""模板章节实体""" """
模板章节实体
记录标题slug序号层级原始标题章节编号与提纲
方便后续节点在提示词中引用并保持锚点一致
"""
title: str title: str
slug: str slug: str
@@ -30,7 +35,11 @@ class TemplateSection:
outline: List[str] = field(default_factory=list) outline: List[str] = field(default_factory=list)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""将章节实体序列化为字典,方便传给LLM或落盘""" """
将章节实体序列化为字典
该结构广泛用于提示词上下文以及 layout/word budget 节点的输入
"""
return { return {
"title": self.title, "title": self.title,
"slug": self.slug, "slug": self.slug,
@@ -52,7 +61,8 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]:
将Markdown模板切分成章节列表按大标题 将Markdown模板切分成章节列表按大标题
返回的每个TemplateSection都携带slug/order/章节号 返回的每个TemplateSection都携带slug/order/章节号
方便后续分章调用与锚点生成 方便后续分章调用与锚点生成解析时会同时兼容
# 标题”“无符号编号”“列表提纲”等不同写法。
""" """
sections: List[TemplateSection] = [] sections: List[TemplateSection] = []
@@ -98,7 +108,12 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]:
def _classify_line(stripped: str, indent: int) -> Optional[dict]: def _classify_line(stripped: str, indent: int) -> Optional[dict]:
"""根据缩进与符号分类行""" """
根据缩进与符号分类行
借助正则判断当前行是章节标题提纲还是普通列表项
并衍生 depth/slug/number 等派生信息
"""
heading_match = heading_pattern.match(stripped) heading_match = heading_pattern.match(stripped)
if heading_match: if heading_match:
@@ -154,14 +169,19 @@ def _classify_line(stripped: str, indent: int) -> Optional[dict]:
def _strip_markup(text: str) -> str: def _strip_markup(text: str) -> str:
"""去除包裹的**、__等简单强调标记""" """去除包裹的**、__等强调标记,避免干扰标题匹配。"""
if text.startswith(("**", "__")) and text.endswith(("**", "__")) and len(text) > 4: if text.startswith(("**", "__")) and text.endswith(("**", "__")) and len(text) > 4:
return text[2:-2].strip() return text[2:-2].strip()
return text return text
def _split_number(payload: str) -> dict: def _split_number(payload: str) -> dict:
"""拆分编号与标题""" """
拆分编号与标题
例如 `1.2 市场趋势` 会被拆成 number=1.2label=市场趋势
并提供 display 用于回填标题
"""
match = number_pattern.match(payload) match = number_pattern.match(payload)
number = match.group("num") if match else "" number = match.group("num") if match else ""
label = match.group("label") if match else payload label = match.group("label") if match else payload
@@ -176,7 +196,7 @@ def _split_number(payload: str) -> dict:
def _build_slug(number: str, title: str) -> str: def _build_slug(number: str, title: str) -> str:
"""根据编号/标题生成锚点""" """根据编号/标题生成锚点,优先复用编号,缺失时对标题slug化。"""
if number: if number:
token = number.replace(".", "-") token = number.replace(".", "-")
else: else:
@@ -186,7 +206,11 @@ def _build_slug(number: str, title: str) -> str:
def _slugify_text(text: str) -> str: def _slugify_text(text: str) -> str:
"""对任意文本做降噪与转写,得到URL友好的slug片段""" """
对任意文本做降噪与转写得到URL友好的slug片段
会规整大小写移除特殊符号并保留汉字确保锚点可读
"""
text = unicodedata.normalize("NFKD", text) text = unicodedata.normalize("NFKD", text)
text = text.replace("·", "-").replace(" ", "-") text = text.replace("·", "-").replace(" ", "-")
text = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff-]+", "-", text) text = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff-]+", "-", text)
@@ -195,7 +219,11 @@ def _slugify_text(text: str) -> str:
def _ensure_unique_slug(slug: str, used: set) -> str: def _ensure_unique_slug(slug: str, used: set) -> str:
"""若slug重复则自动追加序号,直到在used集合中唯一""" """
若slug重复则自动追加序号直到在used集合中唯一
通过 `-2/-3...` 的方式保证相同标题不会产生重复锚点
"""
if slug not in used: if slug not in used:
used.add(slug) used.add(slug)
return slug return slug
+83 -22
View File
@@ -1,6 +1,10 @@
""" """
Report Engine Flask接口 Report Engine Flask接口
提供HTTP API用于报告生成
该模块为前端/CLI提供统一HTTP/SSE入口负责
1. 初始化 ReportAgent 并串联后台线程
2. 管理任务排队进度查询流式推送与日志下载
3. 提供模板列表输入文件检查等周边能力
""" """
import os import os
@@ -35,7 +39,11 @@ tasks_registry: Dict[str, 'ReportTask'] = {}
def _register_stream(task_id: str) -> Queue: def _register_stream(task_id: str) -> Queue:
"""为指定任务注册一个事件队列,供SSE监听器消费。""" """
为指定任务注册一个事件队列供SSE监听器消费
返回的 Queue 会存入 `stream_subscribers`SSE 生成器将不断读取
"""
queue = Queue() queue = Queue()
with stream_lock: with stream_lock:
stream_subscribers[task_id].append(queue) stream_subscribers[task_id].append(queue)
@@ -43,7 +51,11 @@ def _register_stream(task_id: str) -> Queue:
def _unregister_stream(task_id: str, queue: Queue): def _unregister_stream(task_id: str, queue: Queue):
"""安全移除事件队列,避免内存泄漏。""" """
安全移除事件队列避免内存泄漏
需要在finally中调用保证异常情况下资源也能释放
"""
with stream_lock: with stream_lock:
listeners = stream_subscribers.get(task_id, []) listeners = stream_subscribers.get(task_id, [])
if queue in listeners: if queue in listeners:
@@ -53,7 +65,11 @@ def _unregister_stream(task_id: str, queue: Queue):
def _broadcast_event(task_id: str, event: Dict[str, Any]): def _broadcast_event(task_id: str, event: Dict[str, Any]):
"""将事件推送给所有监听者,失败时做好异常捕获。""" """
将事件推送给所有监听者失败时做好异常捕获
采用浅拷贝监听列表防止并发移除导致遍历异常
"""
with stream_lock: with stream_lock:
listeners = list(stream_subscribers.get(task_id, [])) listeners = list(stream_subscribers.get(task_id, []))
for queue in listeners: for queue in listeners:
@@ -64,7 +80,11 @@ def _broadcast_event(task_id: str, event: Dict[str, Any]):
def _prune_task_history_locked(): def _prune_task_history_locked():
"""在task_lock持有期间调用,清理过多的历史任务以控制内存。""" """
在task_lock持有期间调用清理过多的历史任务
仅保留最近 `MAX_TASK_HISTORY` 个任务避免长时间运行占用过多内存
"""
if len(tasks_registry) <= MAX_TASK_HISTORY: if len(tasks_registry) <= MAX_TASK_HISTORY:
return return
# 按创建时间排序,移除最旧的任务 # 按创建时间排序,移除最旧的任务
@@ -74,7 +94,11 @@ def _prune_task_history_locked():
def _get_task(task_id: str) -> Optional['ReportTask']: def _get_task(task_id: str) -> Optional['ReportTask']:
"""统一的任务查找方法,优先返回当前任务。""" """
统一的任务查找方法优先返回当前任务
避免重复写锁逻辑便于多个API共享
"""
with task_lock: with task_lock:
if current_task and current_task.task_id == task_id: if current_task and current_task.task_id == task_id:
return current_task return current_task
@@ -82,7 +106,11 @@ def _get_task(task_id: str) -> Optional['ReportTask']:
def _format_sse(event: Dict[str, Any]) -> str: def _format_sse(event: Dict[str, Any]) -> str:
"""按SSE协议格式化消息。""" """
按SSE协议格式化消息
输出形如 `id:/event:/data:` 的三段文本供浏览器端直接消费
"""
payload = json.dumps(event, ensure_ascii=False) payload = json.dumps(event, ensure_ascii=False)
event_id = event.get('id', 0) event_id = event.get('id', 0)
event_type = event.get('type', 'message') event_type = event.get('type', 'message')
@@ -90,7 +118,11 @@ def _format_sse(event: Dict[str, Any]) -> str:
def initialize_report_engine(): def initialize_report_engine():
"""初始化Report Engine""" """
初始化Report Engine
单例化 ReportAgent方便 API 启动后直接接收任务
"""
global report_agent global report_agent
try: try:
report_agent = create_agent() report_agent = create_agent()
@@ -102,7 +134,12 @@ def initialize_report_engine():
class ReportTask: class ReportTask:
"""报告生成任务""" """
报告生成任务
该对象串联运行状态进度事件历史及最终文件路径
既供后台线程更新也供HTTP接口读取
"""
def __init__(self, query: str, task_id: str, custom_template: str = ""): def __init__(self, query: str, task_id: str, custom_template: str = ""):
""" """
@@ -135,7 +172,11 @@ class ReportTask:
self.last_event_id = 0 self.last_event_id = 0
def update_status(self, status: str, progress: int = None, error_message: str = ""): def update_status(self, status: str, progress: int = None, error_message: str = ""):
"""更新任务状态""" """
更新任务状态并广播事件
会自动刷新 `updated_at`错误信息并触发 `status` 类型的 SSE
"""
self.status = status self.status = status
if progress is not None: if progress is not None:
self.progress = progress self.progress = progress
@@ -155,7 +196,7 @@ class ReportTask:
) )
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式""" """转换为字典格式,方便直接返回给JSON API。"""
return { return {
'task_id': self.task_id, 'task_id': self.task_id,
'query': self.query, 'query': self.query,
@@ -197,7 +238,12 @@ class ReportTask:
def check_engines_ready() -> Dict[str, Any]: def check_engines_ready() -> Dict[str, Any]:
"""检查三个子引擎是否都有新文件""" """
检查三个子引擎是否都有新文件
调用 ReportAgent 的基准检测逻辑并附带论坛日志存在性
/status/generate 的前置校验
"""
directories = { directories = {
'insight': 'insight_engine_streamlit_reports', 'insight': 'insight_engine_streamlit_reports',
'media': 'media_engine_streamlit_reports', 'media': 'media_engine_streamlit_reports',
@@ -221,7 +267,12 @@ def check_engines_ready() -> Dict[str, Any]:
def run_report_generation(task: ReportTask, query: str, custom_template: str = ""): def run_report_generation(task: ReportTask, query: str, custom_template: str = ""):
"""在后台线程中运行报告生成""" """
在后台线程中运行报告生成
包括检查输入加载文档调用ReportAgent持久化输出
推送阶段性事件出现错误会自动推送并写状态
"""
global current_task global current_task
try: try:
@@ -334,7 +385,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = "
@report_bp.route('/status', methods=['GET']) @report_bp.route('/status', methods=['GET'])
def get_status(): def get_status():
"""获取Report Engine状态""" """获取Report Engine状态,包括引擎就绪情况与当前任务信息。"""
try: try:
engines_status = check_engines_ready() engines_status = check_engines_ready()
@@ -356,7 +407,11 @@ def get_status():
@report_bp.route('/generate', methods=['POST']) @report_bp.route('/generate', methods=['POST'])
def generate_report(): def generate_report():
"""开始生成报告""" """
开始生成报告
负责排队创建后台线程清空日志并返回SSE地址
"""
global current_task global current_task
try: try:
@@ -443,7 +498,7 @@ def generate_report():
@report_bp.route('/progress/<task_id>', methods=['GET']) @report_bp.route('/progress/<task_id>', methods=['GET'])
def get_progress(task_id: str): def get_progress(task_id: str):
"""获取报告生成进度""" """获取报告生成进度,若任务被清理则返回一个完成态兜底。"""
try: try:
task = _get_task(task_id) task = _get_task(task_id)
if not task: if not task:
@@ -479,7 +534,13 @@ def get_progress(task_id: str):
@report_bp.route('/stream/<task_id>', methods=['GET']) @report_bp.route('/stream/<task_id>', methods=['GET'])
def stream_task(task_id: str): def stream_task(task_id: str):
"""基于SSE的实时推送接口,向前端持续广播阶段事件。""" """
基于SSE的实时推送接口
- 自动补发Last-Event-ID之后的历史事件
- 周期性发送心跳以防代理中断
- 任务结束后自动注销监听
"""
task = _get_task(task_id) task = _get_task(task_id)
if not task: if not task:
return jsonify({'success': False, 'error': '任务不存在'}), 404 return jsonify({'success': False, 'error': '任务不存在'}), 404
@@ -674,7 +735,7 @@ def cancel_task(task_id: str):
@report_bp.route('/templates', methods=['GET']) @report_bp.route('/templates', methods=['GET'])
def get_templates(): def get_templates():
"""获取可用模板列表""" """获取可用模板列表,便于前端展示可选Markdown骨架。"""
try: try:
if not report_agent: if not report_agent:
return jsonify({ return jsonify({
@@ -738,7 +799,7 @@ def internal_error(error):
def clear_report_log(): def clear_report_log():
"""清空report.log文件""" """清空report.log文件,方便新任务只查看本次运行日志。"""
try: try:
log_file = settings.LOG_FILE log_file = settings.LOG_FILE
with open(log_file, 'w', encoding='utf-8') as f: with open(log_file, 'w', encoding='utf-8') as f:
@@ -750,7 +811,7 @@ def clear_report_log():
@report_bp.route('/log', methods=['GET']) @report_bp.route('/log', methods=['GET'])
def get_report_log(): def get_report_log():
"""获取report.log内容""" """获取report.log内容,并按行去除空白返回。"""
try: try:
log_file = settings.LOG_FILE log_file = settings.LOG_FILE
@@ -781,7 +842,7 @@ def get_report_log():
@report_bp.route('/log/clear', methods=['POST']) @report_bp.route('/log/clear', methods=['POST'])
def clear_log(): def clear_log():
"""手动清空日志""" """手动清空日志,提供REST入口供前端一键重置。"""
try: try:
clear_report_log() clear_report_log()
return jsonify({ return jsonify({
+1
View File
@@ -20,6 +20,7 @@ class IRValidator:
说明 说明
- validate_chapter返回(是否通过, 错误列表) - validate_chapter返回(是否通过, 错误列表)
- 错误定位采用path语法便于快速追踪 - 错误定位采用path语法便于快速追踪
- 内置对heading/paragraph/list/table等所有区块的细粒度校验
""" """
def __init__(self, schema_version: str = IR_VERSION): def __init__(self, schema_version: str = IR_VERSION):
+3 -1
View File
@@ -1,5 +1,7 @@
""" """
LLM module for the Report Engine. Report Engine LLM子模块
目前主要暴露 OpenAI 兼容的 `LLMClient` 封装
""" """
from .base import LLMClient from .base import LLMClient
+4 -2
View File
@@ -1,5 +1,7 @@
""" """
Report Engine 默认的OpenAI兼容LLM客户端封装内置重试/流式能力 Report Engine 默认的OpenAI兼容LLM客户端封装
提供统一的非流式/流式调用可选重试字节安全拼接与模型元信息查询
""" """
import os import os
@@ -107,7 +109,7 @@ class LLMClient:
**kwargs: 额外参数temperature, top_p等 **kwargs: 额外参数temperature, top_p等
Yields: Yields:
响应文本块str 响应文本块str调用方可边读边写入磁盘或透传到UI
""" """
messages = [ messages = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
+3 -2
View File
@@ -1,6 +1,7 @@
""" """
Report Engine节点处理模块 Report Engine节点处理模块
实现报告生成的各个处理步骤
封装模板选择章节生成文档布局篇幅规划等流水线节点
""" """
from .base_node import BaseNode, StateMutationNode from .base_node import BaseNode, StateMutationNode
+25 -9
View File
@@ -1,6 +1,7 @@
""" """
Report Engine节点基类 Report Engine节点基类
定义所有处理节点的基础接口
所有高阶推理节点都继承于此统一日志输入校验与状态变更接口
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@@ -10,7 +11,12 @@ from ..state.state import ReportState
from loguru import logger from loguru import logger
class BaseNode(ABC): class BaseNode(ABC):
"""节点基类""" """
节点基类
统一实现日志工具输入/输出钩子以及LLM客户端依赖注入
便于所有节点只专注业务逻辑
"""
def __init__(self, llm_client: LLMClient, node_name: str = ""): def __init__(self, llm_client: LLMClient, node_name: str = ""):
""" """
@@ -19,6 +25,8 @@ class BaseNode(ABC):
Args: Args:
llm_client: LLM客户端 llm_client: LLM客户端
node_name: 节点名称 node_name: 节点名称
BaseNode 会保存节点名以便统一输出日志前缀
""" """
self.llm_client = llm_client self.llm_client = llm_client
self.node_name = node_name or self.__class__.__name__ self.node_name = node_name or self.__class__.__name__
@@ -39,7 +47,8 @@ class BaseNode(ABC):
def validate_input(self, input_data: Any) -> bool: def validate_input(self, input_data: Any) -> bool:
""" """
验证输入数据 验证输入数据
默认直接通过子类可按需覆写实现字段检查
Args: Args:
input_data: 输入数据 input_data: 输入数据
@@ -51,7 +60,8 @@ class BaseNode(ABC):
def process_output(self, output: Any) -> Any: def process_output(self, output: Any) -> Any:
""" """
处理输出数据 处理输出数据
子类可覆写进行结构化或校验
Args: Args:
output: 原始输出 output: 原始输出
@@ -62,23 +72,29 @@ class BaseNode(ABC):
return output return output
def log_info(self, message: str): def log_info(self, message: str):
"""记录信息日志""" """记录信息日志,并自动带上节点名作为前缀。"""
formatted_message = f"[{self.node_name}] {message}" formatted_message = f"[{self.node_name}] {message}"
logger.info(formatted_message) logger.info(formatted_message)
def log_error(self, message: str): def log_error(self, message: str):
"""记录错误日志""" """记录错误日志,便于排障。"""
formatted_message = f"[{self.node_name}] {message}" formatted_message = f"[{self.node_name}] {message}"
logger.error(formatted_message) logger.error(formatted_message)
class StateMutationNode(BaseNode): class StateMutationNode(BaseNode):
"""带状态修改功能的节点基类""" """
带状态修改功能的节点基类
适用于节点需要直接写入 ReportState 的场景
"""
@abstractmethod @abstractmethod
def mutate_state(self, input_data: Any, state: ReportState, **kwargs) -> ReportState: def mutate_state(self, input_data: Any, state: ReportState, **kwargs) -> ReportState:
""" """
修改状态 修改状态
子类需返回新的状态对象或在原地修改后回传供流水线记录
Args: Args:
input_data: 输入数据 input_data: 输入数据
+10 -2
View File
@@ -29,7 +29,7 @@ except ImportError: # pragma: no cover - optional dependency
class ChapterJsonParseError(ValueError): class ChapterJsonParseError(ValueError):
"""Raised when the LLM output for a chapter cannot be parsed as valid JSON.""" """章节LLM输出无法解析为合法JSON时抛出的异常,附带原始文本方便排查。"""
def __init__(self, message: str, raw_text: Optional[str] = None): def __init__(self, message: str, raw_text: Optional[str] = None):
super().__init__(message) super().__init__(message)
@@ -37,7 +37,15 @@ class ChapterJsonParseError(ValueError):
class ChapterGenerationNode(BaseNode): class ChapterGenerationNode(BaseNode):
"""负责按章节调用LLM并校验JSON结构""" """
负责按章节调用LLM并校验JSON结构
核心能力
- 构造章节级 payload 与提示词
- 以流式形式写入 raw 文件并透传 delta
- 尝试修复/解析LLM输出并使用 IRValidator 校验
- 对block结构做容错修复确保最终JSON可渲染
"""
_COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=') _COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=')
_LINE_BREAK_SENTINEL = "__LINE_BREAK__" _LINE_BREAK_SENTINEL = "__LINE_BREAK__"
+5 -1
View File
@@ -18,7 +18,11 @@ from .base_node import BaseNode
class DocumentLayoutNode(BaseNode): class DocumentLayoutNode(BaseNode):
"""负责生成全局标题、目录与Hero设计""" """
负责生成全局标题目录与Hero设计
结合模板切片报告摘要与论坛讨论指导整本书的视觉与结构基调
"""
def __init__(self, llm_client): def __init__(self, llm_client):
"""记录LLM客户端并设置节点名字,供BaseNode日志使用""" """记录LLM客户端并设置节点名字,供BaseNode日志使用"""
+35 -11
View File
@@ -1,6 +1,8 @@
""" """
模板选择节点 模板选择节点
根据查询内容和可用模板选择最合适的报告模板
综合用户查询三引擎报告论坛日志与本地模板库
调用LLM挑选最合适的报告骨架
""" """
import os import os
@@ -13,7 +15,12 @@ from ..prompts import SYSTEM_PROMPT_TEMPLATE_SELECTION
class TemplateSelectionNode(BaseNode): class TemplateSelectionNode(BaseNode):
"""模板选择处理节点""" """
模板选择处理节点
负责准备模板候选列表构建提示词解析LLM返回结果
并在失败时回退到内置模板
"""
def __init__(self, llm_client, template_dir: str = "ReportEngine/report_template"): def __init__(self, llm_client, template_dir: str = "ReportEngine/report_template"):
""" """
@@ -28,7 +35,7 @@ class TemplateSelectionNode(BaseNode):
def run(self, input_data: Dict[str, Any], **kwargs) -> Dict[str, Any]: def run(self, input_data: Dict[str, Any], **kwargs) -> Dict[str, Any]:
""" """
执行模板选择 执行模板选择
Args: Args:
input_data: 包含查询和报告内容的字典 input_data: 包含查询和报告内容的字典
@@ -37,7 +44,7 @@ class TemplateSelectionNode(BaseNode):
- forum_logs: 论坛日志内容 - forum_logs: 论坛日志内容
Returns: Returns:
选择的模板信息 选择的模板信息包含名称内容与选择理由
""" """
logger.info("开始模板选择...") logger.info("开始模板选择...")
@@ -67,7 +74,12 @@ class TemplateSelectionNode(BaseNode):
def _llm_template_selection(self, query: str, reports: List[Any], forum_logs: str, def _llm_template_selection(self, query: str, reports: List[Any], forum_logs: str,
available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""使用LLM进行模板选择""" """
使用LLM进行模板选择
构造模板列表与报告摘要 调用LLM 解析JSON
验证模板是否存在并返回标准结构
"""
logger.info("尝试使用LLM进行模板选择...") logger.info("尝试使用LLM进行模板选择...")
# 构建模板列表 # 构建模板列表
@@ -150,7 +162,11 @@ class TemplateSelectionNode(BaseNode):
return self._extract_template_from_text(response, available_templates) return self._extract_template_from_text(response, available_templates)
def _clean_llm_response(self, response: str) -> str: def _clean_llm_response(self, response: str) -> str:
"""清理LLM响应""" """
清理LLM响应
去掉 ```json``` 包裹以及前后空白方便 `json.loads`
"""
# 移除可能的markdown代码块标记 # 移除可能的markdown代码块标记
if '```json' in response: if '```json' in response:
response = response.split('```json')[1].split('```')[0] response = response.split('```json')[1].split('```')[0]
@@ -163,7 +179,11 @@ class TemplateSelectionNode(BaseNode):
return response return response
def _extract_template_from_text(self, response: str, available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: def _extract_template_from_text(self, response: str, available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""从文本响应中提取模板信息""" """
从文本响应中提取模板信息
当LLM未输出合法JSON时尝试匹配模板名称关键字做降级
"""
logger.info("尝试从文本响应中提取模板信息") logger.info("尝试从文本响应中提取模板信息")
# 查找响应中是否包含模板名称 # 查找响应中是否包含模板名称
@@ -186,7 +206,11 @@ class TemplateSelectionNode(BaseNode):
return None return None
def _get_available_templates(self) -> List[Dict[str, Any]]: def _get_available_templates(self) -> List[Dict[str, Any]]:
"""获取可用的模板列表""" """
获取可用的模板列表
枚举模板目录下的 `.md` 文件并读取内容与描述字段
"""
templates = [] templates = []
if not os.path.exists(self.template_dir): if not os.path.exists(self.template_dir):
@@ -216,7 +240,7 @@ class TemplateSelectionNode(BaseNode):
return templates return templates
def _extract_template_description(self, template_name: str) -> str: def _extract_template_description(self, template_name: str) -> str:
"""根据模板名称生成描述""" """根据模板名称生成描述,方便LLM理解模板定位。"""
if '企业品牌' in template_name: if '企业品牌' in template_name:
return "适用于企业品牌声誉和形象分析" return "适用于企业品牌声誉和形象分析"
elif '市场竞争' in template_name: elif '市场竞争' in template_name:
@@ -235,7 +259,7 @@ class TemplateSelectionNode(BaseNode):
def _get_fallback_template(self) -> Dict[str, Any]: def _get_fallback_template(self) -> Dict[str, Any]:
"""获取备用默认模板(空模板,让LLM自行发挥)""" """获取备用默认模板(空模板,让LLM自行发挥)"""
logger.info("未找到合适模板,使用空模板让LLM自行发挥") logger.info("未找到合适模板,使用空模板让LLM自行发挥")
return { return {
+5 -1
View File
@@ -18,7 +18,11 @@ from .base_node import BaseNode
class WordBudgetNode(BaseNode): class WordBudgetNode(BaseNode):
"""规划各章节字数与重点""" """
规划各章节字数与重点
输出总字数全局写作准则以及每章/小节的 target/min/max 字数约束
"""
def __init__(self, llm_client): def __init__(self, llm_client):
"""仅记录LLM客户端引用,方便run阶段发起请求""" """仅记录LLM客户端引用,方便run阶段发起请求"""
+3 -2
View File
@@ -1,6 +1,7 @@
""" """
Report Engine提示词模块 Report Engine提示词模块
定义报告生成各个阶段使用的系统提示词
集中导出各阶段系统提示词与辅助函数其他模块可直接from prompts import
""" """
from .prompts import ( from .prompts import (
+8 -4
View File
@@ -1,6 +1,8 @@
""" """
Report Engine 的所有提示词定义 Report Engine 的所有提示词定义
参考MediaEngine的结构专门用于报告生成
集中声明模板选择章节JSON文档布局篇幅规划等阶段的系统提示词
并提供输入输出Schema文本方便LLM理解结构约束
""" """
import json import json
@@ -359,15 +361,17 @@ SYSTEM_PROMPT_WORD_BUDGET = f"""
def build_chapter_user_prompt(payload: dict) -> str: def build_chapter_user_prompt(payload: dict) -> str:
""" """
将章节上下文序列化为提示词输入 将章节上下文序列化为提示词输入
统一使用 `json.dumps(..., indent=2, ensure_ascii=False)`便于LLM读取
""" """
return json.dumps(payload, ensure_ascii=False, indent=2) return json.dumps(payload, ensure_ascii=False, indent=2)
def build_document_layout_prompt(payload: dict) -> str: def build_document_layout_prompt(payload: dict) -> str:
"""将文档设计所需的上下文序列化为JSON字符串""" """将文档设计所需的上下文序列化为JSON字符串,供布局节点发送给LLM。"""
return json.dumps(payload, ensure_ascii=False, indent=2) return json.dumps(payload, ensure_ascii=False, indent=2)
def build_word_budget_prompt(payload: dict) -> str: def build_word_budget_prompt(payload: dict) -> str:
"""将篇幅规划输入转为字符串,便于送入LLM""" """将篇幅规划输入转为字符串,便于送入LLM并保持字段精确。"""
return json.dumps(payload, ensure_ascii=False, indent=2) return json.dumps(payload, ensure_ascii=False, indent=2)
+2
View File
@@ -1,5 +1,7 @@
""" """
Report Engine渲染器集合 Report Engine渲染器集合
目前仅提供 HTMLRenderer未来可扩展为PDF/Markdown等输出
""" """
from .html_renderer import HTMLRenderer from .html_renderer import HTMLRenderer
+7 -1
View File
@@ -11,7 +11,13 @@ from typing import Any, Dict, List
class HTMLRenderer: class HTMLRenderer:
"""Document IR → HTML 渲染器""" """
Document IR HTML 渲染器
- 读取 IR metadata/chapters将结构映射为响应式HTML
- 动态构造目录锚点Chart.js脚本及互动逻辑
- 提供主题变量编号映射等辅助功能
"""
def __init__(self, config: Dict[str, Any] | None = None): def __init__(self, config: Dict[str, Any] | None = None):
"""初始化渲染器缓存并允许注入额外配置(如主题覆盖)""" """初始化渲染器缓存并允许注入额外配置(如主题覆盖)"""
+3 -2
View File
@@ -1,6 +1,7 @@
""" """
Report Engine状态管理模块 Report Engine状态管理模块
定义报告生成过程中的简化状态数据结构
导出 ReportState/ReportMetadata供Agent与Flask接口共享
""" """
from .state import ReportState, ReportMetadata from .state import ReportState, ReportMetadata
+14 -10
View File
@@ -29,7 +29,11 @@ class ReportMetadata:
@dataclass @dataclass
class ReportState: class ReportState:
"""简化的报告状态管理""" """
简化的报告状态管理
存储任务基本信息输入输出与元数据供Agent与Flask层共享
"""
# 基本信息 # 基本信息
task_id: str = "" # 任务ID task_id: str = "" # 任务ID
query: str = "" # 原始查询 query: str = "" # 原始查询
@@ -55,24 +59,24 @@ class ReportState:
self.metadata.query = self.query self.metadata.query = self.query
def mark_processing(self): def mark_processing(self):
"""标记为处理中""" """标记为处理中,后台线程开始调度生成流程。"""
self.status = "processing" self.status = "processing"
def mark_completed(self): def mark_completed(self):
"""标记为完成""" """标记为完成,同时意味着 `html_content` 已可用。"""
self.status = "completed" self.status = "completed"
def mark_failed(self, error_message: str = ""): def mark_failed(self, error_message: str = ""):
"""标记为失败""" """标记为失败,并记录最后一次错误消息。"""
self.status = "failed" self.status = "failed"
self.error_message = error_message self.error_message = error_message
def is_completed(self) -> bool: def is_completed(self) -> bool:
"""检查是否完成""" """检查是否完成,包括状态为completed且存在HTML内容。"""
return self.status == "completed" and bool(self.html_content) return self.status == "completed" and bool(self.html_content)
def get_progress(self) -> float: def get_progress(self) -> float:
"""获取进度百分比""" """获取进度百分比,按照模板/内容两个阶段粗略估算。"""
if self.status == "completed": if self.status == "completed":
return 100.0 return 100.0
elif self.status == "processing": elif self.status == "processing":
@@ -87,7 +91,7 @@ class ReportState:
return 0.0 return 0.0
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式""" """转换为字典格式,方便序列化给前端。"""
return { return {
"task_id": self.task_id, "task_id": self.task_id,
"query": self.query, "query": self.query,
@@ -100,7 +104,7 @@ class ReportState:
} }
def save_to_file(self, file_path: str): def save_to_file(self, file_path: str):
"""保存状态到文件""" """保存状态到文件,排除HTML正文以控制体积。"""
try: try:
state_data = self.to_dict() state_data = self.to_dict()
# 不保存完整的HTML内容到状态文件(太大) # 不保存完整的HTML内容到状态文件(太大)
@@ -113,7 +117,7 @@ class ReportState:
@classmethod @classmethod
def load_from_file(cls, file_path: str) -> Optional["ReportState"]: def load_from_file(cls, file_path: str) -> Optional["ReportState"]:
"""从文件加载状态""" """从文件加载状态,仅恢复关键字段便于调试。"""
try: try:
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
@@ -135,4 +139,4 @@ class ReportState:
except Exception as e: except Exception as e:
print(f"加载状态文件失败: {str(e)}") print(f"加载状态文件失败: {str(e)}")
return None return None
+3 -2
View File
@@ -1,6 +1,7 @@
""" """
Report Engine工具模块 Report Engine工具模块
包含配置管理
当前主要暴露配置读取逻辑后续可扩展更多通用工具
""" """