#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const OUTPUTS = path.join(ROOT, 'outputs'); function main() { const args = parseArgs(process.argv.slice(2)); const outputDir = path.resolve(args.output || process.env.TIHAO_PROOF_GAP_OUTPUT || path.join(OUTPUTS, `proof-gap-request-${Date.now()}`)); const summary = buildProofGapRequest({ root: ROOT, handoffPath: args.handoff ? path.resolve(args.handoff) : latestFile(OUTPUTS, 'optimization-handoff-summary.json'), evidencePath: args.evidence ? path.resolve(args.evidence) : latestFile(OUTPUTS, 'evidence-index-summary.json'), videoReadinessPath: args.videoReadiness ? path.resolve(args.videoReadiness) : latestFile(OUTPUTS, 'video-resource-readiness-summary.json'), closurePath: args.closure ? path.resolve(args.closure) : latestFile(OUTPUTS, 'proof-gap-closure-summary.json') }); fs.mkdirSync(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, 'proof-gap-request-summary.json'); const reportPath = path.join(outputDir, 'proof-gap-request-report.md'); const csvPath = path.join(outputDir, 'proof-gap-request-table.csv'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); writeCsv(csvPath, summary.rows); console.log(JSON.stringify({ outputDir, json: jsonPath, report: reportPath, csv: csvPath, complete: summary.complete, gapCount: summary.rows.length, unresolvedCount: summary.unresolvedCount }, null, 2)); } function buildProofGapRequest({ root, handoffPath, evidencePath, videoReadinessPath, closurePath }) { const handoff = readJson(handoffPath) || {}; const evidence = readJson(evidencePath) || {}; const videoReadiness = readJson(videoReadinessPath) || {}; const closure = readJson(closurePath) || {}; const rows = buildRows({ handoff, evidence, videoReadiness, closure }); return { generatedAt: new Date().toISOString(), root, complete: false, sources: { handoff: rel(root, handoffPath), evidence: rel(root, evidencePath), videoReadiness: rel(root, videoReadinessPath), closure: rel(root, closurePath) }, observed: { statusCounts: handoff.statusCounts || {}, evidenceCounts: evidence.counts || handoff.evidenceCounts || {}, videoReadinessReady: Boolean(videoReadiness.acceptance?.readyForVideoAbPreflight), videoReadinessFailureCount: videoReadiness.failureCount ?? null, proofGapClosureComplete: Boolean(closure.complete), proofGapClosureOpenCount: closure.openCount ?? null }, unresolvedCount: rows.filter(row => row.status !== 'ready').length, rows, guardrails: [ '不要在 CSV、报告或聊天中写入 sessionToken、模型 token、Authorization、npm token 或内部鉴权参数。', 'sample、smoke、模板、dry-run 和 provider fallback 不能作为客户效果证明。', '视频资源就绪只代表可以启动视频 A/B,不能替代 acceptance:video-ab。', '客户效果必须由 customer-effect:audit 证明,缺客户选择或人工补号基线时不得宣称完成。' ] }; } function buildRows({ handoff, evidence, videoReadiness, closure }) { const statusCounts = handoff.statusCounts || {}; const videoReady = Boolean(videoReadiness.acceptance?.readyForVideoAbPreflight); const realCandidateRows = Number(videoReadiness.counts?.realCandidateRows || 0); const rows = [ row({ id: 'historical-dataset', owner: '商务', status: 'missing_real_data', gap: '真实历史 Brief、人工最终名单、客户选择、拒绝原因和历史人工补号量基线未完成独立审计。', required: '按 data-intake-pack 填写 history-data-template.csv,并包含 5-10 个真实历史 Brief。', templateCommand: 'npm run data:intake-template -- --output outputs\\data-intake-pack-latest', templateArtifact: 'outputs\\data-intake-pack-latest\\history-data-template.csv;outputs\\data-intake-pack-latest\\manual-review-template.csv', verify: 'npm run history:from-csv -- --input <历史数据CSV> --output ;npm run history:audit -- --input --output <历史审计输出目录> --strict', pass: 'history:audit 显示 readyForCustomerEffectProof=true,且客户选择、拒绝原因、历史人工补号量基线齐全。' }), row({ id: 'video-real-candidate', owner: '商务/投放', status: videoReady ? 'ready' : 'missing_real_data', gap: videoReady ? '视频 A/B 前置资源已具备。' : `视频资源 readiness 未通过;当前真实候选视频行=${realCandidateRows}。`, required: '补齐至少一个真实参考视频和一个真实候选视频,提供视频 URL,并补封面、ASR、帧图或正文证据之一。', templateCommand: 'npm run video:intake-template -- --output outputs\\video-intake-pack-latest', templateArtifact: 'outputs\\video-intake-pack-latest\\video-resource-template.csv', verify: 'npm run video:resource-readiness -- --input --output <视频资源审计输出目录> --strict', pass: 'readyForVideoAbPreflight=true,failureCount=0。' }), row({ id: 'video-ab-live-proof', owner: '技术/AI', status: 'ready_not_proven', gap: '视频 A/B 脚本和 smoke 已就绪,但缺真实 provider + 真实视频资源下的 live 结果。', required: '配置真实 sessionToken、company、VOC social provider、视频分析 provider、doubao-seed-2-0-pro 模型和真实参考视频链接。', templateCommand: 'npm run video:intake-template -- --output outputs\\video-intake-pack-latest', templateArtifact: 'outputs\\video-intake-pack-latest\\video-resource-template.csv', verify: 'npm run acceptance:video-ab', pass: '真实参考资源、真实视频 URL、封面/ASR/帧图、provider ok、证据卡、Top10 非退化、强推荐不下降全部通过。' }), row({ id: 'live-provider-overnight-proof', owner: '技术/AI', status: 'blocked_by_external_data', gap: '真实 live/provider 长跑尚无完整通过证明。', required: '准备有效 sessionToken、company、provider 配置、真实历史数据和长跑策略矩阵。', templateCommand: 'npm run data:intake-template -- --output outputs\\data-intake-pack-latest', templateArtifact: 'outputs\\data-intake-pack-latest\\history-data-template.csv', verify: 'npm run longrun:readiness;npm run optimization:pipeline 或 overnight live 矩阵命令', pass: 'liveEnabled=true,failureCount=0,failedGateCount=0,overallPass=true,且严格 provider 门禁按需开启。' }), row({ id: 'manual-review-and-customer-effect', owner: '商务', status: 'blocked_by_external_data', gap: '缺本轮商务复核、客户最终选择和本轮人工补号量,无法证明客户效果。', required: '按 manual-review-template.csv 标注人工复核标签、客户选择、归因类型、反馈原因、本轮人工补号量。', templateCommand: 'npm run data:intake-template -- --output outputs\\data-intake-pack-latest', templateArtifact: 'outputs\\data-intake-pack-latest\\manual-review-template.csv', verify: 'npm run review:metrics -- --input <已标注CSV> --output <复核指标输出目录> --strict;npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict', pass: '客户选中率、参考链路客户选中率、历史人工补号基线、本轮人工补号量和人工补号减少率全部通过。' }) ]; if (Number(statusCounts.ready_not_proven || 0) === 0 && Number(statusCounts.blocked_by_external_data || 0) === 0) { rows.push(row({ id: 'status-audit', owner: 'AI', status: 'ready', gap: '状态审计没有 ready_not_proven 或 blocked_by_external_data。', required: '维持当前证据链。', templateCommand: '无需模板', templateArtifact: '无', verify: 'npm run optimization:status', pass: 'complete=true。' })); } return applyClosureStatuses(rows, closure); } function applyClosureStatuses(rows, closure) { const closureRows = Array.isArray(closure?.rows) ? closure.rows : []; if (!closureRows.length) return rows; const closedById = new Map( closureRows .filter(row => row && row.status === 'closed' && row.id) .map(row => [row.id, row]) ); return rows.map(item => { const closed = closedById.get(item.id); if (!closed) return item; return { ...item, status: 'ready', gap: `proof-gap:closure 已关闭:${closed.evidence || item.gap}`, required: `${item.required} 当前 closure 状态为 closed,保留本行作为证据链入口。` }; }); } function row(value) { return { id: value.id, owner: value.owner, status: value.status, gap: value.gap, required: value.required, templateCommand: value.templateCommand || '', templateArtifact: value.templateArtifact || '', verify: value.verify, pass: value.pass }; } function renderReport(summary) { const lines = [ '# 提号真实证明缺口请求包', '', `- 生成时间:${summary.generatedAt}`, `- 是否完成长期目标:否`, `- 未解决缺口数:${summary.unresolvedCount}`, '', '## 当前观测', '', `- status.passed:${summary.observed.statusCounts.passed || 0}`, `- status.ready_not_proven:${summary.observed.statusCounts.ready_not_proven || 0}`, `- status.blocked_by_external_data:${summary.observed.statusCounts.blocked_by_external_data || 0}`, `- evidence.real_evidence:${summary.observed.evidenceCounts.real_evidence || 0}`, `- evidence.smoke_or_local:${summary.observed.evidenceCounts.smoke_or_local || 0}`, `- evidence.not_business_proof:${summary.observed.evidenceCounts.not_business_proof || 0}`, `- video.readyForVideoAbPreflight:${summary.observed.videoReadinessReady ? 'true' : 'false'}`, `- video.failureCount:${summary.observed.videoReadinessFailureCount ?? 'unknown'}`, `- closure.complete:${summary.observed.proofGapClosureComplete ? 'true' : 'false'}`, `- closure.openCount:${summary.observed.proofGapClosureOpenCount ?? 'unknown'}`, '', '## 请求明细', '', '| ID | 负责人 | 状态 | 缺口 | 需要补齐 | 模板命令 | 模板产物 | 验证命令 | 通过标准 |', '| --- | --- | --- | --- | --- | --- | --- | --- | --- |', ...summary.rows.map(item => `| ${item.id} | ${item.owner} | ${item.status} | ${escapeCell(item.gap)} | ${escapeCell(item.required)} | \`${escapeCell(item.templateCommand)}\` | ${escapeCell(item.templateArtifact)} | \`${escapeCell(item.verify)}\` | ${escapeCell(item.pass)} |`), '', '## 边界', '', ...summary.guardrails.map(item => `- ${item}`) ]; return lines.join('\n'); } function writeCsv(file, rows) { const header = ['缺口ID', '负责人', '状态', '当前缺口', '需要补齐', '模板命令', '模板产物', '验证命令', '通过标准']; const body = rows.map(item => [item.id, item.owner, item.status, item.gap, item.required, item.templateCommand, item.templateArtifact, item.verify, item.pass]); fs.writeFileSync(file, withBom([header, ...body].map(row => row.map(csvCell).join(',')).join('\n')), 'utf8'); } function latestFile(outputsDir, fileName) { if (!fs.existsSync(outputsDir)) return ''; return fs.readdirSync(outputsDir, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => path.join(outputsDir, entry.name, fileName)) .filter(file => fs.existsSync(file)) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || ''; } function readJson(file) { if (!file || !fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return null; } } 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 rel(root, file) { return file ? path.relative(root, file).replace(/\\/g, '/') : ''; } function escapeCell(value) { return String(value ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' '); } function csvCell(value) { const text = String(value ?? ''); return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function withBom(text) { return `\uFEFF${text}`; } if (require.main === module) main(); module.exports = { buildProofGapRequest, renderReport };