feat(report): allow resuming report generation after failure

When report generation in Step 4 fails mid-way (e.g. AI credits exhausted,
network error), users previously had to restart from scratch, losing the
outline and any already-completed sections.

This change preserves partial progress and lets users continue from where
generation stopped:

- Backend: `generate_report(resume=True)` reloads the saved outline and
  per-section markdown files, skips the planning phase, and only generates
  the missing sections. The `/api/report/generate` endpoint accepts a
  `resume` flag and reuses the failed report's `report_id`.
- Frontend: Step4Report polls `/progress` and, when `status === 'failed'`,
  shows a "Resume Generation" banner with the failure message and a button
  that calls `generateReport({ simulation_id, resume: true })` and
  restarts log polling.
- Adds `resumeStart` / `sectionResumed` / `reportFailedTitle` /
  `resumeGeneration` locale keys in en and zh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
andreicarpen 2026-04-18 16:48:58 +03:00
parent fa0f6519b1
commit 0387dc7210
6 changed files with 368 additions and 90 deletions

View File

@ -6,6 +6,7 @@ Report API路由
import os
import traceback
import threading
from typing import Optional
from flask import request, jsonify, send_file
from . import report_bp
@ -33,9 +34,10 @@ def generate_report():
请求JSON
{
"simulation_id": "sim_xxxx", // 必填模拟ID
"force_regenerate": false // 可选强制重新生成
"force_regenerate": false, // 可选强制重新生成清空已有报告
"resume": false // 可选从已失败的报告恢复生成沿用大纲和已完成章节
}
返回
{
"success": true,
@ -49,7 +51,7 @@ def generate_report():
"""
try:
data = request.get_json() or {}
simulation_id = data.get('simulation_id')
if not simulation_id:
return jsonify({
@ -58,20 +60,30 @@ def generate_report():
}), 400
force_regenerate = data.get('force_regenerate', False)
resume = data.get('resume', False)
# 获取模拟信息
manager = SimulationManager()
state = manager.get_simulation(simulation_id)
if not state:
return jsonify({
"success": False,
"error": t('api.simulationNotFound', id=simulation_id)
}), 404
# 检查是否已有报告
if not force_regenerate:
existing_report = ReportManager.get_report_by_simulation(simulation_id)
# Resume 模式:查找已失败的报告并复用其 report_id
existing_report = ReportManager.get_report_by_simulation(simulation_id)
resume_report_id: Optional[str] = None
if resume:
if existing_report and existing_report.status != ReportStatus.COMPLETED:
resume_report_id = existing_report.report_id
else:
# 没有可恢复的报告,降级为全新生成
resume = False
# 检查是否已有报告(非 resume / 非 force_regenerate 情况)
if not force_regenerate and not resume:
if existing_report and existing_report.status == ReportStatus.COMPLETED:
return jsonify({
"success": True,
@ -83,7 +95,7 @@ def generate_report():
"already_generated": True
}
})
# 获取项目信息
project = ProjectManager.get_project(state.project_id)
if not project:
@ -91,24 +103,27 @@ def generate_report():
"success": False,
"error": t('api.projectNotFound', id=state.project_id)
}), 404
graph_id = state.graph_id or project.graph_id
if not graph_id:
return jsonify({
"success": False,
"error": t('api.missingGraphIdEnsure')
}), 400
simulation_requirement = project.simulation_requirement
if not simulation_requirement:
return jsonify({
"success": False,
"error": t('api.missingSimRequirement')
}), 400
# 提前生成 report_id以便立即返回给前端
import uuid
report_id = f"report_{uuid.uuid4().hex[:12]}"
# Resume 时复用已有 report_id否则生成新的
if resume_report_id:
report_id = resume_report_id
else:
import uuid
report_id = f"report_{uuid.uuid4().hex[:12]}"
# 创建异步任务
task_manager = TaskManager()
@ -150,10 +165,11 @@ def generate_report():
message=f"[{stage}] {message}"
)
# 生成报告(传入预先生成的 report_id
# 生成报告(传入预先生成的 report_idresume=True 时复用已有大纲和章节
report = agent.generate_report(
progress_callback=progress_callback,
report_id=report_id
report_id=report_id,
resume=resume
)
# 保存报告

View File

@ -1530,13 +1530,14 @@ class ReportAgent:
return final_answer
def generate_report(
self,
self,
progress_callback: Optional[Callable[[str, int, str], None]] = None,
report_id: Optional[str] = None
report_id: Optional[str] = None,
resume: bool = False
) -> Report:
"""
生成完整报告分章节实时输出
每个章节生成完成后立即保存到文件夹不需要等待整个报告完成
文件结构
reports/{report_id}/
@ -1547,21 +1548,22 @@ class ReportAgent:
section_02.md - 第2章节
...
full_report.md - 完整报告
Args:
progress_callback: 进度回调函数 (stage, progress, message)
report_id: 报告ID可选如果不传则自动生成
resume: 是否从已保存的大纲和章节恢复生成跳过规划阶段和已完成章节
Returns:
Report: 完整报告
"""
import uuid
# 如果没有传入 report_id则自动生成
if not report_id:
report_id = f"report_{uuid.uuid4().hex[:12]}"
start_time = datetime.now()
report = Report(
report_id=report_id,
simulation_id=self.simulation_id,
@ -1570,73 +1572,142 @@ class ReportAgent:
status=ReportStatus.PENDING,
created_at=datetime.now().isoformat()
)
# 已完成的章节标题列表(用于进度追踪)
completed_section_titles = []
try:
# 初始化:创建报告文件夹并保存初始状态
ReportManager._ensure_report_folder(report_id)
# 初始化日志记录器(结构化日志 agent_log.jsonl
self.report_logger = ReportLogger(report_id)
self.report_logger.log_start(
simulation_id=self.simulation_id,
graph_id=self.graph_id,
simulation_requirement=self.simulation_requirement
)
# 初始化控制台日志记录器console_log.txt
self.console_logger = ReportConsoleLogger(report_id)
ReportManager.update_progress(
report_id, "pending", 0, t('progress.initReport'),
completed_sections=[]
)
ReportManager.save_report(report)
# 阶段1: 规划大纲
report.status = ReportStatus.PLANNING
ReportManager.update_progress(
report_id, "planning", 5, t('progress.startPlanningOutline'),
completed_sections=[]
)
# 记录规划开始日志
self.report_logger.log_planning_start()
if progress_callback:
progress_callback("planning", 0, t('progress.startPlanningOutline'))
outline = self.plan_outline(
progress_callback=lambda stage, prog, msg:
progress_callback(stage, prog // 5, msg) if progress_callback else None
)
report.outline = outline
# 记录规划完成日志
self.report_logger.log_planning_complete(outline.to_dict())
# 保存大纲到文件
ReportManager.save_outline(report_id, outline)
ReportManager.update_progress(
report_id, "planning", 15, t('progress.outlineDone', count=len(outline.sections)),
completed_sections=[]
)
ReportManager.save_report(report)
logger.info(t('report.outlineSavedToFile', reportId=report_id))
# 阶段2: 逐章节生成(分章节保存)
report.status = ReportStatus.GENERATING
# 尝试从已保存状态恢复resume 模式)
existing_outline: Optional[ReportOutline] = None
existing_sections: Dict[int, str] = {}
if resume:
existing_outline = ReportManager.load_outline(report_id)
if existing_outline:
for sec_info in ReportManager.get_generated_sections(report_id):
existing_sections[sec_info['section_index']] = sec_info['content']
logger.info(t('report.resumeStart', reportId=report_id, count=len(existing_sections)))
self.report_logger.log(
action="resume_start",
stage="generating",
details={
"completed_sections_count": len(existing_sections),
"total_sections": len(existing_outline.sections),
"message": t('report.resumeStart', reportId=report_id, count=len(existing_sections))
}
)
if existing_outline is not None:
# Resume 路径:沿用已保存的大纲,跳过规划阶段
outline = existing_outline
report.outline = outline
report.status = ReportStatus.GENERATING
ReportManager.update_progress(
report_id, "generating", 15,
t('progress.resumeStart', count=len(existing_sections)),
completed_sections=[]
)
ReportManager.save_report(report)
else:
# 全新生成:从头规划
self.report_logger.log_start(
simulation_id=self.simulation_id,
graph_id=self.graph_id,
simulation_requirement=self.simulation_requirement
)
ReportManager.update_progress(
report_id, "pending", 0, t('progress.initReport'),
completed_sections=[]
)
ReportManager.save_report(report)
# 阶段1: 规划大纲
report.status = ReportStatus.PLANNING
ReportManager.update_progress(
report_id, "planning", 5, t('progress.startPlanningOutline'),
completed_sections=[]
)
# 记录规划开始日志
self.report_logger.log_planning_start()
if progress_callback:
progress_callback("planning", 0, t('progress.startPlanningOutline'))
outline = self.plan_outline(
progress_callback=lambda stage, prog, msg:
progress_callback(stage, prog // 5, msg) if progress_callback else None
)
report.outline = outline
# 记录规划完成日志
self.report_logger.log_planning_complete(outline.to_dict())
# 保存大纲到文件
ReportManager.save_outline(report_id, outline)
ReportManager.update_progress(
report_id, "planning", 15, t('progress.outlineDone', count=len(outline.sections)),
completed_sections=[]
)
ReportManager.save_report(report)
logger.info(t('report.outlineSavedToFile', reportId=report_id))
# 阶段2: 逐章节生成(分章节保存)
report.status = ReportStatus.GENERATING
total_sections = len(outline.sections)
generated_sections = [] # 保存内容用于上下文
for i, section in enumerate(outline.sections):
section_num = i + 1
base_progress = 20 + int((i / total_sections) * 70)
# Resume 模式:复用已保存的章节
if section_num in existing_sections:
saved_md = existing_sections[section_num]
header_prefix = f"## {section.title}\n\n"
stripped = saved_md[len(header_prefix):] if saved_md.startswith(header_prefix) else saved_md
section.content = stripped.strip()
generated_sections.append(f"## {section.title}\n\n{section.content}")
completed_section_titles.append(section.title)
if self.report_logger:
self.report_logger.log(
action="section_resumed",
stage="generating",
section_title=section.title,
section_index=section_num,
details={
"content": saved_md,
"message": t('report.sectionResumed', title=section.title),
}
)
ReportManager.update_progress(
report_id, "generating",
base_progress + int(70 / total_sections),
t('progress.sectionResumed', title=section.title),
current_section=None,
completed_sections=completed_section_titles
)
if progress_callback:
progress_callback(
"generating",
base_progress + int(70 / total_sections),
t('progress.sectionResumed', title=section.title)
)
continue
# 更新进度
ReportManager.update_progress(
report_id, "generating", base_progress,
@ -1651,7 +1722,7 @@ class ReportAgent:
base_progress,
t('progress.generatingSection', title=section.title, current=section_num, total=total_sections)
)
# 生成主章节内容
section_content = self._generate_section_react(
section=section,
@ -1659,13 +1730,13 @@ class ReportAgent:
previous_sections=generated_sections,
progress_callback=lambda stage, prog, msg:
progress_callback(
stage,
stage,
base_progress + int(prog * 0.7 / total_sections),
msg
) if progress_callback else None,
section_index=section_num
)
section.content = section_content
generated_sections.append(f"## {section.title}\n\n{section_content}")
@ -2081,15 +2152,37 @@ class ReportManager:
def save_outline(cls, report_id: str, outline: ReportOutline) -> None:
"""
保存报告大纲
在规划阶段完成后立即调用
"""
cls._ensure_report_folder(report_id)
with open(cls._get_outline_path(report_id), 'w', encoding='utf-8') as f:
json.dump(outline.to_dict(), f, ensure_ascii=False, indent=2)
logger.info(t('report.outlineSaved', reportId=report_id))
@classmethod
def load_outline(cls, report_id: str) -> Optional[ReportOutline]:
"""加载已保存的报告大纲(用于 resume 模式)"""
path = cls._get_outline_path(report_id)
if not os.path.exists(path):
return None
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
sections = [
ReportSection(title=s.get('title', ''), content=s.get('content', ''))
for s in data.get('sections', [])
]
return ReportOutline(
title=data.get('title', ''),
summary=data.get('summary', ''),
sections=sections
)
except Exception as e:
logger.warning(f"Failed to load outline for {report_id}: {e}")
return None
@classmethod
def save_section(

View File

@ -2,12 +2,21 @@ import service, { requestWithRetry } from './index'
/**
* 开始报告生成
* @param {Object} data - { simulation_id, force_regenerate? }
* @param {Object} data - { simulation_id, force_regenerate?, resume? }
* - resume=true: 从已失败的报告恢复沿用大纲和已完成章节
*/
export const generateReport = (data) => {
return requestWithRetry(() => service.post('/api/report/generate', data), 3, 1000)
}
/**
* 获取报告生成进度包含 status / progress / completed_sections
* @param {string} reportId
*/
export const getReportProgress = (reportId) => {
return service.get(`/api/report/${reportId}/progress`)
}
/**
* 获取报告生成状态
* @param {string} reportId

View File

@ -4,6 +4,25 @@
<div class="main-split-layout">
<!-- LEFT PANEL: Report Style -->
<div class="left-panel report-style" ref="leftPanel">
<!-- Failure + Resume Banner -->
<div v-if="reportStatus === 'failed'" class="resume-banner">
<div class="resume-banner-icon">!</div>
<div class="resume-banner-body">
<div class="resume-banner-title">{{ $t('step4.reportFailedTitle') }}</div>
<div class="resume-banner-desc">
{{ $t('step4.reportFailedDesc', { count: Object.keys(generatedSections).length }) }}
</div>
<div v-if="reportError" class="resume-banner-error">{{ reportError }}</div>
<button
class="resume-banner-btn"
:disabled="isResuming || !simulationId"
@click="resumeReport"
>
{{ isResuming ? $t('step4.resumingGeneration') : $t('step4.resumeGeneration') }}
</button>
</div>
</div>
<div v-if="reportOutline" class="report-content-wrapper">
<!-- Report Header -->
<div class="report-header-block">
@ -393,7 +412,7 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { getAgentLog, getConsoleLog } from '../api/report'
import { getAgentLog, getConsoleLog, getReportProgress, generateReport } from '../api/report'
const router = useRouter()
const { t } = useI18n()
@ -425,6 +444,9 @@ const expandedContent = ref(new Set())
const expandedLogs = ref(new Set())
const collapsedSections = ref(new Set())
const isComplete = ref(false)
const reportStatus = ref(null) // : pending|planning|generating|completed|failed
const reportError = ref(null) //
const isResuming = ref(false) // "" loading
const startTime = ref(null)
const leftPanel = ref(null)
const rightPanel = ref(null)
@ -2154,14 +2176,63 @@ const fetchConsoleLog = async () => {
}
}
let progressTimer = null
const fetchProgress = async () => {
if (!props.reportId) return
try {
const res = await getReportProgress(props.reportId)
if (res.success && res.data) {
const prevStatus = reportStatus.value
reportStatus.value = res.data.status
// progress.json message
reportError.value = res.data.status === 'failed' ? (res.data.message || null) : null
if (res.data.status === 'failed') {
emit('update-status', 'error')
stopPolling()
} else if (res.data.status === 'completed') {
emit('update-status', 'completed')
} else if (prevStatus !== res.data.status) {
emit('update-status', 'processing')
}
}
} catch (err) {
// 404
}
}
const resumeReport = async () => {
if (!props.simulationId || isResuming.value) return
isResuming.value = true
try {
const res = await generateReport({ simulation_id: props.simulationId, resume: true })
if (res.success) {
//
reportStatus.value = 'generating'
reportError.value = null
emit('update-status', 'processing')
agentLogLine.value = 0
consoleLogLine.value = 0
startPolling()
}
} catch (err) {
console.warn('Resume failed:', err)
} finally {
isResuming.value = false
}
}
const startPolling = () => {
if (agentLogTimer || consoleLogTimer) return
fetchAgentLog()
fetchConsoleLog()
fetchProgress()
agentLogTimer = setInterval(fetchAgentLog, 2000)
consoleLogTimer = setInterval(fetchConsoleLog, 1500)
progressTimer = setInterval(fetchProgress, 3000)
}
const stopPolling = () => {
@ -2173,6 +2244,10 @@ const stopPolling = () => {
clearInterval(consoleLogTimer)
consoleLogTimer = null
}
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
}
// Lifecycle
@ -2200,8 +2275,11 @@ watch(() => props.reportId, (newId) => {
expandedLogs.value = new Set()
collapsedSections.value = new Set()
isComplete.value = false
reportStatus.value = null
reportError.value = null
isResuming.value = false
startTime.value = null
startPolling()
}
}, { immediate: true })
@ -2332,6 +2410,72 @@ watch(() => props.reportId, (newId) => {
padding: 30px 50px 60px 50px;
}
.resume-banner {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 16px 18px;
margin-bottom: 24px;
background: #FFF7ED;
border: 1px solid #FDBA74;
border-radius: 8px;
}
.resume-banner-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #F97316;
color: #FFF;
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.resume-banner-body {
flex: 1;
min-width: 0;
}
.resume-banner-title {
font-weight: 700;
font-size: 14px;
color: #9A3412;
margin-bottom: 4px;
}
.resume-banner-desc {
font-size: 13px;
color: #7C2D12;
line-height: 1.5;
}
.resume-banner-error {
margin-top: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: #9A3412;
word-break: break-word;
opacity: 0.85;
}
.resume-banner-btn {
margin-top: 10px;
padding: 8px 16px;
background: #F97316;
color: #FFF;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.resume-banner-btn:hover:not(:disabled) {
background: #EA580C;
}
.resume-banner-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.left-panel::-webkit-scrollbar {
width: 6px;
}

View File

@ -247,7 +247,11 @@
"panelRelatedEdges": "Related Edges",
"panelRelatedNodes": "Related Nodes",
"world1": "World 1",
"world2": "World 2"
"world2": "World 2",
"reportFailedTitle": "Report generation failed",
"reportFailedDesc": "Your outline and {count} completed sections are preserved. Click below to continue from where it stopped.",
"resumeGeneration": "Resume Generation",
"resumingGeneration": "Resuming..."
},
"step5": {
"interactiveTools": "Interactive Tools",
@ -429,6 +433,8 @@
"outlineDone": "Outline complete, {count} sections",
"generatingSection": "Generating section: {title} ({current}/{total})",
"sectionDone": "Section {title} complete",
"resumeStart": "Resuming generation, {count} sections already done",
"sectionResumed": "Section {title} already exists, skipped",
"assemblingReport": "Assembling full report...",
"reportComplete": "Report generation complete",
"reportFailed": "Report generation failed: {error}",
@ -597,6 +603,8 @@
"sectionGenFailedContent": "(This section failed to generate: LLM returned empty response, please retry later)",
"outlineSavedToFile": "Outline saved to file: {reportId}/outline.json",
"sectionSaved": "Section saved: {reportId}/section_{sectionNum}.md",
"resumeStart": "Resuming report generation {reportId} ({count} sections already completed)",
"sectionResumed": "Section {title} already generated, reusing",
"reportGenDone": "Report generation complete: {reportId}",
"reportGenFailed": "Report generation failed: {error}",
"agentChat": "Report Agent chat: {message}...",

View File

@ -247,7 +247,11 @@
"panelRelatedEdges": "相关关系",
"panelRelatedNodes": "相关节点",
"world1": "世界1",
"world2": "世界2"
"world2": "世界2",
"reportFailedTitle": "报告生成失败",
"reportFailedDesc": "大纲和已完成的 {count} 个章节已保存,点击下方按钮可从失败处继续。",
"resumeGeneration": "继续生成",
"resumingGeneration": "正在继续..."
},
"step5": {
"interactiveTools": "Interactive Tools",
@ -429,6 +433,8 @@
"outlineDone": "大纲规划完成,共{count}个章节",
"generatingSection": "正在生成章节: {title} ({current}/{total})",
"sectionDone": "章节 {title} 已完成",
"resumeStart": "恢复生成,已完成 {count} 个章节",
"sectionResumed": "章节 {title} 已存在,跳过",
"assemblingReport": "正在组装完整报告...",
"reportComplete": "报告生成完成",
"reportFailed": "报告生成失败: {error}",
@ -597,6 +603,8 @@
"sectionGenFailedContent": "本章节生成失败LLM 返回空响应,请稍后重试)",
"outlineSavedToFile": "大纲已保存到文件: {reportId}/outline.json",
"sectionSaved": "章节已保存: {reportId}/section_{sectionNum}.md",
"resumeStart": "恢复生成报告 {reportId}(已完成 {count} 个章节)",
"sectionResumed": "章节 {title} 已生成过,复用已有内容",
"reportGenDone": "报告生成完成: {reportId}",
"reportGenFailed": "报告生成失败: {error}",
"agentChat": "Report Agent对话: {message}...",