#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const ROOT = path.resolve(__dirname, '..'); function main() { const args = parseArgs(process.argv.slice(2)); const outputRoot = path.resolve(args.output || process.env.TIHAO_PIPELINE_OUTPUT || path.join(ROOT, 'outputs', `optimization-pipeline-${Date.now()}`)); const dryRun = Boolean(args.dryRun); const strict = Boolean(args.strict); fs.mkdirSync(outputRoot, { recursive: true }); const context = buildContext(args, outputRoot); const steps = buildSteps(context); const results = []; for (const step of steps) { if (dryRun) { results.push({ id: step.id, status: 'planned', command: step.command.join(' '), output: step.output || '' }); continue; } const result = runStep(step); results.push(result); if (strict && result.status !== 'pass') break; } const closureResult = results.find(item => item.id === 'proof-gap-closure'); const closureSummary = closureResult?.status === 'pass' ? readJson(closureResult.output) : null; const proofGapClosureClaimable = isClaimableProofGapClosure(closureSummary); const readyForClaim = results.every(item => item.status === 'pass') && Boolean(context.inputs.reviewCsv) && proofGapClosureClaimable; const summary = { generatedAt: new Date().toISOString(), outputRoot, dryRun, strict, inputs: context.inputs, counts: countStatuses(results), readyForClaim, proofGapClosure: closureSummary ? { complete: Boolean(closureSummary.complete), openCount: Number(closureSummary.openCount || 0), closedCount: Number(closureSummary.closedCount || 0), businessGapCount: Number(closureSummary.businessGapCount || 0), claimable: proofGapClosureClaimable } : null, results, nextActions: buildNextActions(results, context, { dryRun }) }; const jsonPath = path.join(outputRoot, 'optimization-pipeline-summary.json'); const reportPath = path.join(outputRoot, 'optimization-pipeline-report.md'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); console.log(JSON.stringify({ outputRoot, json: jsonPath, report: reportPath, counts: summary.counts, readyForClaim: summary.readyForClaim, nextActionCount: summary.nextActions.length }, null, 2)); if (strict && !summary.readyForClaim) process.exitCode = 2; } function buildContext(args, outputRoot) { const dataPack = resolveOptional(args.dataPack || process.env.TIHAO_DATA_PACK); const videoPack = resolveOptional(args.videoPack || process.env.TIHAO_VIDEO_PACK); const historyCsv = resolveOptional(args.historyCsv || process.env.TIHAO_HISTORY_CSV || (dataPack ? path.join(dataPack, 'history-data-template.csv') : '')); const historyDir = resolveOptional(args.historyDir || process.env.TIHAO_HISTORY_DATASET); const preflight = resolveOptional(args.preflight || process.env.TIHAO_PREFLIGHT_SUMMARY); const reviewCsv = resolveOptional(args.reviewCsv || process.env.TIHAO_REVIEW_CSV || (dataPack ? path.join(dataPack, 'manual-review-template.csv') : '')); const videoCsv = resolveOptional(args.videoCsv || process.env.TIHAO_VIDEO_CSV || (videoPack ? path.join(videoPack, 'video-resource-template.csv') : '')); const currentManualSupplementCount = args.currentManualSupplementCount || process.env.TIHAO_CURRENT_MANUAL_SUPPLEMENT_COUNT || ''; const historyMinBriefs = args.historyMinBriefs || process.env.TIHAO_HISTORY_MIN_BRIEFS || ''; const generatedHistoryDir = path.join(outputRoot, 'history-dataset'); const intakeOutput = path.join(outputRoot, 'intake-readiness'); const historyAuditOutput = path.join(outputRoot, 'history-audit'); const videoReadinessOutput = path.join(outputRoot, 'video-resource-readiness'); const reviewOutput = path.join(outputRoot, 'review-metrics'); const customerEffectOutput = path.join(outputRoot, 'customer-effect-audit'); const closureOutput = path.join(outputRoot, 'proof-gap-closure'); const readinessOutput = path.join(outputRoot, 'long-run-readiness'); const evidenceOutput = path.join(outputRoot, 'evidence-index'); return { outputRoot, historyDatasetDir: historyCsv ? generatedHistoryDir : historyDir, historyAuditJson: path.join(historyAuditOutput, 'historical-dataset-audit.json'), intakeReadinessJson: path.join(intakeOutput, 'intake-readiness-summary.json'), videoReadinessJson: path.join(videoReadinessOutput, 'video-resource-readiness-summary.json'), inputs: { dataPack, videoPack, historyCsv, historyDir, videoCsv, preflight, reviewCsv, currentManualSupplementCount, historyMinBriefs }, paths: { generatedHistoryDir, intakeOutput, historyAuditOutput, videoReadinessOutput, reviewOutput, customerEffectOutput, closureOutput, readinessOutput, evidenceOutput } }; } function buildSteps(context) { const steps = []; if (context.inputs.dataPack || context.inputs.videoPack || (context.inputs.historyCsv && context.inputs.reviewCsv && context.inputs.videoCsv)) { const intakeArgs = ['npm.cmd', 'run', 'intake:readiness', '--', '--output', context.paths.intakeOutput]; if (context.inputs.dataPack) intakeArgs.push('--data-pack', context.inputs.dataPack); else { intakeArgs.push('--history-csv', context.inputs.historyCsv); intakeArgs.push('--review-csv', context.inputs.reviewCsv); } if (context.inputs.videoPack) intakeArgs.push('--video-pack', context.inputs.videoPack); else intakeArgs.push('--video-csv', context.inputs.videoCsv); if (context.inputs.historyMinBriefs) intakeArgs.push('--min-briefs', context.inputs.historyMinBriefs); steps.push(step('intake-readiness', intakeArgs, context.intakeReadinessJson)); } if (context.inputs.historyCsv) { steps.push(step('history-from-csv', ['npm.cmd', 'run', 'history:from-csv', '--', '--input', context.inputs.historyCsv, '--output', context.paths.generatedHistoryDir], context.paths.generatedHistoryDir)); } if (context.historyDatasetDir) { const historyAuditArgs = ['npm.cmd', 'run', 'history:audit', '--', '--input', context.historyDatasetDir, '--output', context.paths.historyAuditOutput, '--strict']; if (context.inputs.historyMinBriefs) historyAuditArgs.push('--min-briefs', context.inputs.historyMinBriefs); steps.push(step('history-audit', historyAuditArgs, context.historyAuditJson)); } if (context.inputs.videoCsv) { steps.push(step('video-resource-readiness', ['npm.cmd', 'run', 'video:resource-readiness', '--', '--input', context.inputs.videoCsv, '--output', context.paths.videoReadinessOutput, '--strict'], context.videoReadinessJson)); } const readinessArgs = ['npm.cmd', 'run', 'longrun:readiness', '--', '--mode', context.inputs.reviewCsv ? 'customer-effect' : 'full-matrix', '--output', context.paths.readinessOutput]; if (context.inputs.preflight) readinessArgs.push('--preflight', context.inputs.preflight); if (context.historyDatasetDir) readinessArgs.push('--history', context.historyAuditJson); if (context.inputs.dataPack || context.inputs.videoPack || context.inputs.videoCsv) readinessArgs.push('--intake-readiness', context.intakeReadinessJson); if (context.inputs.videoCsv) readinessArgs.push('--video-readiness', context.videoReadinessJson); steps.push(step('longrun-readiness', readinessArgs, path.join(context.paths.readinessOutput, 'long-run-readiness-summary.json'))); if (context.inputs.reviewCsv) { steps.push(step('review-metrics', ['npm.cmd', 'run', 'review:metrics', '--', '--input', context.inputs.reviewCsv, '--output', context.paths.reviewOutput, '--strict'], path.join(context.paths.reviewOutput, 'review-metrics-summary.json'))); const effectArgs = ['npm.cmd', 'run', 'customer-effect:audit', '--', '--review-csv', context.inputs.reviewCsv, '--history-audit', context.historyAuditJson, '--output', context.paths.customerEffectOutput, '--strict']; if (context.inputs.currentManualSupplementCount) effectArgs.push('--current-manual-supplement-count', context.inputs.currentManualSupplementCount); steps.push(step('customer-effect-audit', effectArgs, path.join(context.paths.customerEffectOutput, 'customer-effect-summary.json'))); } const closureArgs = ['npm.cmd', 'run', 'proof-gap:closure', '--', '--output', context.paths.closureOutput]; if (context.historyDatasetDir) closureArgs.push('--history-audit', context.historyAuditJson); if (context.inputs.dataPack || context.inputs.videoPack || context.inputs.videoCsv) closureArgs.push('--intake-readiness', context.intakeReadinessJson); if (context.inputs.videoCsv) closureArgs.push('--video-readiness', context.videoReadinessJson); if (context.inputs.reviewCsv) closureArgs.push('--customer-effect', path.join(context.paths.customerEffectOutput, 'customer-effect-summary.json')); steps.push(step('proof-gap-closure', closureArgs, path.join(context.paths.closureOutput, 'proof-gap-closure-summary.json'))); steps.push(step('evidence-index', ['npm.cmd', 'run', 'evidence:index', '--', '--output', context.paths.evidenceOutput], path.join(context.paths.evidenceOutput, 'evidence-index-summary.json'))); return steps; } function step(id, command, output) { return { id, command, output }; } function runStep(step) { const [command, ...args] = step.command; const result = process.platform === 'win32' ? spawnSync('cmd.exe', ['/d', '/s', '/c', step.command.map(cmdQuote).join(' ')], { cwd: ROOT, encoding: 'utf8' }) : spawnSync(command, args, { cwd: ROOT, encoding: 'utf8' }); return { id: step.id, command: step.command.join(' '), output: step.output, status: result.status === 0 ? 'pass' : 'fail', exitCode: result.status, stdout: safeTail(result.stdout), stderr: safeTail(result.stderr) }; } function cmdQuote(value) { const text = String(value || ''); if (!/[()\s&|<>^"]/u.test(text)) return text; return `"${text.replace(/"/g, '\\"')}"`; } function renderReport(summary) { const lines = [ '# 提号优化 Pipeline 报告', '', `- 生成时间:${summary.generatedAt}`, `- 输出目录:${summary.outputRoot}`, `- dryRun:${summary.dryRun ? '是' : '否'}`, `- strict:${summary.strict ? '是' : '否'}`, `- 是否可宣称业务效果:${summary.readyForClaim ? '是' : '否'}`, `- proofGapClosure.complete:${summary.proofGapClosure?.complete ? 'true' : 'false'}`, `- proofGapClosure.openCount:${summary.proofGapClosure?.openCount ?? 'unknown'}`, '', '## 输入', '', ...Object.entries(summary.inputs).map(([key, value]) => `- ${key}: ${value || '未提供'}`), '', '## 步骤', '', '| 步骤 | 状态 | 输出 | 命令 |', '| --- | --- | --- | --- |', ...summary.results.map(item => `| ${escapeCell(item.id)} | ${item.status} | ${escapeCell(item.output || '')} | ${escapeCell(item.command)} |`), '', '## 下一步动作', '', summary.nextActions.length ? '| 优先级 | 负责人 | 动作 | 触发步骤 | 执行命令 | 预期产物 | 通过标准 |\n| --- | --- | --- | --- | --- | --- | --- |\n' + summary.nextActions.map(item => `| ${item.priority} | ${escapeCell(item.owner)} | ${escapeCell(item.title)} | ${escapeCell(item.step)} | \`${escapeCell(item.command)}\` | ${escapeCell(item.expectedArtifact)} | ${escapeCell(item.acceptance)} |`).join('\n') : '- 暂无后续动作。', '', '## 边界', '', '- pipeline 只编排已有脚本,不自动写入 token,也不默认启动真实 live 长跑。', '- 没有客户选择、历史人工补号基线或本轮人工补号量时,不能宣称客户效果完成。', '- `readyForClaim=false` 时,只能说明链路入口或局部验收完成。' ]; return lines.join('\n'); } function buildNextActions(results, context, { dryRun }) { if (dryRun) { return [{ priority: 1, owner: '技术/AI', title: '确认输入后执行真实 pipeline', step: 'dry-run', command: rerunCommandWithoutDryRun(context), expectedArtifact: path.join(context.outputRoot, 'optimization-pipeline-summary.json'), acceptance: 'dryRun=false,且关键步骤不再是 planned。' }]; } const actions = []; for (const result of results) { if (result.status === 'pass') continue; actions.push(actionForFailedStep(result.id, context, result)); if (context.inputs.currentManualSupplementCount && result.id === 'customer-effect-audit') continue; } if (!context.inputs.currentManualSupplementCount && context.inputs.reviewCsv) { actions.push({ priority: 2, owner: '商务', title: '补本轮人工补号量', step: 'customer-effect-audit', command: '补齐 --current-manual-supplement-count <本轮人工补号量> 后重跑 optimization:pipeline', expectedArtifact: '<客户效果输出目录>/customer-effect-summary.json', acceptance: 'customer-effect:audit 输出 manualSupplementReductionRate,且 strict 审计通过。' }); } if (!actions.length && !results.every(item => item.status === 'pass')) { actions.push({ priority: 99, owner: '技术/AI', title: '检查 pipeline 未通过步骤', step: 'pipeline', command: '打开 optimization-pipeline-report.md,按首个 fail 步骤处理。', expectedArtifact: 'optimization-pipeline-summary.json', acceptance: '所有 pipeline 步骤 status=pass。' }); } const closureResult = results.find(item => item.id === 'proof-gap-closure'); const closureSummary = closureResult?.status === 'pass' ? readJson(closureResult.output) : null; if (!actions.length && closureSummary && !isClaimableProofGapClosure(closureSummary)) { actions.push({ priority: 2, owner: '技术/AI', title: '关闭真实证明缺口', step: 'proof-gap-closure', command: '按 proof-gap-closure-report.md 补齐 open 项后重跑 optimization:pipeline。', expectedArtifact: closureResult.output || '<输出目录>/proof-gap-closure/proof-gap-closure-summary.json', acceptance: 'proof-gap:closure complete=true、openCount=0、closedCount>=5,且 businessGapCount>=5。' }); } if (!actions.length && results.every(item => item.status === 'pass') && !context.inputs.reviewCsv) { actions.push({ priority: 3, owner: '商务', title: '补人工复核 CSV 后验证客户效果', step: 'review-metrics', command: 'npm run optimization:pipeline -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --current-manual-supplement-count <本轮人工补号量> --output <输出目录> --strict', expectedArtifact: '<输出目录>/customer-effect-audit/customer-effect-summary.json', acceptance: 'review:metrics 和 customer-effect:audit 均通过。' }); } return dedupeActions(actions).sort((a, b) => a.priority - b.priority); } function actionForFailedStep(stepId, context, result) { const base = { priority: 10, owner: '技术/AI', title: `修复 ${stepId}`, step: stepId, command: result.command, expectedArtifact: result.output || '', acceptance: `${stepId} status=pass。` }; const map = { 'intake-readiness': { priority: 1, owner: '商务/投放', title: '修复真实材料收集包', command: '按 intake-readiness-report.md 的问题修正 data-pack/video-pack 后重跑 optimization:pipeline。', acceptance: 'intake-readiness overallReady=true 且 failureCount=0。' }, 'history-from-csv': { priority: 2, owner: '商务', title: '修复历史数据 CSV', command: '检查 history-data-template.csv 表头、编码和必填字段后重跑 history:from-csv。', acceptance: 'history-dataset JSON 成功生成。' }, 'history-audit': { priority: 3, owner: '商务', title: '补齐历史数据审计字段', command: '补齐真实 Brief、参考链接、人工名单、客户选择、拒绝原因和历史人工补号量基线后重跑 history:audit --strict。', acceptance: 'historical-dataset-audit readyForCustomerEffectProof=true。' }, 'video-resource-readiness': { priority: 4, owner: '商务/投放', title: '补齐真实视频资源', command: '补齐真实参考视频、真实候选视频、视频 URL、封面、ASR、帧图或正文证据后重跑 video:resource-readiness --strict。', acceptance: 'video-resource-readiness readyForVideoAbPreflight=true。' }, 'longrun-readiness': { priority: 5, owner: '技术/AI', title: '修复长跑前门禁', command: '根据 long-run-readiness-report.md 补 live preflight、history audit、intake readiness、video readiness 或 proof gap 后重跑 longrun:readiness。', acceptance: 'longrun:readiness ready=true 且 fail=0。' }, 'review-metrics': { priority: 6, owner: '商务', title: '补齐人工复核标注', command: '补齐人工复核标签、客户选择和负样本归因后重跑 review:metrics --strict。', acceptance: 'review:metrics overallPass=true,负样本归因覆盖率=100%。' }, 'customer-effect-audit': { priority: 7, owner: '商务', title: '补齐客户效果证明', command: '补齐客户最终选择、历史人工补号量基线和本轮人工补号量后重跑 customer-effect:audit --strict。', acceptance: 'customer-effect:audit overallPass=true,客户选中率和人工补号减少率达标。' }, 'evidence-index': { priority: 20, owner: '技术/AI', title: '刷新证据台账', command: 'npm run evidence:index -- --output outputs\\evidence-index-latest', acceptance: 'evidence-index-summary.json 成功生成,且不把 smoke 当业务证明。' } }; return { ...base, ...(map[stepId] || {}) }; } function rerunCommandWithoutDryRun(context) { const parts = ['npm run optimization:pipeline --']; if (context.inputs.dataPack) parts.push(`--data-pack ${context.inputs.dataPack}`); if (context.inputs.videoPack) parts.push(`--video-pack ${context.inputs.videoPack}`); if (!context.inputs.dataPack && context.inputs.historyCsv) parts.push(`--history-csv ${context.inputs.historyCsv}`); if (!context.inputs.dataPack && context.inputs.reviewCsv) parts.push(`--review-csv ${context.inputs.reviewCsv}`); if (context.inputs.historyMinBriefs) parts.push(`--history-min-briefs ${context.inputs.historyMinBriefs}`); if (context.inputs.currentManualSupplementCount) parts.push(`--current-manual-supplement-count ${context.inputs.currentManualSupplementCount}`); parts.push(`--output ${context.outputRoot}`); if (context.strict) parts.push('--strict'); return parts.join(' '); } function dedupeActions(actions) { const seen = new Set(); return actions.filter(action => { const key = `${action.owner}|${action.title}|${action.step}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function countStatuses(results) { return results.reduce((acc, item) => { acc[item.status] = (acc[item.status] || 0) + 1; return acc; }, {}); } 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 resolveOptional(value) { return value ? path.resolve(value) : ''; } function safeTail(value) { return String(value || '').slice(-1200); } function readJson(file) { if (!file || !fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return null; } } function isClaimableProofGapClosure(summary) { return Boolean( summary?.complete === true && Number(summary?.openCount || 0) === 0 && Number(summary?.closedCount || 0) >= 5 && Number(summary?.businessGapCount || 0) >= 5 ); } function escapeCell(value) { return String(value || '').replace(/\|/g, '/').replace(/\r?\n/g, ' '); } function withBom(text) { return `\uFEFF${text}`; } if (require.main === module) { try { main(); } catch (error) { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); } } module.exports = { buildContext, buildSteps, isClaimableProofGapClosure };