#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const MODES = new Set(['smoke', 'live-subset', 'full-matrix', 'video-ab', 'customer-effect']); function main() { const args = parseArgs(process.argv.slice(2)); const mode = args.mode || process.env.TIHAO_LONGRUN_MODE || 'full-matrix'; if (!MODES.has(mode)) throw new Error(`Unknown mode: ${mode}`); const outputDir = path.resolve(args.output || process.env.TIHAO_LONGRUN_READINESS_OUTPUT || path.join(ROOT, 'outputs', `long-run-readiness-${Date.now()}`)); const preflight = readJsonArg(args.preflight || process.env.TIHAO_PREFLIGHT_SUMMARY || latestSummaryPath('live-preflight-latest', 'live-preflight-summary.json')); const history = readJsonArg(args.history || process.env.TIHAO_HISTORY_AUDIT_SUMMARY || ''); const intakeReadiness = readJsonArg(args.intakeReadiness || process.env.TIHAO_INTAKE_READINESS_SUMMARY || ''); const videoReadiness = readJsonArg(args.videoReadiness || process.env.TIHAO_VIDEO_RESOURCE_READINESS_SUMMARY || ''); const proofGap = readJsonArg(args.proofGap || process.env.TIHAO_PROOF_GAP_SUMMARY || ''); const proofGapClosure = readJsonArg(args.proofGapClosure || process.env.TIHAO_PROOF_GAP_CLOSURE_SUMMARY || ''); const strict = Boolean(args.strict || process.env.TIHAO_LONGRUN_READINESS_STRICT === 'true'); const summary = buildReadiness({ mode, preflight, history, intakeReadiness, videoReadiness, proofGap, proofGapClosure }); fs.mkdirSync(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, 'long-run-readiness-summary.json'); const reportPath = path.join(outputDir, 'long-run-readiness-report.md'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); console.log(JSON.stringify({ outputDir, json: jsonPath, report: reportPath, mode, ready: summary.ready, failCount: summary.counts.fail || 0, warnCount: summary.counts.warn || 0 }, null, 2)); if (strict && !summary.ready) process.exitCode = 2; } function buildReadiness({ mode, preflight, history, intakeReadiness, videoReadiness, proofGap, proofGapClosure }) { const checks = []; checks.push(check('local-acceptance-required', 'pass', '启动前必须先运行 npm run acceptance。本审计只检查长跑外部证据,不替代发布门禁。')); if (mode !== 'smoke') { checks.push(check('preflight-summary-present', preflight ? 'pass' : 'fail', '需要先运行 npm run live:preflight -- --strict,并提供 live-preflight-summary.json。')); if (preflight) { checks.push(check('live-acceptance-ready', preflight.readyForLiveAcceptance ? 'pass' : 'fail', 'live preflight 未达到 readyForLiveAcceptance。')); checks.push(check('session-token-present', preflight.live?.tokenPresent ? 'pass' : 'fail', '缺少 Parse sessionToken。')); checks.push(check('company-resolved', preflight.live?.companyResolved ? 'pass' : 'fail', '缺少 company。')); checks.push(check('parse-user-resolved', preflight.live?.parseUserResolved ? 'pass' : 'fail', 'sessionToken 未解析到 Parse 用户。')); } } if (['full-matrix', 'customer-effect'].includes(mode)) { if (intakeReadiness) { checks.push(check('intake-readiness-overall-ready', intakeReadiness.acceptance?.overallReady ? 'pass' : 'fail', '真实材料 intake readiness 未通过,说明模板仍有占位、半成品或缺客户/视频关键字段。')); checks.push(check('intake-readiness-no-failures', Number(intakeReadiness.failureCount || 0) === 0 ? 'pass' : 'fail', `intake readiness failureCount=${intakeReadiness.failureCount ?? 'unknown'}。`)); } checks.push(check('history-audit-present', history ? 'pass' : 'fail', '需要先运行 npm run history:audit -- --strict,并提供 historical-dataset-audit.json。')); if (history) { checks.push(check('history-ready-for-long-run', history.acceptance?.readyForLongRun ? 'pass' : 'fail', '历史数据还不能用于长期优化长跑。')); checks.push(check('history-min-briefs-met', history.acceptance?.minBriefsMet ? 'pass' : 'fail', '历史 Brief 数量不足。')); checks.push(check('history-customer-decision-present', history.acceptance?.allHaveCustomerDecision ? 'pass' : 'fail', '缺少客户最终选中/拒绝记录。')); checks.push(check('history-feedback-reason-present', history.acceptance?.allHaveFeedbackReason ? 'pass' : 'fail', '缺少拒绝原因或商务复核原因。')); } checks.push(check('proof-gap-present', proofGap ? 'pass' : 'warn', '建议先运行 npm run proof-gap:request,明确仍缺哪些真实证明材料。')); if (proofGap || proofGapClosure) { const unresolved = currentProofGapOpenCount({ proofGap, proofGapClosure }); const source = proofGapClosure ? 'proof-gap:closure' : 'proof-gap:request'; checks.push(check('proof-gap-unresolved-zero', unresolved === 0 ? 'pass' : 'fail', `${source} 显示仍有 ${unresolved} 个真实证明缺口未解决,不能宣称长跑/客户效果证明完成。`)); } checks.push(check('proof-gap-closure-present', proofGapClosure ? 'pass' : 'warn', '建议运行 npm run proof-gap:closure,确认真实证明缺口是否已关闭。')); if (proofGapClosure) { const openCount = Number(proofGapClosure.openCount || 0); checks.push(check('proof-gap-closure-complete', proofGapClosure.complete === true && openCount === 0 ? 'pass' : 'fail', `proof-gap:closure 仍有 openCount=${openCount},不能宣称长跑/客户效果证明完成。`)); } } if (mode === 'video-ab') { checks.push(check('video-ab-ready', preflight?.readyForVideoAb ? 'pass' : 'fail', '需要视频分析 base URL、运行时 token 和模型 doubao-seed-2-0-pro。')); checks.push(check('doubao-model', preflight?.providers?.videoModel === 'doubao-seed-2-0-pro' ? 'pass' : 'fail', '视频模型必须是 doubao-seed-2-0-pro。')); if (intakeReadiness) { checks.push(check('intake-video-ready', intakeReadiness.acceptance?.videoReady ? 'pass' : 'fail', 'intake readiness 显示视频资源模板仍未补成真实资源。')); } checks.push(check('video-resource-readiness-present', videoReadiness ? 'pass' : 'fail', '需要先运行 npm run video:resource-readiness -- --strict,并提供 video-resource-readiness-summary.json。')); if (videoReadiness) { checks.push(check('video-resource-ready-for-ab', videoReadyForAb(videoReadiness) ? 'pass' : 'fail', '视频资源前置审计未通过,不能启动真实视频 A/B。')); checks.push(check('video-real-reference-present', Number(videoReadiness.counts?.realReferenceRows || 0) > 0 ? 'pass' : 'fail', '缺少真实参考视频资源。')); checks.push(check('video-real-candidate-present', Number(videoReadiness.counts?.realCandidateRows || 0) > 0 ? 'pass' : 'fail', '缺少真实候选视频资源。')); checks.push(check('video-url-present', Number(videoReadiness.counts?.videoUrlRows || 0) > 0 ? 'pass' : 'fail', '缺少真实视频 URL。')); } } if (mode === 'customer-effect') { checks.push(check('history-ready-for-customer-effect-proof', history?.acceptance?.readyForCustomerEffectProof ? 'pass' : 'fail', '需要人工补号量基线才能证明人工补号减少。')); checks.push(check('manual-supplement-baseline-present', history?.acceptance?.allHaveManualSupplementBaseline ? 'pass' : 'fail', '缺少人工补号量基线。')); } const counts = countStatuses(checks); const ready = (counts.fail || 0) === 0; return { generatedAt: new Date().toISOString(), mode, ready, counts, checks, nextCommands: buildNextCommands(mode), boundaries: buildBoundaries(mode, { preflight, history, videoReadiness, proofGap, proofGapClosure }) }; } function check(name, status, note) { return { name, status, note: status === 'pass' ? '' : note }; } function buildNextCommands(mode) { if (mode === 'smoke') return ['npm run acceptance', 'npm run overnight:quality']; if (mode === 'live-subset') return [ 'npm run live:preflight -- --strict', '设置 TIHAO_OVERNIGHT_LIVE=true、TIHAO_OVERNIGHT_MAX_RUNS=2 后运行 npm run overnight:quality' ]; if (mode === 'video-ab') return [ 'npm run live:preflight -- --strict', 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest', 'npm run video:resource-readiness -- --input --output <视频资源审计输出目录> --strict', 'npm run acceptance:video-ab' ]; if (mode === 'customer-effect') return [ 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest', 'npm run history:audit -- --input --output <输出目录> --strict', 'npm run review:metrics -- --input <已标注CSV> --strict', 'npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit --current-manual-supplement-count <本轮人工补号量> --output <输出目录> --strict', 'npm run proof-gap:request -- --output outputs\\proof-gap-request-latest', 'npm run proof-gap:closure -- --output outputs\\proof-gap-closure-latest', 'npm run evidence:index -- --output outputs\\evidence-index-latest' ]; return [ 'npm run acceptance', 'npm run live:preflight -- --strict', 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest', 'npm run history:audit -- --input --output <输出目录> --strict', 'npm run proof-gap:request -- --output outputs\\proof-gap-request-latest', 'npm run proof-gap:closure -- --output outputs\\proof-gap-closure-latest', 'npm run overnight:quality' ]; } function buildBoundaries(mode, { preflight, history, videoReadiness, proofGap, proofGapClosure }) { const boundaries = []; if (mode !== 'smoke' && !preflight) boundaries.push('没有 live preflight summary 时,不得启动真实 live 长跑。'); if (['full-matrix', 'customer-effect'].includes(mode) && !history) boundaries.push('没有历史数据审计时,不得宣称客户选中率或人工补号量改善。'); if (mode === 'video-ab') boundaries.push('只有 acceptance:video-ab 通过后,才能说视频分析证明了提号结果提升。'); if (mode === 'video-ab' && !videoReadiness) boundaries.push('没有 video-resource-readiness summary 时,不得启动真实视频 A/B。'); if (mode === 'customer-effect') boundaries.push('客户选中率和人工补号量减少必须用真实客户选择与人工补号量基线证明。'); if ((proofGap || proofGapClosure) && currentProofGapOpenCount({ proofGap, proofGapClosure }) > 0) boundaries.push('proof-gap 仍有未解决项时,只能继续补真实材料,不能宣称长期优化目标完成。'); if (proofGapClosure && Number(proofGapClosure.openCount || 0) > 0) boundaries.push('proof-gap:closure 仍有 open 项时,不能宣称长期优化目标完成。'); return boundaries; } function currentProofGapOpenCount({ proofGap, proofGapClosure }) { if (proofGapClosure && Number.isFinite(Number(proofGapClosure.openCount))) { return Number(proofGapClosure.openCount || 0); } return Number(proofGap?.unresolvedCount || 0); } function renderReport(summary) { const lines = [ '# 提号长跑就绪度审计', '', `- 生成时间:${summary.generatedAt}`, `- 模式:${summary.mode}`, `- 是否就绪:${summary.ready ? '是' : '否'}`, '', '## 检查项', '', '| 检查项 | 状态 | 说明 |', '| --- | --- | --- |', ...summary.checks.map(item => `| ${escapeCell(item.name)} | ${item.status} | ${escapeCell(item.note || '通过')} |`), '', '## 下一步命令', '', ...summary.nextCommands.map(item => `- ${item}`), '', '## 边界', '', ...(summary.boundaries.length ? summary.boundaries.map(item => `- ${item}`) : ['- 当前模式没有额外边界。']) ]; return lines.join('\n'); } function readJsonArg(file) { if (!file) return null; const resolved = path.resolve(file); if (!fs.existsSync(resolved)) return null; return JSON.parse(fs.readFileSync(resolved, 'utf8').replace(/^\uFEFF/, '')); } function latestSummaryPath(dirName, fileName) { return path.join(ROOT, 'outputs', dirName, fileName); } function videoReadyForAb(videoReadiness) { return Boolean(videoReadiness?.acceptance?.readyForVideoAbPreflight ?? videoReadiness?.readyForVideoAbPreflight); } function countStatuses(items) { const result = {}; for (const item of items) result[item.status] = (result[item.status] || 0) + 1; return result; } function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { const raw = argv[index]; if (!raw.startsWith('--')) continue; const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[index + 1]; if (!next || next.startsWith('--')) args[key] = true; else { args[key] = next; index += 1; } } return args; } function escapeCell(value) { return String(value || '').replace(/\|/g, '/').replace(/\n/g, ' '); } function withBom(text) { return `\uFEFF${text}`; } if (require.main === module) main(); module.exports = { buildReadiness };