马一丁

Add Comments

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