Spaces:
Sleeping
Sleeping
| /** | |
| * 使命必达执行器 - Mission-Critical Task Executor | |
| * | |
| * ★★★ 核心特性 ★★★ | |
| * 1. 智能模型切换:超时自动切换到下一个模型,不等待看门狗 | |
| * 2. 速度优先:记录快速模型,下次优先使用 | |
| * 3. 无限重试:模型池轮询,直到找到响应快的模型 | |
| * 4. 使命必达:绝不放弃,直到任务完成 | |
| * 5. ★★★ 任务 3: 模型感知适配 - 自动折叠 System Role、裁剪 Context ★★★ | |
| */ | |
| import pLimit from 'p-limit'; | |
| import Redis from 'ioredis'; | |
| import { ModelDispatcher } from '../lib/model-dispatcher'; | |
| import { getModelMeta, TaskPhase, MODEL_POOL } from '../lib/model-registry'; | |
| // ★★★ 任务 3: 导入适配器 ★★★ | |
| import { | |
| adaptPayload, | |
| sniffErrorAndLearn, | |
| getEffectiveModelMeta, | |
| ChatMessage, | |
| } from '../lib/payload-adapter'; | |
| import { getDesignPrompt, getStyledPrompt } from '../lib/design-prompt-loader'; | |
| import type { StyleProfile } from '../lib/style-profiles'; | |
| import { runOpenGamePipeline } from '../lib/opengame-pipeline'; | |
| import { sanitizeErrorMessage } from '../lib/error-sanitizer'; | |
| console.log('[STARTUP] task-executor loaded, BUILD: 20260523-v9-ANTI-SLOP-V2'); | |
| export type DesignTaskType = 'html_prototype' | 'pitch_deck' | 'ui_design_system'; | |
| export type OpenGameTaskType = 'opengame_generation'; | |
| export interface TaskParams { | |
| prompt: string; | |
| sections?: number; | |
| style?: StyleProfile; | |
| maxTokens?: number; | |
| draftContent?: string; | |
| skipPhase1?: boolean; | |
| tier?: 'free' | 'starter' | 'pro' | 'enterprise'; | |
| taskType?: 'full_plan' | 'sales_manual' | 'world_setting' | 'competitor_analysis' | DesignTaskType | OpenGameTaskType; | |
| } | |
| export interface TaskOutline { | |
| title: string; | |
| sections: Array<{ | |
| title: string; | |
| points: string[]; | |
| }>; | |
| metadata?: Record<string, any>; | |
| } | |
| export interface TaskResult { | |
| content: string; | |
| outline: TaskOutline; | |
| sectionsGenerated: number; | |
| duration: number; | |
| title?: string; // ★ LLM 生成的文档标题(15字以内) | |
| phases: { | |
| planning: { duration: number; model: string; attempts: number }; | |
| expansion: { duration: number; model: string; sections: number; attempts: number }; | |
| review: { duration: number; model: string; attempts: number }; | |
| }; | |
| totalAttempts: number; // 总尝试次数 | |
| fastModelsUsed: string[]; // 使用的快速模型列表 | |
| outputType?: 'docx' | 'zip' | 'pptx'; | |
| fileExtension?: string; | |
| contentType?: string; | |
| } | |
| export interface ProgressReport { | |
| taskId: string; | |
| status: 'planning' | 'writing' | 'reviewing' | 'completed' | 'failed'; | |
| progress: number; | |
| phase: string; | |
| detail?: string; | |
| updatedAt: number; | |
| modelSwitch?: string; // 模型切换信息 | |
| } | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // ★★★ 日本服务器极速跳板模式 ★★★ | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // ★★★ 任务 1: 使用日本跳板替代直连 NVIDIA ★★★ | |
| const PROXY_ENDPOINT = process.env.LLM_PROXY_ENDPOINT || 'https://game.5e1.com/api/internal/llm-proxy'; | |
| const PROXY_SECRET = process.env.INTERNAL_API_SECRET || ''; | |
| const CONCURRENCY_LIMIT = 2; | |
| // ★★★ 设计任务类型检测 ★★★ | |
| const DESIGN_TASK_TYPES: DesignTaskType[] = ['html_prototype', 'pitch_deck', 'ui_design_system']; | |
| function isDesignTask(taskType?: string): taskType is DesignTaskType { | |
| return DESIGN_TASK_TYPES.includes(taskType as DesignTaskType); | |
| } | |
| function getDesignTaskOutputMeta(taskType: DesignTaskType): { outputType: 'zip' | 'pptx'; fileExtension: string; contentType: string } { | |
| if (taskType === 'pitch_deck') { | |
| return { | |
| outputType: 'pptx', | |
| fileExtension: 'pptx', | |
| contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', | |
| }; | |
| } | |
| return { | |
| outputType: 'zip', | |
| fileExtension: 'zip', | |
| contentType: 'application/zip', | |
| }; | |
| } | |
| // ★★★ 超时策略:给模型充足生成时间 ★★★ | |
| const MODEL_TIMEOUT_MS = 45000; // 45秒总超时(含 TTFB + token 生成,mimo-v2.5 planning 需要 20-30s) | |
| const MAX_MODEL_ATTEMPTS = 5; | |
| const FAST_MODEL_THRESHOLD = 5000; | |
| const SLOW_MODEL_BLACKLIST_MS = 120000; | |
| // Redis Key 用于存储快速模型优先级 | |
| const FAST_MODEL_KEY = 'slow_task:fast_models'; | |
| export class TaskExecutor { | |
| private dispatcher: ModelDispatcher; | |
| private redis: Redis; | |
| private abortFlag: boolean = false; | |
| private progressCallback: ((report: ProgressReport) => void) | null = null; | |
| // 心跳状态 | |
| private lastProgress: number = 0; | |
| private lastPhase: string = 'init'; | |
| private lastDetail: string = ''; | |
| // AbortController | |
| private abortController: AbortController | null = null; | |
| // ★★★ 新增:快速模型缓存 ★★★ | |
| private fastModelCache: Map<string, { avgTtfb: number; lastSuccess: number }> = new Map(); | |
| private slowModelBlacklist: Map<string, number> = new Map(); // modelId -> blacklistUntil | |
| constructor(dispatcher: ModelDispatcher, redis: Redis) { | |
| this.dispatcher = dispatcher; | |
| this.redis = redis; | |
| // 加载快速模型缓存 | |
| this.loadFastModelCache(); | |
| } | |
| /** | |
| * 从 Redis 加载快速模型缓存 | |
| */ | |
| private async loadFastModelCache(): Promise<void> { | |
| try { | |
| const data = await this.redis.hgetall(FAST_MODEL_KEY); | |
| if (data) { | |
| for (const [modelId, stats] of Object.entries(data)) { | |
| const parsed = typeof stats === 'string' ? JSON.parse(stats) : stats; | |
| this.fastModelCache.set(modelId, { | |
| avgTtfb: parsed.avgTtfb || 5000, | |
| lastSuccess: parsed.lastSuccess || Date.now() | |
| }); | |
| } | |
| console.log(`[MissionExecutor] 📊 加载快速模型缓存: ${this.fastModelCache.size} 个模型`); | |
| } | |
| } catch (err) { | |
| console.warn('[MissionExecutor] 加载快速模型缓存失败:', err); | |
| } | |
| } | |
| /** | |
| * 记录快速模型 | |
| */ | |
| private async recordFastModel(modelId: string, ttfb: number): Promise<void> { | |
| if (ttfb < FAST_MODEL_THRESHOLD) { | |
| const existing = this.fastModelCache.get(modelId); | |
| const avgTtfb = existing ? (existing.avgTtfb + ttfb) / 2 : ttfb; | |
| this.fastModelCache.set(modelId, { avgTtfb, lastSuccess: Date.now() }); | |
| // 写入 Redis 持久化 | |
| try { | |
| await this.redis.hset(FAST_MODEL_KEY, { | |
| [modelId]: JSON.stringify({ avgTtfb, lastSuccess: Date.now() }) | |
| }); | |
| console.log(`[MissionExecutor] ⚡ 记录快速模型: ${modelId} (avg TTFB: ${avgTtfb}ms)`); | |
| } catch (err) { | |
| console.warn('[MissionExecutor] 记录快速模型失败:', err); | |
| } | |
| } | |
| } | |
| /** | |
| * 检查模型是否在黑名单中 | |
| */ | |
| private isModelBlacklisted(modelId: string): boolean { | |
| const blacklistUntil = this.slowModelBlacklist.get(modelId); | |
| if (blacklistUntil && Date.now() < blacklistUntil) { | |
| return true; | |
| } | |
| // 黑名单过期,移除 | |
| if (blacklistUntil) { | |
| this.slowModelBlacklist.delete(modelId); | |
| } | |
| return false; | |
| } | |
| /** | |
| * 将慢模型加入临时黑名单 | |
| */ | |
| private blacklistSlowModel(modelId: string): void { | |
| this.slowModelBlacklist.set(modelId, Date.now() + SLOW_MODEL_BLACKLIST_MS); | |
| console.log(`[MissionExecutor] 🚫 暂时黑名单慢模型: ${modelId} (${SLOW_MODEL_BLACKLIST_MS / 1000}秒)`); | |
| } | |
| /** | |
| * ★★★ 核心方法:使命必达的模型调用 ★★★ | |
| * 特性: | |
| * - 单次超时45秒,超时立即切换下一个模型 | |
| * - 每个阶段最多尝试5个模型 | |
| * - 记录快速模型,下次优先使用 | |
| * - 黑名单慢模型2分钟 | |
| */ | |
| private async callNIMMissionCritical( | |
| taskId: string, | |
| phase: TaskPhase, | |
| messages: Array<{ role: string; content: string }>, | |
| maxTokens: number, | |
| systemPrompt?: string | |
| ): Promise<{ content: string; modelId: string; ttfb: number; attempts: number }> { | |
| const startTime = Date.now(); | |
| let attempts = 0; | |
| let lastError: Error | null = null; | |
| this.dispatcher.setPhase(phase); | |
| // ★★★ 最多尝试 MAX_MODEL_ATTEMPTS 个不同模型 ★★★ | |
| while (attempts < MAX_MODEL_ATTEMPTS) { | |
| attempts++; | |
| // 获取下一个可用模型(跳过黑名单) | |
| let modelId: string | null = null; | |
| let tryCount = 0; | |
| const maxTry = 10; | |
| while (!modelId && tryCount < maxTry) { | |
| const candidate = this.dispatcher.getNextModel(); | |
| if (!candidate) { | |
| console.warn(`[MissionExecutor] 无可用模型,重置健康状态`); | |
| this.dispatcher.resetAllHealth(); | |
| continue; | |
| } | |
| // 检查黑名单 | |
| if (!this.isModelBlacklisted(candidate)) { | |
| modelId = candidate; | |
| } else { | |
| console.log(`[MissionExecutor] 跳过黑名单模型: ${candidate}`); | |
| } | |
| tryCount++; | |
| } | |
| if (!modelId) { | |
| // 所有模型都在黑名单,清空黑名单强制使用 | |
| this.slowModelBlacklist.clear(); | |
| modelId = this.dispatcher.getNextModel(); | |
| if (!modelId) { | |
| throw new Error('所有模型池耗尽,无法继续'); | |
| } | |
| } | |
| const apiKey = this.dispatcher.getApiKey(); | |
| const meta = getModelMeta(modelId); | |
| console.log(`[MissionExecutor] 🎯 第 ${attempts} 次尝试: ${modelId} (${phase}阶段)`); | |
| await this.updateProgress(taskId, this.lastProgress, this.lastPhase, | |
| `${phase}阶段尝试 ${attempts}/${MAX_MODEL_ATTEMPTS}: ${modelId}`); | |
| // ★★★ 单次调用超时45秒 ★★★ | |
| const callStartTime = Date.now(); | |
| let heartbeatInterval: ReturnType<typeof setInterval> | null = null; // ★★★ 定义在外层,便于 catch 访问 ★★★ | |
| try { | |
| // ★★★ 修复:创建 AbortController 用于 fetch 和看门狗 ★★★ | |
| this.abortController = new AbortController(); | |
| this.abortFlag = false; | |
| // ★★★ 关键修复:使用 AbortSignal.timeout 覆盖整个请求周期 ★★★ | |
| // AbortSignal.timeout 会在 fetch + response.json() 整个过程中超时 | |
| // 这是 Node.js 18+ 特性,比手动 setTimeout 更可靠 | |
| const timeoutSignal = AbortSignal.timeout(MODEL_TIMEOUT_MS); | |
| // 心跳定时器(每10秒更新进度) | |
| heartbeatInterval = setInterval(async () => { | |
| if (this.abortFlag) { | |
| if (heartbeatInterval) clearInterval(heartbeatInterval); | |
| return; | |
| } | |
| const elapsed = Math.floor((Date.now() - callStartTime) / 1000); | |
| await this.updateProgress(taskId, this.lastProgress, this.lastPhase, | |
| `${phase}阶段: ${modelId} 响应中 (${elapsed}秒)`); | |
| }, 10000); | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // ★★★ 任务 3: 模型感知适配 ★★★ | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // 1. 获取有效模型元数据(合并静态 + 学习数据) | |
| const effectiveMeta = await getEffectiveModelMeta(this.redis, modelId); | |
| // 2. 构建原始消息数组 | |
| const rawMessages: ChatMessage[] = systemPrompt | |
| ? [{ role: 'system', content: systemPrompt }, ...messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }))] | |
| : messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })); | |
| // 3. 使用适配器自动适配 | |
| const adapted = adaptPayload(modelId, rawMessages, { maxTokens }); | |
| // 4. 如果有适配行为,打印日志 | |
| if (adapted.adapted) { | |
| console.log(`[MissionExecutor] 🔧 ${modelId} 适配: ${adapted.adaptations.join('; ')}`); | |
| await this.updateProgress(taskId, this.lastProgress, this.lastPhase, | |
| `${phase}阶段: ${modelId} 已适配 (${adapted.adaptations.length}项)`); | |
| } | |
| const finalMaxTokens = adapted.maxTokens; | |
| const finalMessages = adapted.messages; | |
| // ★★★ 任务 2: 使用跳板模式 ★★★ | |
| // 不再直连 NVIDIA,而是通过日本服务器跳板 | |
| console.log(`[MissionExecutor] 🚀 使用跳板: ${PROXY_ENDPOINT}`); | |
| // ★★★ 硬超时:Promise.race 覆盖 fetch + response.json() 整个生命周期 ★★★ | |
| // AbortSignal.timeout 只控制连接/TTFB,不控制 token 生成速度 | |
| // Promise.race 确保总时间不超过 MODEL_TIMEOUT_MS | |
| const fetchAndParse = async () => { | |
| const response = await fetch(PROXY_ENDPOINT, { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${PROXY_SECRET}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| model: modelId, | |
| messages: finalMessages, | |
| max_tokens: finalMaxTokens, | |
| stream: false | |
| }), | |
| signal: timeoutSignal | |
| }); | |
| if (!response.ok) { | |
| const errText = await response.text(); | |
| throw { status: response.status, body: errText, isHttpError: true }; | |
| } | |
| return response.json() as Promise<{ | |
| choices?: Array<{ message?: { content?: string; reasoning_content?: string } }>; | |
| _proxy_meta?: { ttfb: number }; | |
| }>; | |
| }; | |
| const hardTimeout = new Promise<never>((_, reject) => { | |
| setTimeout(() => reject(new Error('MODEL_HARD_TIMEOUT')), MODEL_TIMEOUT_MS); | |
| }); | |
| const data = await Promise.race([fetchAndParse(), hardTimeout]); | |
| // ★★★ 跳板返回错误处理 + 任务 3: 错误嗅探与学习 ★★★ | |
| // (HTTP 错误已在 fetchAndParse 内部抛出) | |
| const content = data.choices?.[0]?.message?.content || ''; | |
| if (!content) { | |
| const rLen = data.choices?.[0]?.message?.reasoning_content?.length || 0; | |
| console.warn(`[MissionExecutor] ⚠️ ${modelId} content 为空! reasoning_content=${rLen} tokens, 可能 max_tokens 不足或缺少 system prompt`); | |
| } | |
| const ttfb = data._proxy_meta?.ttfb || (Date.now() - callStartTime); | |
| // ★★★ 清除心跳定时器 ★★★ | |
| if (heartbeatInterval) clearInterval(heartbeatInterval); | |
| // ★★★ 成功!记录快速模型 ★★★ | |
| await this.recordFastModel(modelId, ttfb); | |
| await this.dispatcher.reportSuccess(modelId, ttfb, content.length / 4, Date.now() - callStartTime); | |
| console.log(`[MissionExecutor] ✅ 成功: ${modelId} TTFB=${ttfb}ms 尝试次数=${attempts}`); | |
| return { content, modelId, ttfb, attempts }; | |
| } catch (err: any) { | |
| if (heartbeatInterval) clearInterval(heartbeatInterval); | |
| const elapsed = Date.now() - callStartTime; | |
| // ★★★ 区分超时、HTTP 错误和其他错误 ★★★ | |
| const isTimeout = err.message === 'MODEL_HARD_TIMEOUT' | |
| || err.name === 'AbortError' | |
| || err.name === 'TimeoutError' | |
| || elapsed >= MODEL_TIMEOUT_MS - 1000; | |
| if (isTimeout) { | |
| console.warn(`[MissionExecutor] ⏱️ ${modelId} 超时 (${elapsed}ms),加入黑名单,切换下一个`); | |
| this.blacklistSlowModel(modelId); | |
| await this.dispatcher.reportFailure(modelId, 504, 'Timeout'); | |
| lastError = new Error(`模型 ${modelId} 超时 (${elapsed}ms)`); | |
| } else if (err.isHttpError) { | |
| // ★★★ HTTP 错误:嗅探学习 ★★★ | |
| console.error(`[跳板报错] 模型: ${modelId}, 状态码: ${err.status}, 内容: ${err.body?.slice(0, 200)}`); | |
| const sniffResult = await sniffErrorAndLearn(this.redis, modelId, err.body || ''); | |
| if (sniffResult.shouldRetry && attempts < MAX_MODEL_ATTEMPTS) { | |
| console.log(`[MissionExecutor] 🔄 ${modelId} 自愈重试 (已学习适配)`); | |
| attempts--; | |
| if (sniffResult.newParams) maxTokens = sniffResult.newParams.maxTokens; | |
| continue; | |
| } | |
| await this.dispatcher.reportFailure(modelId, err.status || 500, err.body?.slice(0, 100) || 'Unknown'); | |
| lastError = new Error(`HTTP ${err.status}: ${(err.body || '').slice(0, 200)}`); | |
| } else { | |
| console.warn(`[MissionExecutor] ❌ ${modelId} 错误: ${err.message}`); | |
| await this.dispatcher.reportFailure(modelId, 500, err.message); | |
| lastError = err; | |
| } | |
| // 继续尝试下一个模型 | |
| console.log(`[MissionExecutor] 🔄 切换到下一个模型...`); | |
| } | |
| } | |
| // 所有模型都失败 | |
| throw new Error(sanitizeErrorMessage( | |
| `使命必达失败: ${phase}阶段尝试了 ${attempts} 个模型均超时/失败。最后错误: ${lastError?.message}` | |
| )); | |
| } | |
| /** | |
| * 执行慢任务完整流程 | |
| */ | |
| async execute(taskId: string, params: TaskParams): Promise<TaskResult> { | |
| const startTime = Date.now(); | |
| const phases = { | |
| planning: { duration: 0, model: '', attempts: 0 }, | |
| expansion: { duration: 0, model: '', sections: 0, attempts: 0 }, | |
| review: { duration: 0, model: '', attempts: 0 } | |
| }; | |
| let totalAttempts = 0; | |
| const fastModelsUsed: string[] = []; | |
| this.abortController = new AbortController(); | |
| this.abortFlag = false; | |
| // ★ OPT-02: 兜底 — 当 prompt 为空但 draftContent 存在时,用 draftContent 作为 prompt | |
| if (!params.prompt && params.draftContent) { | |
| params.prompt = params.draftContent; | |
| console.log(`[TaskExecutor] ⚡ Prompt 为空,使用 draftContent 作为 fallback (${params.prompt.length} chars)`); | |
| } | |
| // ★★★ DRY_RUN 急速测试模式 ★★★ | |
| if (params.prompt && params.prompt.includes('[DRY_RUN]')) { | |
| console.log('[MissionExecutor] 🚀 DRY_RUN 模式'); | |
| await this.updateProgress(taskId, 50, 'writing', 'DRY_RUN 测试...'); | |
| await new Promise(resolve => setTimeout(resolve, 3000)); | |
| const fakeMarkdown = `# DRY_RUN 测试文档 | |
| 任务 ID: ${taskId} | |
| 测试时间: ${new Date().toISOString()} | |
| 使命必达机制验证成功!`; | |
| await this.redis.set(`slow_task:content:${taskId}`, fakeMarkdown, 'EX', 86400 * 7); | |
| await this.redis.hset('slow_task:results', { | |
| [taskId]: JSON.stringify({ | |
| taskId, status: 'completed', progress: 100, phase: 'completed', | |
| detail: 'DRY_RUN 完成', updatedAt: Date.now(), | |
| // downloadUrl 由 processor.ts 上传 COS 后设置 | |
| qualityGrade: 'S', sectionsGenerated: 1, duration: 3000 | |
| }) | |
| }); | |
| return { | |
| content: fakeMarkdown, | |
| outline: { title: 'DRY_RUN', sections: [{ title: 'Test', points: ['test'] }] }, | |
| sectionsGenerated: 1, duration: 3000, phases, | |
| totalAttempts: 1, fastModelsUsed: ['dry_run'], | |
| title: 'DRY_RUN', | |
| }; | |
| } | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // OpenGame 管线任务 | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| if (params.taskType === 'opengame_generation') { | |
| return this.executeOpenGame(taskId, params); | |
| } | |
| try { | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // Phase 1: 结构规划 | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| await this.updateProgress(taskId, 5, 'planning', '开始规划大纲...'); | |
| const planningStart = Date.now(); | |
| let outline: TaskOutline; | |
| let llmTitle = ''; // ★ LLM 生成的文档标题 | |
| // ★★★ 修复:draftContent 为空时,fallback 到 prompt(含 chatContext 中的 HTML artifact) ★★★ | |
| const draftSource = params.draftContent || ''; | |
| // 检测是否为草案模式:有 draftContent,或 prompt 包含 HTML/结构化内容 | |
| const hasHtmlContent = params.prompt && (/<h[1-6][^>]*>/i.test(params.prompt) || /<artifact[\s>]/i.test(params.prompt) || /<div[\s>]/i.test(params.prompt)); | |
| const isDraftMode = (draftSource.length > 0 && params.skipPhase1) || hasHtmlContent; | |
| const effectiveDraft = draftSource.length > 0 ? draftSource : (hasHtmlContent ? params.prompt : ''); | |
| if (isDraftMode && effectiveDraft.length > 0) { | |
| // ★★★ 关键:先清洗 HTML,移除 style/script/head 内容 ★★★ | |
| const cleanedContent = this.cleanHtmlContent(effectiveDraft); | |
| // 先尝试结构化提取 | |
| outline = this.extractOutlineFromDraft(effectiveDraft); | |
| phases.planning.model = 'draft-anchor'; | |
| phases.planning.attempts = 1; | |
| // ★ 诊断数据 | |
| const draftDebug: Record<string, any> = { | |
| promptLen: params.prompt?.length || 0, | |
| draftSourceLen: draftSource.length, | |
| effectiveDraftLen: effectiveDraft.length, | |
| cleanedContentLen: cleanedContent.length, | |
| cleanedContentPreview: cleanedContent.slice(0, 200), | |
| hasHtmlContent, | |
| isDraftMode, | |
| extractedSections: outline.sections.length, | |
| extractedTitle: outline.title, | |
| firstSectionTitle: outline.sections[0]?.title || '', | |
| buildTag: 'v9-anti-slop-v2', | |
| llmOutlineAttempted: false, | |
| llmOutlineSuccess: false, | |
| llmTitleAttempted: false, | |
| llmTitleSuccess: false, | |
| }; | |
| (phases.planning as any).draftDebug = draftDebug; | |
| // ★★★ 如果结构化提取只得到泛化 fallback,用 LLM 从实际内容生成大纲 ★★★ | |
| const isGenericOutline = outline.sections.length === 1 && outline.sections[0].title === '主体内容'; | |
| if (isGenericOutline) { | |
| console.log('[MissionExecutor] 结构化提取失败,使用 LLM 从实际内容生成大纲...'); | |
| draftDebug.llmOutlineAttempted = true; | |
| try { | |
| const isPaid = params.tier && params.tier !== 'free'; | |
| const contentForLLM = cleanedContent.slice(0, 4000); | |
| const outlinePrompt = `你是一位文档分析专家。请分析以下内容,提取核心主题并生成文档大纲。 | |
| 内容: | |
| ${contentForLLM} | |
| 严格输出纯 JSON,禁止包含其他文字或代码块标记: | |
| { | |
| "title": "文档标题(15字以内,精准有力,必须体现具体内容主题)", | |
| "sections": [ | |
| { "title": "章节标题", "points": ["要点1", "要点2", "要点3"] } | |
| ] | |
| } | |
| 要求: | |
| - 章节数量:${isPaid ? '5-8' : '4-6'} 个 | |
| - 每章节 3-5 个要点 | |
| - 标题必须反映内容的具体主题,禁止使用"主体内容""概述"等泛化标题`; | |
| const outlineResult = await this.callNIMMissionCritical( | |
| taskId, 'planning', | |
| [{ role: 'user', content: outlinePrompt }], | |
| 4000, | |
| '你是一位文档分析专家。只输出纯 JSON,不要任何多余文字。' | |
| ); | |
| draftDebug.llmOutlineRaw = outlineResult.content.slice(0, 500); | |
| draftDebug.llmOutlineModel = outlineResult.modelId; | |
| // 安全解析 JSON | |
| const jsonStr = outlineResult.content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); | |
| let parsed: any = null; | |
| try { parsed = JSON.parse(jsonStr); } catch { | |
| const m = jsonStr.match(/\{[\s\S]*\}/); | |
| if (m) { try { parsed = JSON.parse(m[0]); } catch {} } | |
| } | |
| if (parsed && parsed.sections && parsed.sections.length > 0) { | |
| outline = { | |
| title: parsed.title || outline.title, | |
| sections: parsed.sections.map((s: any) => ({ | |
| title: s.title || '未命名章节', | |
| points: Array.isArray(s.points) ? s.points : ['详细阐述'], | |
| })), | |
| metadata: { source: 'draft-llm' }, | |
| }; | |
| llmTitle = parsed.title || ''; | |
| draftDebug.llmOutlineSuccess = true; | |
| draftDebug.llmSections = outline.sections.length; | |
| draftDebug.llmOutlineTitle = llmTitle; | |
| console.log('[MissionExecutor] LLM 大纲生成成功:', outline.title, '章节:', outline.sections.length); | |
| } else { | |
| draftDebug.llmOutlineParseError = 'JSON parsed but no sections found'; | |
| console.warn('[MissionExecutor] LLM 大纲 JSON 解析成功但无 sections:', JSON.stringify(parsed)?.slice(0, 200)); | |
| } | |
| } catch (outlineErr: any) { | |
| draftDebug.llmOutlineError = outlineErr.message?.slice(0, 200); | |
| console.warn('[MissionExecutor] LLM 大纲生成失败:', outlineErr.message); | |
| } | |
| } | |
| // ★★★ 标题生成:如果 LLM 大纲没有产出标题,调用专门的标题生成 ★★★ | |
| if (!llmTitle) { | |
| draftDebug.llmTitleAttempted = true; | |
| try { | |
| const titleResult = await this.callNIMMissionCritical( | |
| taskId, 'planning', | |
| [{ | |
| role: 'user', | |
| content: `请为以下文档内容生成一个精准有力的标题(15字以内,直接输出标题文字,不要引号或任何标记):\n\n${cleanedContent.slice(0, 1000)}` | |
| }], | |
| 2000, | |
| '你是文档标题专家。只输出一个标题,15字以内,精准有力,不要任何多余文字。' | |
| ); | |
| const rawTitle = titleResult.content.replace(/["'"「」【】\s\n\r]/g, '').slice(0, 15); | |
| if (rawTitle.length > 0) { | |
| llmTitle = rawTitle; | |
| draftDebug.llmTitleSuccess = true; | |
| draftDebug.llmTitleResult = llmTitle; | |
| draftDebug.llmTitleModel = titleResult.modelId; | |
| console.log('[MissionExecutor] LLM 生成标题(草案模式):', llmTitle); | |
| } else { | |
| draftDebug.llmTitleEmpty = true; | |
| console.warn('[MissionExecutor] LLM 标题返回空'); | |
| } | |
| } catch (titleErr: any) { | |
| draftDebug.llmTitleError = titleErr.message?.slice(0, 200); | |
| console.warn('[MissionExecutor] 标题生成失败:', titleErr.message); | |
| } | |
| // ★★★ 最终兜底:从内容首行提取标题 ★★★ | |
| if (!llmTitle) { | |
| const firstLine = cleanedContent.split(/[。\n]/).find(l => l.trim().length > 3) || ''; | |
| llmTitle = firstLine.replace(/[^a-zA-Z0-9一-鿿·\-| ]/g, '').trim().slice(0, 15); | |
| draftDebug.heuristicTitle = llmTitle; | |
| console.log('[MissionExecutor] 启发式标题:', llmTitle); | |
| } | |
| } | |
| } else { | |
| const isPaid = params.tier && params.tier !== 'free'; | |
| const planningSystemPrompt = isPaid | |
| ? `你是腾讯/网易级资深系统策划,正在为「大师全案」生成文档大纲。 | |
| 【反 AI 废话协议】 | |
| - 禁止学术名词堆砌(沉没成本/损失厌恶等术语全篇最多出现1次) | |
| - 禁止名人名言、煽情愿景 | |
| - 章节标题必须具体有力,禁止"概述""简介""背景"等虚词 | |
| - 每个要点必须是可执行的具体动作或可量化的数据指标 | |
| 大纲格式(严格输出纯 JSON): | |
| { | |
| "title": "文档标题(15字以内,体现具体主题,禁止'完整策划案''大师全案'等泛化词)", | |
| "subtitle": "副标题(20字以内,说明核心价值)", | |
| "sections": [ | |
| { "title": "章节标题", "points": ["要点1", "要点2", "要点3", "要点4", "要点5"] } | |
| ] | |
| } | |
| 结构要求: | |
| - 章节数量:6-8 个,逻辑递进 | |
| - 每章节 4-6 个要点,每个要点必须具体到可执行 | |
| - 必须包含的模块(按顺序): | |
| 1. 现状诊断(含具体数据指标和问题定位) | |
| 2. 核心机制设计(含数据结构、接口定义、边界情况) | |
| 3. 用户流程与交互(含客户端-服务端通信流程) | |
| 4. 数值与经济系统(含数值表、平衡公式、付费转化节点) | |
| 5. 风险与异常处理(含 TOP3 风险 + 具体规避方案) | |
| 6. 验收标准(含可测量 KPI 和测试用例) | |
| - 要点示例(必须达到此粒度): | |
| ✓ "用户数据表含 userId/giftId/status/purchasedAt 五个核心字段" | |
| ✓ "支付回调超时 30s 触发定时补偿任务,24h 内自动对账" | |
| ✓ "首充转化率目标 42%,基于沉没成本机制行业基准推导" | |
| ✗ "分析用户需求"(太抽象) | |
| ✗ "制定运营策略"(太泛化)` | |
| : `你是资深系统策划,正在为「灵感草案」生成文档大纲。 | |
| 【反 AI 废话协议】禁止学术名词堆砌、名人名言、模糊词。章节标题必须具体。 | |
| 大纲格式(严格输出纯 JSON): | |
| { | |
| "title": "文档标题(10字以内,体现具体主题,禁止'灵感草案''草案'等泛化词)", | |
| "subtitle": "副标题(简短描述核心方向)", | |
| "sections": [ | |
| { "title": "章节标题", "points": ["要点1", "要点2", "要点3"] } | |
| ] | |
| } | |
| 结构要求: | |
| - 章节数量:4-5 个,抓住核心 | |
| - 每章节 3-4 个要点,每个要点具体可执行 | |
| - 必须覆盖:核心机制 → 关键数据/数值 → 执行路径 → 风险与注意事项 | |
| - 要点必须有具体数字或可执行动作,禁止泛化罗列 | |
| - 标题示例:"独立游戏首充系统设计"(好) vs "变现策略分析"(差)`; | |
| const planningResult = await this.callNIMMissionCritical( | |
| taskId, 'planning', | |
| [{ role: 'user', content: params.prompt }], | |
| 4000, | |
| planningSystemPrompt | |
| ); | |
| // 解析大纲 | |
| try { | |
| const jsonMatch = planningResult.content.match(/\{[\s\S]*\}/); | |
| outline = jsonMatch ? JSON.parse(jsonMatch[0]) : { | |
| title: '生成文档', sections: [ | |
| { title: '概述', points: ['背景介绍', '核心概念'] }, | |
| { title: '主体', points: ['详细分析', '关键要素'] }, | |
| { title: '总结', points: ['核心结论', '建议方案'] } | |
| ] | |
| }; | |
| } catch { | |
| outline = { title: '生成文档', sections: [{ title: '主体', points: ['内容生成'] }] }; | |
| } | |
| // ★★★ 从 LLM 输出的 JSON 中提取 title ★★★ | |
| llmTitle = outline.title || ''; | |
| console.log('[MissionExecutor] LLM 生成标题(规划模式):', llmTitle); | |
| phases.planning.model = planningResult.modelId; | |
| phases.planning.attempts = planningResult.attempts; | |
| totalAttempts += planningResult.attempts; | |
| if (planningResult.ttfb < FAST_MODEL_THRESHOLD) { | |
| fastModelsUsed.push(planningResult.modelId); | |
| } | |
| } | |
| phases.planning.duration = Date.now() - planningStart; | |
| await this.updateProgress(taskId, 20, 'planning', '大纲生成完成'); | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // Phase 2: 分章扩写 | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| const sections = outline.sections || []; | |
| const expansionStart = Date.now(); | |
| const limit = pLimit(CONCURRENCY_LIMIT); | |
| const designTask = isDesignTask(params.taskType); | |
| console.log(`[MissionExecutor] Phase 2: 扩写 ${sections.length} 个章节${designTask ? ' (设计任务)' : ''}`); | |
| // ★★★ 设计任务:一次性完整生成(不拆章节) ★★★ | |
| let allSectionContent: string[] = []; | |
| if (designTask) { | |
| const designPrompt = params.style | |
| ? getStyledPrompt(params.taskType!, params.style) | |
| : getDesignPrompt(params.taskType!); | |
| await this.updateProgress(taskId, 30, 'writing', '设计任务:一次性生成完整内容...'); | |
| // ★★★ 在 user message 中强制重复输出格式要求 ★★★ | |
| const formatHint = params.taskType === 'pitch_deck' | |
| ? `【关键】你必须使用 <slide title="..."> 标签输出,不要使用 <file> 标签。每个 slide 标签包含一个幻灯片。` | |
| : `【关键】你必须使用 <file name="..."> 标签输出。每个 file 标签包含一个文件。`; | |
| const designResult = await this.callNIMMissionCritical( | |
| taskId, 'expansion', | |
| [{ role: 'user', content: [ | |
| `## 任务需求`, | |
| ``, | |
| params.prompt, | |
| ``, | |
| formatHint, | |
| ``, | |
| `请严格按照 system prompt 中的输出格式要求,生成完整的内容。`, | |
| `所有内容必须包含在单个输出中,不要分多次输出。`, | |
| ].join('\n') }], | |
| 16000, | |
| designPrompt | |
| ); | |
| phases.expansion.attempts = designResult.attempts; | |
| totalAttempts += designResult.attempts; | |
| if (designResult.ttfb < FAST_MODEL_THRESHOLD) { | |
| fastModelsUsed.push(designResult.modelId); | |
| } | |
| allSectionContent = [designResult.content]; | |
| phases.expansion.sections = 1; | |
| await this.updateProgress(taskId, 80, 'writing', '设计内容生成完成'); | |
| } | |
| // ★★★ 非设计任务:分章节扩写 ★★★ | |
| const sectionTasks = designTask ? [] : sections.map((section, index) => | |
| limit(async () => { | |
| if (this.abortFlag) return ''; | |
| const startProgress = 20 + Math.floor(index / sections.length * 60); | |
| await this.updateProgress(taskId, startProgress, 'writing', | |
| `章节 ${index + 1}/${sections.length}: ${section.title}`); | |
| const isPaid = params.tier && params.tier !== 'free'; | |
| const expansionSystemPrompt = isPaid | |
| ? `你是腾讯/网易级资深系统策划,正在撰写「大师全案」。请深度扩写以下章节。 | |
| ═══════════════════════════════════════ | |
| 【反 AI 虚空废话协议 — 违反即不合格】 | |
| ═══════════════════════════════════════ | |
| 1. 禁止在线打草稿:不得出现「错误!应为」「修正:」「更正:」「实际上应该是」等自我纠错痕迹。一次成型,不回头。 | |
| 2. 禁止学术名词复读:沉没成本/损失厌恶/认知失调/心理锚点等术语,全章最多提及1次(1句解释即止),此后用「该机制」「此设计」指代。 | |
| 3. 禁止名人名言:不得引用彼得·德鲁克、乔布斯等人名言,不得使用「我们的使命是」「最危险的不是」等煽情句式。 | |
| 4. 禁止 QA 自问自答:不得使用「你可能会问」「答案是」等伪对话。 | |
| 5. 禁止模糊词:「很多」「大量」「显著」「一定」「若干」全部替换为具体数字或百分比。 | |
| 6. 禁止泛化罗列:不得出现「首先...其次...最后...」八股文结构,不得用「概述」「简介」等虚词作章节标题。 | |
| ═══════════════════════════════════════ | |
| 【强制输出 — 涉及系统/功能/机制设计时必须包含】 | |
| ═══════════════════════════════════════ | |
| A. 数据结构表 — 每个核心系统必须给出字段定义: | |
| | 字段名 | 类型 | 说明 | 示例值 | | |
| B. 客户端-服务端流程 — 涉及网络请求时必须给出: | |
| 请求: POST /api/xxx → 请求体: {} → 响应: {} → 错误码: 列表 | |
| C. 边界情况表 — 每个系统必须列出 3-5 个异常场景: | |
| | 场景 | 触发条件 | 处理方式 | | |
| D. 核心循环 — 用箭头链描述主流程: | |
| 步骤A → 步骤B → [条件判断] → 步骤C | |
| ═══════════════════════════════════════ | |
| 输出格式标签(按场景选用,禁止使用未定义标签) | |
| ═══════════════════════════════════════ | |
| 1. 高管摘要(开篇必须使用,结论先行): | |
| <exec_summary>2-3句话概括本章最核心的结论或建议</exec_summary> | |
| 2. 数据网格(核心数据/财务指标,最多4个): | |
| <metric_grid>指标 → 数值 | 指标 → 数值 | 指标 → 数值</metric_grid> | |
| 3. KPI指标横排(每章最多1次): | |
| <kpi_row>指标 → 数值 | 指标 → 数值</kpi_row> | |
| 4. 核心结论框(每章最多1次): | |
| <highlight_box title="核心结论">明确判断 + 数据依据</highlight_box> | |
| 5. 操作步骤(执行类章节): | |
| <step_box>1. 步骤名 | 具体操作 + 预期结果 + 时间节点</step_box> | |
| 6. 阶段进度: | |
| <phase_bar>第一阶段:xxx | 第二阶段:xxx | 第三阶段:xxx</phase_bar> | |
| 7. 左右对比: | |
| <comparison_box left_title="方案A" right_title="方案B">左侧内容 | 右侧内容</comparison_box> | |
| 8. 话术演示: | |
| <script_box scene="场景">对话脚本</script_box> | |
| 9. 提示框: | |
| <tip_box title="执行建议">具体建议 + 时间 + 责任角色</tip_box> | |
| 10. 警告框: | |
| <warn_box>风险描述 + 规避方案</warn_box> | |
| 11. 检查清单: | |
| <checklist title="交付标准">[x] 已完成 [ ] 待完成</checklist> | |
| 12. 大数字突出: | |
| <number_card label="指标名" value="数值" note="说明"> | |
| 13. 数据表格(Markdown): | |
| | 维度 | 现状 | 目标 | 差距 | | |
| ═══════════════════════════════════════ | |
| 内容质量标准 | |
| ═══════════════════════════════════════ | |
| ✓ 每章 800-1500 字,必须包含至少 1 个数据表格 + 1 个流程描述 | |
| ✓ 所有数字必须有单位和来源,禁止「显著提升」「一定效果」 | |
| ✓ 竞品案例必须具名(《原神》《王者荣耀》等),不用「某游戏」「某公司」 | |
| ✓ 执行建议必须有时间节点(「第1周」「上线后3天」)和责任主体 | |
| ✓ 涉及系统设计时,必须给出数据结构、接口定义、边界情况 | |
| ✓ 必须使用 3-5 种不同格式标签 | |
| 风格:${params.style || '专业严谨,数据驱动,直击要害,像腾讯/网易内部策划案'}` | |
| : `你是资深系统策划,正在撰写「灵感草案」。帮用户快速看清核心问题和可行方向。 | |
| ═══════════════════════════════════════ | |
| 【反 AI 虚空废话协议 — 违反即不合格】 | |
| ═══════════════════════════════════════ | |
| 1. 禁止自我纠错:不得出现「错误」「修正」「更正」等痕迹。一次成型。 | |
| 2. 禁止学术名词复读:术语全章最多1次,此后用「该机制」指代。 | |
| 3. 禁止名人名言和煽情句式。 | |
| 4. 禁止 QA 自问自答。 | |
| 5. 禁止模糊词:用具体数字替代「很多」「大量」「显著」。 | |
| ═══════════════════════════════════════ | |
| 输出格式标签(精简版,按需选用) | |
| ═══════════════════════════════════════ | |
| 1. 高管摘要(开篇使用):<exec_summary>1-2句核心结论</exec_summary> | |
| 2. 核心数据:<metric_grid>指标 → 数值 | 指标 → 数值</metric_grid> | |
| 3. 核心结论(每章最多1个):<highlight_box title="核心洞察">关键判断</highlight_box> | |
| 4. 注意事项:<warn_box>风险 + 规避方案</warn_box> | |
| 5. 行动建议:<step_box>1. 步骤 | 说明</step_box> | |
| 6. 对比框:<comparison_box left_title="A" right_title="B">左 | 右</comparison_box> | |
| 7. 检查清单:<checklist title="行动">[ ] 待完成</checklist> | |
| ═══════════════════════════════════════ | |
| 内容质量要求 | |
| ═══════════════════════════════════════ | |
| ✓ 每章 400-800 字,精炼不啰嗦 | |
| ✓ 每个要点 2-3 句话说清楚 | |
| ✓ 至少用 2 种格式标签 | |
| ✓ 涉及系统设计时给出核心数据结构和边界情况 | |
| ✓ 给方向的同时给出关键数字(转化率、成本、时间估算) | |
| 风格:${params.style || '简洁直接,专业务实,像资深同事给的建议'}`; | |
| // ★★★ 构建扩写用户消息:有草案时作为背景参考 ★★★ | |
| const draftReference = params.draftContent | |
| ? `\n## 背景参考(草案素材)\n\n⚠️ 以下内容仅作为背景参考和结构依据,不得照搬原文。你的任务是在此基础上重新创作一份专业全案,深度、数据、案例均需大幅扩充。\n\n${params.draftContent.slice(0, 500)}\n` | |
| : ''; | |
| const expansionResult = await this.callNIMMissionCritical( | |
| taskId, 'expansion', | |
| [{ role: 'user', content: [ | |
| `## 章节任务`, | |
| ``, | |
| `章节标题:**${section.title}**`, | |
| ``, | |
| `核心要点(需要深度覆盖):`, | |
| section.points.map((p, i) => `${i + 1}. ${p}`).join('\n'), | |
| ``, | |
| `原始用户需求(保持相关性):`, | |
| params.prompt.slice(0, 300), | |
| draftReference, | |
| `要求:`, | |
| isPaid | |
| ? `- 深度扩写,字数 800-1500字\n- 每个要点都要有数据支撑和具体案例\n- 必须使用至少 3 种格式标签\n- 禁止模糊表达,全部量化` | |
| : `- 精简扩写,字数 400-800字\n- 每个要点 2-3 句话,激发思路\n- 使用 2-3 种格式标签\n- 简洁直接`, | |
| ].join('\n') }], | |
| isPaid ? 4000 : 2000, | |
| expansionSystemPrompt | |
| ); | |
| const endProgress = 20 + Math.floor((index + 1) / sections.length * 60); | |
| await this.updateProgress(taskId, endProgress, 'writing', | |
| `章节 ${index + 1}/${sections.length}: ${section.title} (完成)`); | |
| phases.expansion.attempts += expansionResult.attempts; | |
| totalAttempts += expansionResult.attempts; | |
| if (expansionResult.ttfb < FAST_MODEL_THRESHOLD) { | |
| fastModelsUsed.push(expansionResult.modelId); | |
| } | |
| return expansionResult.content; | |
| }) | |
| ); | |
| const sectionResults = designTask ? allSectionContent : await Promise.all(sectionTasks); | |
| const validSections = sectionResults.filter(r => r.length > 0); | |
| phases.expansion.duration = Date.now() - expansionStart; | |
| phases.expansion.sections = validSections.length; | |
| phases.expansion.model = this.dispatcher.getNextModel() || 'multi'; | |
| if (!designTask) { | |
| await this.updateProgress(taskId, 80, 'writing', `生成 ${validSections.length} 个章节`); | |
| } | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // Phase 3: 质量自检(可选,设计任务跳过) | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| const fullContent = validSections.join('\n\n---\n\n'); | |
| const REVIEW_SKIP_THRESHOLD = 10000; | |
| // ★★★ 设计任务跳过 review(XML 标签会被 review 破坏)★★★ | |
| if (designTask) { | |
| console.log(`[MissionExecutor] ⚠️ 设计任务,跳过 Review`); | |
| phases.review.model = 'skipped-design-task'; | |
| phases.review.attempts = 0; | |
| } else if (fullContent.length > REVIEW_SKIP_THRESHOLD) { | |
| console.log(`[MissionExecutor] ⚠️ 内容过长(${fullContent.length}),跳过 Review`); | |
| phases.review.model = 'skipped'; | |
| phases.review.attempts = 0; | |
| } else { | |
| await this.updateProgress(taskId, 85, 'reviewing', '开始质量审查...'); | |
| const reviewStart = Date.now(); | |
| try { | |
| // ★★★ 修复:裁剪content到3000字符,maxTokens降到512 ★★★ | |
| // 模型maxContext=4096,需要给prompt留足够空间 | |
| const reviewContent = fullContent.length > 3000 | |
| ? fullContent.slice(0, 3000) + '\n...[内容已裁剪,仅审查前半部分]' | |
| : fullContent; | |
| const reviewResult = await this.callNIMMissionCritical( | |
| taskId, 'review', | |
| [{ role: 'user', content: reviewContent }], | |
| 1000, | |
| `你是腾讯/网易级文档质量审核编辑。执行以下审查: | |
| 【反 AI 废话审查 — 发现即删除/修正】 | |
| 1. 删除所有自我纠错痕迹(「错误!应为」「修正:」「更正:」「实际上应该是」) | |
| 2. 删除名人名言和煽情愿景句式(「我们的使命是」「最危险的不是」) | |
| 3. 删除 QA 自问自答(「你可能会问」「答案是」) | |
| 4. 学术名词(沉没成本/损失厌恶/认知失调等)若出现超过1次,保留首次,删除后续重复 | |
| 5. 模糊词(「很多」「大量」「显著」「一定」)替换为具体数字或删除 | |
| 【内容质量审查】 | |
| 6. 修正逻辑矛盾或前后不一致的表述 | |
| 7. 补充遗漏的关键数据占位符(格式:[待补充:XXX数据]) | |
| 8. 优化过于口语化的表达为专业文档风格 | |
| 【格式保护】 | |
| 9. 严禁删除或修改 <tag> 标签(exec_summary, metric_grid, kpi_row, highlight_box, info_box, step_box, phase_bar, comparison_box, script_box, warn_box, tip_box, checklist, number_card) | |
| 10. 严禁修改数据表格的 | 格式 | |
| 11. 输出修正后的完整内容,不加任何说明文字` | |
| ); | |
| phases.review.model = reviewResult.modelId; | |
| phases.review.attempts = reviewResult.attempts; | |
| phases.review.duration = Date.now() - reviewStart; | |
| totalAttempts += reviewResult.attempts; | |
| // ★★★ Review结果仅替换审查部分 ★★★ | |
| const reviewedContent = reviewResult.content.length > reviewContent.length * 0.5 | |
| ? reviewResult.content + (fullContent.length > 3000 ? '\n\n' + fullContent.slice(3000) : '') | |
| : fullContent; | |
| await this.redis.set(`slow_task:content:${taskId}`, reviewedContent, 'EX', 86400 * 7); | |
| await this.updateProgress(taskId, 95, 'reviewing', '审查完成'); | |
| } catch (reviewErr) { | |
| console.warn(`[MissionExecutor] Review 失败,使用原文:`, reviewErr); | |
| phases.review.model = 'fallback'; | |
| phases.review.attempts = 0; | |
| await this.redis.set(`slow_task:content:${taskId}`, fullContent, 'EX', 86400 * 7); | |
| } | |
| } | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| // 完成!写入最终状态 | |
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| const finalContent = await this.redis.get(`slow_task:content:${taskId}`) || fullContent; | |
| await this.redis.hset('slow_task:results', { | |
| [taskId]: JSON.stringify({ | |
| taskId, status: 'completed', progress: 100, phase: 'completed', | |
| detail: `任务完成 (尝试 ${totalAttempts} 次,快速模型: ${fastModelsUsed.length})`, | |
| updatedAt: Date.now(), | |
| // downloadUrl 由 processor.ts 上传 COS 后设置(真实 URL) | |
| qualityGrade: totalAttempts <= 2 ? 'S' : totalAttempts <= 4 ? 'A' : 'B', | |
| sectionsGenerated: validSections.length, | |
| duration: Date.now() - startTime, | |
| phases, totalAttempts, fastModelsUsed | |
| }) | |
| }); | |
| console.log(`[MissionExecutor] ✅ 任务完成: ${taskId}, 总尝试: ${totalAttempts}, 快速模型: ${fastModelsUsed.join(',')}`); | |
| // ★★★ 设计任务附加输出元数据 ★★★ | |
| const designMeta = designTask ? getDesignTaskOutputMeta(params.taskType as DesignTaskType) : {}; | |
| return { | |
| content: typeof finalContent === 'string' ? finalContent : JSON.stringify(finalContent), | |
| outline, sectionsGenerated: validSections.length, | |
| duration: Date.now() - startTime, phases, totalAttempts, fastModelsUsed, | |
| title: llmTitle || outline.title || '', // ★ 优先使用 LLM 生成的标题 | |
| ...designMeta, | |
| }; | |
| } catch (err: any) { | |
| const safeMessage = sanitizeErrorMessage(err?.message); | |
| console.error(`[MissionExecutor] ❌ 任务失败: ${err.message}`); | |
| await this.updateProgress(taskId, 0, 'failed', safeMessage); | |
| throw err; | |
| } | |
| } | |
| /** | |
| * 更新进度 | |
| */ | |
| private async updateProgress(taskId: string, progress: number, phase: string, detail?: string): Promise<void> { | |
| this.lastProgress = progress; | |
| this.lastPhase = phase; | |
| this.lastDetail = detail || ''; | |
| const status = progress === 100 ? 'completed' : progress === 0 ? 'failed' : 'writing'; | |
| // ★★★ 合并现有状态,避免覆盖 processor.ts 设置的 downloadUrl 等字段 ★★★ | |
| try { | |
| const existing = await this.redis.hget('slow_task:results', taskId); | |
| const prevState = existing ? (typeof existing === 'string' ? JSON.parse(existing) : existing) : {}; | |
| const report: ProgressReport = { | |
| ...prevState, | |
| taskId, | |
| status, | |
| progress, phase, detail: detail || '', updatedAt: Date.now() | |
| }; | |
| await this.redis.hset('slow_task:results', { [taskId]: JSON.stringify(report) }); | |
| if (this.progressCallback) { | |
| this.progressCallback(report); | |
| } | |
| } catch { | |
| // fallback: 直接写入(不合并) | |
| const report: ProgressReport = { | |
| taskId, | |
| status, | |
| progress, phase, detail: detail || '', updatedAt: Date.now() | |
| }; | |
| await this.redis.hset('slow_task:results', { [taskId]: JSON.stringify(report) }); | |
| if (this.progressCallback) { | |
| this.progressCallback(report); | |
| } | |
| } | |
| } | |
| /** | |
| * 注册进度回调 | |
| */ | |
| onProgress(callback: (report: ProgressReport) => void): void { | |
| this.progressCallback = callback; | |
| } | |
| /** | |
| * 中止任务 | |
| */ | |
| abort(): void { | |
| this.abortFlag = true; | |
| if (this.abortController) { | |
| this.abortController.abort(); | |
| console.warn('[MissionExecutor] 🛑 AbortController.abort() 已调用'); | |
| } | |
| } | |
| isAborted(): boolean { | |
| return this.abortFlag; | |
| } | |
| reset(): void { | |
| this.abortFlag = false; | |
| this.progressCallback = null; | |
| this.slowModelBlacklist.clear(); | |
| } | |
| /** | |
| * 执行 OpenGame 管线任务 | |
| */ | |
| private async executeOpenGame(taskId: string, params: TaskParams): Promise<TaskResult> { | |
| const startTime = Date.now(); | |
| const outputDir = `/tmp/opengame/${taskId}`; | |
| try { | |
| const result = await runOpenGamePipeline({ | |
| prompt: params.prompt, | |
| style: typeof params.style === 'string' ? params.style : params.style?.name, | |
| complexity: 'medium', | |
| tier: params.tier as 'free' | 'pro' | 'enterprise' | undefined, | |
| outputDir, | |
| onProgress: (report) => { | |
| this.updateProgress(taskId, report.progress, report.stage, report.detail).catch(() => {}); | |
| }, | |
| }); | |
| const duration = Date.now() - startTime; | |
| const content = result.code.files.map(f => `// === ${f.path} ===\n${f.content}`).join('\n\n'); | |
| // 写入 Redis | |
| await this.redis.set(`slow_task:content:${taskId}`, content, 'EX', 86400 * 7); | |
| await this.redis.hset('slow_task:results', { | |
| [taskId]: JSON.stringify({ | |
| taskId, | |
| status: 'completed', | |
| progress: 100, | |
| phase: 'completed', | |
| detail: `OpenGame 完成: ${result.filesWritten.length} 个文件, 验证 ${result.verification.passed ? '通过' : '未通过'}`, | |
| updatedAt: Date.now(), | |
| qualityGrade: result.verification.passed ? 'A' : 'B', | |
| }), | |
| }); | |
| return { | |
| content, | |
| outline: { | |
| title: result.gdd.metadata.title, | |
| sections: result.gdd.entities.map(e => ({ title: e.name, points: e.behaviors })), | |
| }, | |
| sectionsGenerated: result.code.files.length, | |
| duration, | |
| phases: { | |
| planning: { duration: 0, model: 'pipeline', attempts: 1 }, | |
| expansion: { duration: 0, model: 'pipeline', sections: result.code.files.length, attempts: 1 }, | |
| review: { duration: 0, model: 'pipeline', attempts: result.verification.rounds }, | |
| }, | |
| totalAttempts: result.verification.rounds, | |
| fastModelsUsed: ['opengame-pipeline'], | |
| outputType: 'zip', | |
| fileExtension: 'zip', | |
| contentType: 'application/zip', | |
| }; | |
| } catch (err: any) { | |
| const safeMessage = sanitizeErrorMessage(err?.message); | |
| await this.updateProgress(taskId, 0, 'failed', safeMessage); | |
| throw err; | |
| } | |
| } | |
| /** | |
| * 清洗 HTML 内容:移除 style/script/head 标签及其内容,再剥离剩余标签 | |
| */ | |
| private cleanHtmlContent(html: string): string { | |
| return html | |
| .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') | |
| .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') | |
| .replace(/<head[^>]*>[\s\S]*<\/head>/gi, '') | |
| .replace(/<!--[\s\S]*?-->/g, '') | |
| .replace(/<[^>]*>/g, ' ') | |
| .replace(/&[a-z]+;/gi, ' ') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| /** | |
| * 从草案提取大纲 | |
| */ | |
| private extractOutlineFromDraft(draft: string): TaskOutline { | |
| const sections: Array<{ title: string; points: string[] }> = []; | |
| // ★★★ 关键修复:先移除 style/script/head 标签内容,再提取标题 ★★★ | |
| const cleanedForStructure = draft | |
| .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') | |
| .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') | |
| .replace(/<head[^>]*>[\s\S]*<\/head>/gi, '') | |
| .replace(/<!--[\s\S]*?-->/g, ''); | |
| const mdHeadingRegex = /^##\s+(.+)$/gm; | |
| let mdMatch; | |
| while ((mdMatch = mdHeadingRegex.exec(cleanedForStructure)) !== null) { | |
| sections.push({ title: mdMatch[1].trim(), points: ['延续草案风格'] }); | |
| } | |
| if (sections.length === 0) { | |
| const htmlHeadingRegex = /<h2[^>]*>([\s\S]*?)<\/h2>/gi; | |
| let htmlMatch; | |
| while ((htmlMatch = htmlHeadingRegex.exec(cleanedForStructure)) !== null) { | |
| sections.push({ title: htmlMatch[1].replace(/<[^>]*>/g, '').trim(), points: ['延续草案风格'] }); | |
| } | |
| } | |
| if (sections.length === 0) { | |
| sections.push({ title: '主体内容', points: ['详细阐述'] }); | |
| } | |
| const titleMatch = cleanedForStructure.match(/^#\s+(.+)$/m) || cleanedForStructure.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i); | |
| const docTitle = titleMatch ? titleMatch[1].replace(/<[^>]*>/g, '').trim() : '策划方案'; | |
| return { title: docTitle, sections, metadata: { source: 'draft' } }; | |
| } | |
| } | |
| export function createTaskExecutor(dispatcher: ModelDispatcher, redis: Redis): TaskExecutor { | |
| return new TaskExecutor(dispatcher, redis); | |
| } |