马一丁

Add Comments

... ... @@ -296,8 +296,19 @@ class ReportAgent:
4. 将章节装订成Document IR,再交给HTML渲染器生成成品;
5. 可选地将HTML/IR/状态落盘,并向外界回传路径信息。
Returns:
dict: HTML内容以及保存的文件路径信息
参数:
query: 最终要生成的报告主题或提问语句。
reports: 来自 Query/Media/Insight 等分析引擎的原始输出,允许传入字符串或更复杂的对象。
forum_logs: 论坛/协同记录,供LLM理解多人讨论上下文。
custom_template: 用户指定的Markdown模板,如为空则交由模板节点自动挑选。
save_report: 是否在生成后自动将HTML、IR与状态写入磁盘。
stream_handler: 可选的流式事件回调,接收阶段标签与payload,用于UI实时展示。
返回:
dict: 包含 `html_content` 以及HTML/IR/状态文件路径的字典;若 `save_report=False` 则仅返回HTML字符串。
异常:
Exception: 任一子节点或渲染阶段失败时抛出,外层调用方负责兜底。
"""
start_time = datetime.now()
report_id = f"report-{uuid4().hex[:8]}"
... ... @@ -538,6 +549,15 @@ class ReportAgent:
优先使用用户指定的模板;否则将查询、三引擎报告与论坛日志
作为上下文交给 TemplateSelectionNode,由 LLM 返回最契合的
模板名称、内容及理由,并自动记录在状态中。
参数:
query: 报告主题,用于提示词聚焦行业/事件。
reports: 多来源报告原文,帮助LLM判断结构复杂度。
forum_logs: 对应论坛或协作讨论的文本,用于补充背景。
custom_template: CLI/前端传入的自定义Markdown模板,非空时直接采用。
返回:
dict: 包含 `template_name`、`template_content` 与 `selection_reason` 的结构化结果,供后续节点消费。
"""
logger.info("选择报告模板...")
... ... @@ -584,6 +604,12 @@ class ReportAgent:
委托 `parse_template_sections` 将Markdown标题/编号解析为
`TemplateSection` 列表,确保后续章节生成有稳定的章节ID。
当模板格式异常时,会回退到内置的简单骨架避免崩溃。
参数:
template_markdown: 完整的模板Markdown文本。
返回:
list[TemplateSection]: 解析后的章节序列;如解析失败则返回单章兜底结构。
"""
sections = parse_template_sections(template_markdown)
if sections:
... ... @@ -618,6 +644,19 @@ class ReportAgent:
将模板名称、布局设计、主题配色、篇幅规划、论坛日志等
一次性整合为 `generation_context`,后续每章调用 LLM 时
直接复用,确保所有章节共享一致的语调和视觉约束。
参数:
query: 用户查询词。
reports: 归一化后的 query/media/insight 报告映射。
forum_logs: 三引擎讨论记录。
template_result: 模板节点返回的模板元信息。
layout_design: 文档布局节点产出的标题/目录/主题设计。
chapter_directives: 字数规划节点返回的章节指令映射。
word_plan: 篇幅规划原始结果,包含全局字数约束。
template_overview: 模板切片提炼的章节骨架摘要。
返回:
dict: LLM章节生成所需的全集上下文,包含主题色、布局、约束等键。
"""
# 优先使用设计稿定制的主题色,否则退回默认主题
theme_tokens = (
... ... @@ -650,6 +689,12 @@ class ReportAgent:
约定顺序为 Query/Media/Insight,引擎提供的对象可能是
字典或自定义类型,因此统一走 `_stringify` 做容错。
参数:
reports: 任意类型的报告列表,允许缺失或顺序混乱。
返回:
dict: 包含 `query_engine`/`media_engine`/`insight_engine` 三个字符串字段的映射。
"""
keys = ["query_engine", "media_engine", "insight_engine"]
normalized: Dict[str, str] = {}
... ... @@ -664,6 +709,12 @@ class ReportAgent:
当检测到供应商返回的错误包含特定关键词时,允许章节生成
重新尝试,以便绕过偶发的内容审查触发。
参数:
error: LLM客户端抛出的异常对象。
返回:
bool: 若匹配到内容审查关键词则返回True,否则为False。
"""
message = str(error) if error else ""
if not message:
... ... @@ -683,6 +734,12 @@ class ReportAgent:
- dict/list 统一序列化为格式化 JSON,便于提示词消费;
- 其他类型走 `str()`,None 则返回空串,避免 None 传播。
参数:
value: 任意Python对象。
返回:
str: 适配提示词/日志的字符串表现。
"""
if value is None:
return ""
... ... @@ -700,6 +757,9 @@ class ReportAgent:
构造默认主题变量,供渲染器/LLM共用。
当布局节点未返回专属配色时使用该套色板,保持报告风格统一。
返回:
dict: 包含颜色、字体、间距、布尔开关等渲染参数的主题字典。
"""
return {
"colors": {
... ... @@ -735,6 +795,13 @@ class ReportAgent:
提取模板标题与章节骨架,供设计/篇幅规划统一引用。
同时记录章节ID/slug/order等辅助字段,保证多节点对齐。
参数:
template_markdown: 模板原文,用于解析全局标题。
sections: `TemplateSection` 列表,作为章节骨架。
返回:
dict: 包含模板标题与章节元数据的概览结构。
"""
fallback_title = sections[0].title if sections else ""
overview = {
... ... @@ -763,6 +830,13 @@ class ReportAgent:
优先返回首个 `#` 语法标题;如果模板首行就是正文,则回退到
第一行非空文本或调用方提供的 fallback。
参数:
template_markdown: 模板原文。
fallback: 备用标题,当文档缺少显式标题时使用。
返回:
str: 解析到的标题文本。
"""
for line in template_markdown.splitlines():
stripped = line.strip()
... ... @@ -834,6 +908,14 @@ class ReportAgent:
生成基于查询和时间戳的易读文件名,同时也把运行态的
`ReportState` 写入 JSON,方便下游排障或断点续跑。
参数:
html_content: 渲染后的HTML正文。
document_ir: Document IR结构化数据。
report_id: 当前任务ID,用于创建独立文件名。
返回:
dict: 记录HTML/IR/State文件的绝对与相对路径信息。
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
query_safe = "".join(
... ... @@ -879,6 +961,14 @@ class ReportAgent:
`Document IR` 与 HTML 解耦保存,便于调试渲染差异以及
在不重新跑 LLM 的情况下再次渲染或导出其他格式。
参数:
document_ir: 整本报告的IR结构。
query_safe: 已清洗的查询短语,用于文件命名。
timestamp: 运行时间戳,保证文件名唯一。
返回:
Path: 指向保存后的IR文件路径。
"""
filename = f"report_ir_{query_safe}_{timestamp}.json"
ir_path = Path(self.config.DOCUMENT_IR_OUTPUT_DIR) / filename
... ... @@ -901,6 +991,12 @@ class ReportAgent:
这些中间件文件(document_layout/word_plan/template_overview)
方便在调试或复盘时快速定位:标题/目录/主题是如何确定的、
字数分配有什么要求,以便后续人工校正。
参数:
run_dir: 章节输出根目录。
layout_design: 文档布局节点的原始输出。
word_plan: 篇幅规划节点输出。
template_overview: 模板概览JSON。
"""
artifacts = {
"document_layout": layout_design,
... ...
... ... @@ -75,6 +75,13 @@ class ChapterStorage:
为本次报告创建独立的章节输出目录与manifest。
同时把全局metadata写入 `manifest.json`,供渲染/调试查询。
参数:
report_id: 任务ID。
metadata: Report元数据(标题、主题等)。
返回:
Path: 新建的run目录。
"""
run_dir = self.base_dir / report_id
run_dir.mkdir(parents=True, exist_ok=True)
... ... @@ -93,6 +100,13 @@ class ChapterStorage:
创建章节子目录并在manifest中标记为streaming状态。
会生成 `order-slug` 风格的子目录,并提前登记 raw 文件路径。
参数:
run_dir: 会话根目录。
chapter_meta: 包含 chapterId/title/slug/order 的元数据。
返回:
Path: 章节目录。
"""
slug_value = str(
chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
... ... @@ -124,6 +138,15 @@ class ChapterStorage:
章节流式生成完毕后写入最终JSON并更新manifest状态。
若校验失败,错误信息会被写入manifest,供前端展示。
参数:
run_dir: 会话根目录。
chapter_meta: 章节元信息。
payload: 校验通过的章节JSON。
errors: 可选的错误列表,用于标记invalid状态。
返回:
Path: 最终的 `chapter.json` 文件路径。
"""
slug_value = str(
chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
... ... @@ -159,6 +182,12 @@ class ChapterStorage:
从指定run目录读取全部chapter.json并按order排序返回。
常用于 DocumentComposer 将多个章节装订成整本IR。
参数:
run_dir: 会话根目录。
返回:
list[dict]: 章节payload列表。
"""
payloads: List[Dict[str, object]] = []
for child in sorted(run_dir.iterdir()):
... ... @@ -183,6 +212,12 @@ class ChapterStorage:
将流式输出实时写入raw文件。
通过 contextmanager 暴露文件句柄,简化章节节点的写入逻辑。
参数:
chapter_dir: 当前章节目录。
返回:
Generator[TextIO]: 作为上下文管理器使用的文件对象。
"""
raw_path = self._raw_stream_path(chapter_dir)
raw_path.parent.mkdir(parents=True, exist_ok=True)
... ...
... ... @@ -36,6 +36,14 @@ class DocumentComposer:
把所有章节按order排序并注入唯一锚点,形成整本IR。
同时合并 metadata/themeTokens/assets,供渲染器直接消费。
参数:
report_id: 本次报告ID。
metadata: 全局元信息(标题、主题、toc等)。
chapters: 章节payload列表。
返回:
dict: 满足渲染器需求的Document IR。
"""
ordered = sorted(chapters, key=lambda c: c.get("order", 0))
for idx, chapter in enumerate(ordered, start=1):
... ...
... ... @@ -63,6 +63,12 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]:
返回的每个TemplateSection都携带slug/order/章节号,
方便后续分章调用与锚点生成。解析时会同时兼容
“# 标题”“无符号编号”“列表提纲”等不同写法。
参数:
template_md: 模板Markdown全文。
返回:
list[TemplateSection]: 结构化的章节序列。
"""
sections: List[TemplateSection] = []
... ... @@ -113,6 +119,13 @@ def _classify_line(stripped: str, indent: int) -> Optional[dict]:
借助正则判断当前行是章节标题、提纲还是普通列表项,
并衍生 depth/slug/number 等派生信息。
参数:
stripped: 去除前后空格后的原始行。
indent: 行首空格数量,用于区分层级。
返回:
dict | None: 识别后的元数据;无法识别时返回None。
"""
heading_match = heading_pattern.match(stripped)
... ... @@ -181,6 +194,12 @@ def _split_number(payload: str) -> dict:
例如 `1.2 市场趋势` 会被拆成 number=1.2、label=市场趋势,
并提供 display 用于回填标题。
参数:
payload: 原始标题字符串。
返回:
dict: 包含 number/title/display。
"""
match = number_pattern.match(payload)
number = match.group("num") if match else ""
... ... @@ -196,7 +215,16 @@ def _split_number(payload: str) -> dict:
def _build_slug(number: str, title: str) -> str:
"""根据编号/标题生成锚点,优先复用编号,缺失时对标题slug化。"""
"""
根据编号/标题生成锚点,优先复用编号,缺失时对标题slug化。
参数:
number: 章节编号。
title: 标题文本。
返回:
str: 形如 `section-1-0` 的slug。
"""
if number:
token = number.replace(".", "-")
else:
... ... @@ -223,6 +251,13 @@ def _ensure_unique_slug(slug: str, used: set) -> str:
若slug重复则自动追加序号,直到在used集合中唯一。
通过 `-2/-3...` 的方式保证相同标题不会产生重复锚点。
参数:
slug: 初始slug。
used: 已使用集合。
返回:
str: 去重后的slug。
"""
if slug not in used:
used.add(slug)
... ...
... ... @@ -43,6 +43,12 @@ def _register_stream(task_id: str) -> Queue:
为指定任务注册一个事件队列,供SSE监听器消费。
返回的 Queue 会存入 `stream_subscribers`,SSE 生成器将不断读取。
参数:
task_id: 需要监听的任务ID。
返回:
Queue: 线程安全的事件队列。
"""
queue = Queue()
with stream_lock:
... ... @@ -55,6 +61,10 @@ def _unregister_stream(task_id: str, queue: Queue):
安全移除事件队列,避免内存泄漏。
需要在finally中调用,保证异常情况下资源也能释放。
参数:
task_id: 任务ID。
queue: 之前注册的事件队列。
"""
with stream_lock:
listeners = stream_subscribers.get(task_id, [])
... ... @@ -69,6 +79,10 @@ def _broadcast_event(task_id: str, event: Dict[str, Any]):
将事件推送给所有监听者,失败时做好异常捕获。
采用浅拷贝监听列表,防止并发移除导致遍历异常。
参数:
task_id: 待推送的任务ID。
event: 结构化事件payload。
"""
with stream_lock:
listeners = list(stream_subscribers.get(task_id, []))
... ... @@ -84,6 +98,9 @@ def _prune_task_history_locked():
在task_lock持有期间调用,清理过多的历史任务。
仅保留最近 `MAX_TASK_HISTORY` 个任务,避免长时间运行占用过多内存。
说明:
该函数假设调用方已获取 `task_lock`,否则存在竞态风险。
"""
if len(tasks_registry) <= MAX_TASK_HISTORY:
return
... ... @@ -98,6 +115,12 @@ def _get_task(task_id: str) -> Optional['ReportTask']:
统一的任务查找方法,优先返回当前任务。
避免重复写锁逻辑,便于多个API共享。
参数:
task_id: 任务ID。
返回:
ReportTask | None: 命中时返回任务实例,否则为None。
"""
with task_lock:
if current_task and current_task.task_id == task_id:
... ... @@ -110,6 +133,12 @@ def _format_sse(event: Dict[str, Any]) -> str:
按SSE协议格式化消息。
输出形如 `id:/event:/data:` 的三段文本,供浏览器端直接消费。
参数:
event: 事件payload,至少包含 id/type。
返回:
str: SSE协议要求的字符串。
"""
payload = json.dumps(event, ensure_ascii=False)
event_id = event.get('id', 0)
... ... @@ -122,6 +151,9 @@ def initialize_report_engine():
初始化Report Engine。
单例化 ReportAgent,方便 API 启动后直接接收任务。
返回:
bool: 初始化成功返回True,异常时返回False。
"""
global report_agent
try:
... ... @@ -176,6 +208,11 @@ class ReportTask:
更新任务状态并广播事件。
会自动刷新 `updated_at`、错误信息,并触发 `status` 类型的 SSE。
参数:
status: 任务阶段(pending/running/completed/error/cancelled)。
progress: 可选的进度百分比。
error_message: 出错时的人类可读说明。
"""
self.status = status
if progress is not None:
... ... @@ -214,7 +251,13 @@ class ReportTask:
}
def publish_event(self, event_type: str, payload: Dict[str, Any]) -> None:
"""将任意事件放入缓存并广播,所有新增逻辑均配套中文说明。"""
"""
将任意事件放入缓存并广播,所有新增逻辑均配套中文说明。
参数:
event_type: SSE中的event名称。
payload: 实际业务数据。
"""
timestamp = datetime.utcnow().isoformat() + 'Z'
event: Dict[str, Any] = {
'id': 0,
... ... @@ -230,7 +273,15 @@ class ReportTask:
_broadcast_event(self.task_id, event)
def history_since(self, last_event_id: Optional[int]) -> List[Dict[str, Any]]:
"""根据Last-Event-ID补发历史事件,确保断线重连无遗漏。"""
"""
根据Last-Event-ID补发历史事件,确保断线重连无遗漏。
参数:
last_event_id: SSE客户端记录的最后一个事件ID。
返回:
list[dict]: 从 last_event_id 之后的事件列表。
"""
with self._event_lock:
if last_event_id is None:
return list(self.event_history)
... ... @@ -272,6 +323,11 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = "
包括:检查输入→加载文档→调用ReportAgent→持久化输出→
推送阶段性事件。出现错误会自动推送并写状态。
参数:
task: 本次任务对象,内部持有事件队列。
query: 报告主题。
custom_template: 可选的自定义模板字符串。
"""
global current_task
... ... @@ -385,7 +441,12 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = "
@report_bp.route('/status', methods=['GET'])
def get_status():
"""获取Report Engine状态,包括引擎就绪情况与当前任务信息。"""
"""
获取Report Engine状态,包括引擎就绪情况与当前任务信息。
返回:
Response: JSON结构包含initialized/engines_ready/当前任务等。
"""
try:
engines_status = check_engines_ready()
... ... @@ -411,6 +472,13 @@ def generate_report():
开始生成报告。
负责排队、创建后台线程、清空日志并返回SSE地址。
请求体:
query: 报告主题(可选)。
custom_template: 自定义模板字符串(可选)。
返回:
Response: JSON,包含 task_id 与 SSE stream url。
"""
global current_task
... ... @@ -498,7 +566,15 @@ def generate_report():
@report_bp.route('/progress/<task_id>', methods=['GET'])
def get_progress(task_id: str):
"""获取报告生成进度,若任务被清理则返回一个完成态兜底。"""
"""
获取报告生成进度,若任务被清理则返回一个完成态兜底。
参数:
task_id: 任务唯一标识。
返回:
Response: JSON包含任务当前状态。
"""
try:
task = _get_task(task_id)
if not task:
... ... @@ -540,6 +616,12 @@ def stream_task(task_id: str):
- 自动补发Last-Event-ID之后的历史事件;
- 周期性发送心跳以防代理中断;
- 任务结束后自动注销监听。
参数:
task_id: 任务唯一标识。
返回:
Response: `text/event-stream` 类型响应。
"""
task = _get_task(task_id)
if not task:
... ... @@ -592,7 +674,15 @@ def stream_task(task_id: str):
@report_bp.route('/result/<task_id>', methods=['GET'])
def get_result(task_id: str):
"""获取报告生成结果"""
"""
获取报告生成结果。
参数:
task_id: 任务ID。
返回:
Response: JSON,包含HTML预览与文件路径。
"""
try:
task = _get_task(task_id)
if not task:
... ... @@ -655,7 +745,15 @@ def get_result_json(task_id: str):
@report_bp.route('/download/<task_id>', methods=['GET'])
def download_report(task_id: str):
"""下载已生成的报告HTML文件"""
"""
下载已生成的报告HTML文件。
参数:
task_id: 任务ID。
返回:
Response: HTML文件的附件下载响应。
"""
try:
task = _get_task(task_id)
if not task:
... ... @@ -694,7 +792,15 @@ def download_report(task_id: str):
@report_bp.route('/cancel/<task_id>', methods=['POST'])
def cancel_task(task_id: str):
"""取消报告生成任务"""
"""
取消报告生成任务。
参数:
task_id: 需要被取消的任务ID。
返回:
Response: JSON,包含取消结果或错误信息。
"""
global current_task
try:
... ... @@ -735,7 +841,12 @@ def cancel_task(task_id: str):
@report_bp.route('/templates', methods=['GET'])
def get_templates():
"""获取可用模板列表,便于前端展示可选Markdown骨架。"""
"""
获取可用模板列表,便于前端展示可选Markdown骨架。
返回:
Response: JSON,列出模板名称/描述/大小。
"""
try:
if not report_agent:
return jsonify({
... ... @@ -799,7 +910,12 @@ def internal_error(error):
def clear_report_log():
"""清空report.log文件,方便新任务只查看本次运行日志。"""
"""
清空report.log文件,方便新任务只查看本次运行日志。
返回:
None
"""
try:
log_file = settings.LOG_FILE
with open(log_file, 'w', encoding='utf-8') as f:
... ... @@ -811,7 +927,12 @@ def clear_report_log():
@report_bp.route('/log', methods=['GET'])
def get_report_log():
"""获取report.log内容,并按行去除空白返回。"""
"""
获取report.log内容,并按行去除空白返回。
返回:
Response: JSON,包含最新日志行数组。
"""
try:
log_file = settings.LOG_FILE
... ... @@ -842,7 +963,12 @@ def get_report_log():
@report_bp.route('/log/clear', methods=['POST'])
def clear_log():
"""手动清空日志,提供REST入口供前端一键重置。"""
"""
手动清空日志,提供REST入口供前端一键重置。
返回:
Response: JSON,标记是否清理成功。
"""
try:
clear_report_log()
return jsonify({
... ...
... ... @@ -101,15 +101,15 @@ class LLMClient:
def stream_invoke(self, system_prompt: str, user_prompt: str, **kwargs) -> Generator[str, None, None]:
"""
流式调用LLM,逐步返回响应内容
流式调用LLM,逐步返回响应内容
Args:
system_prompt: 系统提示词
user_prompt: 用户提示词
**kwargs: 额外参数(temperature, top_p等)
参数:
system_prompt: 系统提示词。
user_prompt: 用户提示词。
**kwargs: 采样参数(temperature、top_p等)。
Yields:
响应文本块(str),调用方可边读边写入磁盘或透传到UI
产出:
str: 每次yield一段delta文本,方便上层实时渲染。
"""
messages = [
{"role": "system", "content": system_prompt},
... ... @@ -143,15 +143,15 @@ class LLMClient:
@with_retry(LLM_RETRY_CONFIG)
def stream_invoke_to_string(self, system_prompt: str, user_prompt: str, **kwargs) -> str:
"""
流式调用LLM并安全地拼接为完整字符串(避免UTF-8多字节字符截断)
流式调用LLM并安全地拼接为完整字符串(避免UTF-8多字节字符截断)
Args:
system_prompt: 系统提示词
user_prompt: 用户提示词
**kwargs: 额外参数(temperature, top_p等)
参数:
system_prompt: 系统提示词。
user_prompt: 用户提示词。
**kwargs: 采样或超时配置。
Returns:
完整的响应字符串
返回:
str: 将所有delta拼接后的完整响应。
"""
# 以字节形式收集所有块
byte_chunks = []
... ...
... ... @@ -107,7 +107,23 @@ class ChapterGenerationNode(BaseNode):
stream_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
**kwargs,
) -> Dict[str, Any]:
"""针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果"""
"""
针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果。
参数:
section: 模板切片生成的章节对象,包含标题/顺序/slug。
context: Agent构造的共享上下文(主题、篇幅、布局等)。
run_dir: 章节存盘目录,由 `ChapterStorage.start_session` 返回。
stream_callback: 可选流式回调,将LLM delta 推送给前端。
**kwargs: 透传温度、top_p等采样参数。
返回:
dict: 通过IR校验的章节JSON。
异常:
ChapterJsonParseError: 多次尝试后仍无法解析合法JSON。
ChapterContentError: 正文密度不足或只有标题,需要触发重试。
"""
chapter_meta = {
"chapterId": section.chapter_id,
"slug": section.slug,
... ... @@ -167,7 +183,16 @@ class ChapterGenerationNode(BaseNode):
# ====== 内部方法 ======
def _build_payload(self, section: TemplateSection, context: Dict[str, Any]) -> Dict[str, Any]:
"""构造LLM输入payload"""
"""
构造LLM输入payload。
参数:
section: 当前要生成的章节,提供标题/编号/提纲。
context: 全局上下文字典,包含主题、三引擎报告、篇幅规划等。
返回:
dict: 可以直接序列化进提示词的payload,兼顾章节信息与全局约束。
"""
reports = context.get("reports", {})
# 章节篇幅规划(来自WordBudgetNode),用于指导字数与强调点
chapter_plan_map = context.get("chapter_directives", {})
... ... @@ -233,7 +258,19 @@ class ChapterGenerationNode(BaseNode):
section_meta: Optional[Dict[str, Any]] = None,
**kwargs,
) -> str:
"""流式调用LLM并实时写入raw文件,同时通过回调将delta抛出。"""
"""
流式调用LLM并实时写入raw文件,同时通过回调将delta抛出。
参数:
user_message: 拼装好的用户提示词。
chapter_dir: 章节的本地缓存目录,用于存放 stream.raw。
stream_callback: SSE流式推送的回调函数。
section_meta: 附带的章节ID/标题,用于回调payload。
**kwargs: 透传温度、top_p等参数。
返回:
str: 将所有delta拼接后的原始文本。
"""
chunks: List[str] = []
with self.storage.capture_stream(chapter_dir) as stream_fp:
stream = self.llm_client.stream_invoke(
... ... @@ -254,7 +291,18 @@ class ChapterGenerationNode(BaseNode):
return "".join(chunks)
def _parse_chapter(self, raw_text: str) -> Dict[str, Any]:
"""清洗LLM输出并解析JSON"""
"""
清洗LLM输出并解析JSON。
参数:
raw_text: LLM原始输出(可能包含```包裹或额外说明)。
返回:
dict: 章节JSON对象,至少包含 chapterId/title/blocks。
异常:
ChapterJsonParseError: 多种修复策略仍无法解析合法JSON。
"""
cleaned = raw_text.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
... ... @@ -304,7 +352,15 @@ class ChapterGenerationNode(BaseNode):
raise ValueError("章节JSON缺少chapter字段")
def _repair_llm_json(self, text: str) -> str:
"""处理常见的LLM错误(如\":=导致的非法JSON)"""
"""
处理常见的LLM错误(如":=导致的非法JSON)。
参数:
text: 原始章节JSON文本。
返回:
str: 修复后的文本;若未做改动则返回原内容。
"""
repaired = text
mutated = False
... ... @@ -482,7 +538,12 @@ class ChapterGenerationNode(BaseNode):
return fixed
def _sanitize_chapter_blocks(self, chapter: Dict[str, Any]):
"""修正常见的结构性错误(例如list.items嵌套过深)"""
"""
修正常见的结构性错误(例如list.items嵌套过深)。
参数:
chapter: 章节JSON对象,会在原地被清理和规整。
"""
def walk(blocks: List[Dict[str, Any]] | None):
"""递归检查并修复嵌套结构,保证每个block合法"""
... ... @@ -527,6 +588,12 @@ class ChapterGenerationNode(BaseNode):
blocks缺失、除标题外无有效区块,或正文字符数低于阈值,
则视为章节内容异常,触发ChapterContentError以便上游重试。
参数:
chapter: 当前章节JSON
异常:
ChapterContentError: 当正文区块数量或字符数达不到下限时抛出。
"""
blocks = chapter.get("blocks")
if not isinstance(blocks, list) or not blocks:
... ... @@ -552,6 +619,12 @@ class ChapterGenerationNode(BaseNode):
- 忽略heading/divider/widget等非正文类型;
- paragraph/list/table/callout等结构抽取嵌套文本;
- 仅用于粗粒度判断篇幅是否合理。
参数:
blocks: 章节的 blocks 列表或子树。
返回:
int: 估算的正文字符数量。
"""
def walk(node: Any) -> int:
... ...
... ... @@ -37,7 +37,20 @@ class DocumentLayoutNode(BaseNode):
query: str,
template_overview: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
"""综合模板+多源内容,生成全书的标题、目录结构与主题色板"""
"""
综合模板+多源内容,生成全书的标题、目录结构与主题色板。
参数:
sections: 模板切片后的章节列表。
template_markdown: 模板原文,用于LLM理解上下文。
reports: 三个引擎的内容映射。
forum_logs: 论坛讨论摘要。
query: 用户查询词。
template_overview: 预生成的模板概览,可复用以减少提示词长度。
返回:
dict: 包含 title/subtitle/toc/hero/themeTokens 等设计信息的字典。
"""
# 将模板原文、切片结构与多源报告一并喂给LLM,便于其理解层级与素材
payload = {
"query": query,
... ... @@ -66,7 +79,18 @@ class DocumentLayoutNode(BaseNode):
return design
def _parse_response(self, raw: str) -> Dict[str, Any]:
"""解析LLM返回的JSON文本,若失败则抛出友好错误"""
"""
解析LLM返回的JSON文本,若失败则抛出友好错误。
参数:
raw: LLM原始返回字符串,允许带```包裹。
返回:
dict: 结构化的设计稿。
异常:
ValueError: 当响应为空或JSON解析失败时抛出。
"""
cleaned = raw.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
... ...
... ... @@ -79,6 +79,15 @@ class TemplateSelectionNode(BaseNode):
构造模板列表与报告摘要 → 调用LLM → 解析JSON →
验证模板是否存在并返回标准结构。
参数:
query: 用户输入的主题词。
reports: 多个分析引擎的报告内容。
forum_logs: 论坛日志,可能为空。
available_templates: 本地可用模板清单。
返回:
dict | None: 若LLM成功返回合法结果则包含模板信息,否则为None。
"""
logger.info("尝试使用LLM进行模板选择...")
... ... @@ -166,6 +175,12 @@ class TemplateSelectionNode(BaseNode):
清理LLM响应。
去掉 ```json``` 包裹以及前后空白,方便 `json.loads`。
参数:
response: LLM原始响应。
返回:
str: 适合直接做JSON解析的纯文本。
"""
# 移除可能的markdown代码块标记
if '```json' in response:
... ... @@ -183,6 +198,13 @@ class TemplateSelectionNode(BaseNode):
从文本响应中提取模板信息。
当LLM未输出合法JSON时,尝试匹配模板名称关键字做降级。
参数:
response: 非结构化的LLM文本。
available_templates: 可选模板列表。
返回:
dict | None: 匹配成功时返回模板详情,否则为None。
"""
logger.info("尝试从文本响应中提取模板信息")
... ... @@ -210,6 +232,9 @@ class TemplateSelectionNode(BaseNode):
获取可用的模板列表。
枚举模板目录下的 `.md` 文件并读取内容与描述字段。
返回:
list[dict]: 每项包含 name/path/content/description。
"""
templates = []
... ... @@ -259,7 +284,12 @@ class TemplateSelectionNode(BaseNode):
def _get_fallback_template(self) -> Dict[str, Any]:
"""获取备用默认模板(空模板,让LLM自行发挥)。"""
"""
获取备用默认模板(空模板,让LLM自行发挥)。
返回:
dict: 结构体字段与LLM返回一致,方便直接替换。
"""
logger.info("未找到合适模板,使用空模板让LLM自行发挥")
return {
... ...
... ... @@ -37,7 +37,20 @@ class WordBudgetNode(BaseNode):
query: str,
template_overview: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
"""根据设计稿和所有素材规划章节字数,让LLM写作时有明确篇幅目标"""
"""
根据设计稿和所有素材规划章节字数,让LLM写作时有明确篇幅目标。
参数:
sections: 模板章节列表。
design: 布局节点返回的设计稿(title/toc/hero等)。
reports: 三引擎报告映射。
forum_logs: 论坛日志原文。
query: 用户查询词。
template_overview: 可选的模板概览,含章节元信息。
返回:
dict: 章节篇幅规划结果,包含 `totalWords`、`globalGuidelines` 与逐章 `chapters`。
"""
# 输入中除了章节骨架外,还包含布局节点输出,方便约束篇幅时参考视觉主次
payload = {
"query": query,
... ... @@ -63,7 +76,18 @@ class WordBudgetNode(BaseNode):
return plan
def _parse_response(self, raw: str) -> Dict[str, Any]:
"""将LLM输出的JSON文本转为字典,失败时提示规划异常"""
"""
将LLM输出的JSON文本转为字典,失败时提示规划异常。
参数:
raw: LLM返回值,可能包含```包裹。
返回:
dict: 合法的篇幅规划JSON。
异常:
ValueError: 当响应为空或JSON解析失败时抛出。
"""
cleaned = raw.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
... ...
... ... @@ -62,7 +62,15 @@ class HTMLRenderer:
# ====== 公共入口 ======
def render(self, document_ir: Dict[str, Any]) -> str:
"""接收Document IR,重置内部状态并输出完整HTML"""
"""
接收Document IR,重置内部状态并输出完整HTML。
参数:
document_ir: 由 DocumentComposer 生成的整本报告数据。
返回:
str: 可直接写入磁盘的完整HTML文档。
"""
self.document = document_ir or {}
self.widget_scripts = []
self.chart_counter = 0
... ... @@ -89,7 +97,16 @@ class HTMLRenderer:
# ====== Head / Body ======
def _render_head(self, title: str, theme_tokens: Dict[str, Any]) -> str:
"""渲染<head>部分,加载主题CSS与必要的脚本依赖"""
"""
渲染<head>部分,加载主题CSS与必要的脚本依赖。
参数:
title: 页面title标签内容。
theme_tokens: 主题变量,用于注入CSS。
返回:
str: head片段HTML。
"""
css = self._build_css(theme_tokens)
return f"""
<head>
... ... @@ -124,7 +141,12 @@ class HTMLRenderer:
</head>""".strip()
def _render_body(self) -> str:
"""拼装<body>结构,包含头部、导航、章节和脚本"""
"""
拼装<body>结构,包含头部、导航、章节和脚本。
返回:
str: body片段HTML。
"""
header = self._render_header()
cover = self._render_cover()
hero = self._render_hero()
... ... @@ -152,7 +174,12 @@ class HTMLRenderer:
# ====== Header / Meta / TOC ======
def _render_header(self) -> str:
"""渲染吸顶头部,包含标题、副标题与功能按钮"""
"""
渲染吸顶头部,包含标题、副标题与功能按钮。
返回:
str: header HTML。
"""
metadata = self.metadata
title = metadata.get("title") or "智能舆情分析报告"
subtitle = metadata.get("subtitle") or metadata.get("templateName") or "自动生成"
... ... @@ -172,14 +199,24 @@ class HTMLRenderer:
""".strip()
def _render_tagline(self) -> str:
"""渲染标题下方的标语,如无标语则返回空字符串"""
"""
渲染标题下方的标语,如无标语则返回空字符串。
返回:
str: tagline HTML或空串。
"""
tagline = self.metadata.get("tagline")
if not tagline:
return ""
return f'<p class="tagline">{self._escape_html(tagline)}</p>'
def _render_cover(self) -> str:
"""文章开头的封面区,居中展示标题与“文章总览”提示"""
"""
文章开头的封面区,居中展示标题与“文章总览”提示。
返回:
str: cover section HTML。
"""
title = self.metadata.get("title") or "智能舆情报告"
subtitle = self.metadata.get("subtitle") or self.metadata.get("templateName") or ""
overview_hint = "文章总览"
... ... @@ -192,7 +229,12 @@ class HTMLRenderer:
""".strip()
def _render_hero(self) -> str:
"""根据layout中的hero字段输出摘要/KPI/亮点区"""
"""
根据layout中的hero字段输出摘要/KPI/亮点区。
返回:
str: hero区HTML,若无数据则为空字符串。
"""
hero = self.metadata.get("hero") or {}
if not hero:
return ""
... ... @@ -239,7 +281,12 @@ class HTMLRenderer:
return ""
def _render_toc_section(self) -> str:
"""生成目录模块,如无目录数据则返回空字符串"""
"""
生成目录模块,如无目录数据则返回空字符串。
返回:
str: toc HTML结构。
"""
if not self.toc_entries:
return ""
toc_config = self.metadata.get("toc") or {}
... ... @@ -258,7 +305,15 @@ class HTMLRenderer:
""".strip()
def _collect_toc_entries(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""根据metadata中的tocPlan或章节heading收集目录项"""
"""
根据metadata中的tocPlan或章节heading收集目录项。
参数:
chapters: Document IR中的章节数组。
返回:
list[dict]: 规范化后的目录条目,包含level/text/anchor。
"""
metadata = self.metadata
toc_config = metadata.get("toc") or {}
custom_entries = toc_config.get("customEntries")
... ... @@ -296,7 +351,15 @@ class HTMLRenderer:
return entries
def _format_toc_entry(self, entry: Dict[str, Any]) -> str:
"""将单个目录项转为带描述的HTML行"""
"""
将单个目录项转为带描述的HTML行。
参数:
entry: 目录条目,需包含 `text` 与 `anchor`。
返回:
str: `<li>` 形式的HTML。
"""
desc = entry.get("description")
desc_html = f'<p class="toc-desc">{self._escape_html(desc)}</p>' if desc else ""
level = entry.get("level", 2)
... ... @@ -304,7 +367,15 @@ class HTMLRenderer:
return f'<li class="level-{css_level}"><a href="#{self._escape_attr(entry["anchor"])}">{self._escape_html(entry["text"])}</a>{desc_html}</li>'
def _compute_heading_labels(self, chapters: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
"""预计算各级标题的编号(章:一、二;节:1.1;小节:1.1.1)"""
"""
预计算各级标题的编号(章:一、二;节:1.1;小节:1.1.1)。
参数:
chapters: Document IR中的章节数组。
返回:
dict: 锚点到编号/描述的映射,方便TOC与正文引用。
"""
label_map: Dict[str, Dict[str, Any]] = {}
for chap_idx, chapter in enumerate(chapters or [], start=1):
... ... @@ -394,17 +465,41 @@ class HTMLRenderer:
# ====== 章节 & Block 渲染 ======
def _render_chapter(self, chapter: Dict[str, Any]) -> str:
"""将章节blocks包裹进<section>,便于CSS控制"""
"""
将章节blocks包裹进<section>,便于CSS控制。
参数:
chapter: 单个章节JSON。
返回:
str: section包裹的HTML。
"""
section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}")
blocks_html = self._render_blocks(chapter.get("blocks", []))
return f'<section id="{section_id}" class="chapter">\n{blocks_html}\n</section>'
def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str:
"""顺序渲染章节内所有block"""
"""
顺序渲染章节内所有block。
参数:
blocks: 章节内部的block数组。
返回:
str: 拼接后的HTML。
"""
return "".join(self._render_block(block) for block in blocks or [])
def _render_block(self, block: Dict[str, Any]) -> str:
"""根据block.type分派到不同的渲染函数"""
"""
根据block.type分派到不同的渲染函数。
参数:
block: 单个block对象。
返回:
str: 渲染后的HTML,未知类型会输出JSON调试信息。
"""
block_type = block.get("type")
handlers = {
"heading": self._render_heading,
... ... @@ -468,7 +563,15 @@ class HTMLRenderer:
return f'<{tag}{class_attr}>{items_html}</{tag}>'
def _render_table(self, block: Dict[str, Any]) -> str:
"""渲染表格,同时保留caption与单元格属性"""
"""
渲染表格,同时保留caption与单元格属性。
参数:
block: table类型的block。
返回:
str: 包含<table>结构的HTML。
"""
rows = self._normalize_table_rows(block.get("rows") or [])
rows_html = ""
for row in rows:
... ... @@ -491,7 +594,15 @@ class HTMLRenderer:
return f'<div class="table-wrap"><table>{caption_html}<tbody>{rows_html}</tbody></table></div>'
def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""检测并修正仅有单列的竖排表,转换为标准网格"""
"""
检测并修正仅有单列的竖排表,转换为标准网格。
参数:
rows: 原始表格行。
返回:
list[dict]: 若检测到竖排表则返回转置后的行,否则原样返回。
"""
if not rows:
return []
if not all(len((row.get("cells") or [])) == 1 for row in rows):
... ... @@ -611,7 +722,15 @@ class HTMLRenderer:
return f'<div class="figure-placeholder">{self._escape_html(caption)}</div>'
def _render_callout(self, block: Dict[str, Any]) -> str:
"""渲染高亮提示盒,tone决定颜色"""
"""
渲染高亮提示盒,tone决定颜色。
参数:
block: callout类型的block。
返回:
str: callout HTML,若内部包含不允许的块会被拆分。
"""
tone = block.get("tone", "info")
title = block.get("title")
safe_blocks, trailing_blocks = self._split_callout_content(block.get("blocks"))
... ... @@ -689,7 +808,15 @@ class HTMLRenderer:
return f'<div class="kpi-grid">{cards}</div>'
def _render_widget(self, block: Dict[str, Any]) -> str:
"""渲染Chart.js等交互组件的占位容器,并记录配置JSON"""
"""
渲染Chart.js等交互组件的占位容器,并记录配置JSON。
参数:
block: widget类型的block,包含widgetId/props/data。
返回:
str: 含canvas与配置脚本的HTML。
"""
self.chart_counter += 1
canvas_id = f"chart-{self.chart_counter}"
config_id = f"chart-config-{self.chart_counter}"
... ... @@ -830,7 +957,15 @@ class HTMLRenderer:
return payload
def _render_inline(self, run: Dict[str, Any]) -> str:
"""渲染单个inline run,支持多种marks叠加"""
"""
渲染单个inline run,支持多种marks叠加。
参数:
run: 含 text 与 marks 的内联节点。
返回:
str: 已包裹标签/样式的HTML片段。
"""
text_value, marks = self._normalize_inline_payload(run)
math_mark = next((mark for mark in marks if mark.get("type") == "math"), None)
if math_mark:
... ...
... ... @@ -47,7 +47,12 @@ settings = Settings()
def print_config(config: Settings):
"""将当前配置项按人类可读格式输出到日志,方便排障"""
"""
将当前配置项按人类可读格式输出到日志,方便排障。
参数:
config: Settings实例,通常为全局settings。
"""
message = ""
message += "\n=== Report Engine 配置 ===\n"
message += f"LLM 模型: {config.REPORT_ENGINE_MODEL_NAME}\n"
... ...
... ... @@ -1081,6 +1081,7 @@
</style>
</head>
<body>
<!-- 顶层容器:同时包裹搜索区、双列主工作区与状态栏 -->
<div class="container">
<!-- 搜索框区域 -->
<div class="search-section">
... ... @@ -1158,13 +1159,14 @@
</div>
</div>
<!-- 状态栏 -->
<!-- 状态栏:实时展示WebSocket连接状态与系统时钟 -->
<div class="status-bar">
<span id="connectionStatus">连接中...</span>
<span id="systemTime"></span>
</div>
</div>
<!-- 配置弹窗:与后端.env互通,允许在线修改LLM参数 -->
<div class="config-modal-overlay" id="configModal">
<div class="config-modal">
<div class="config-modal-header">
... ... @@ -1187,9 +1189,10 @@
</div>
</div>
<!-- 消息提示 -->
<!-- 消息提示:右上角滑出式成功/错误提醒 -->
<div class="message" id="message"></div>
<!-- 前端业务脚本:维护Socket连接、引擎启动状态与Report Engine交互 -->
<script>
// 全局变量
let socket;
... ...