#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'real-proof-closure-work-order-latest'); function main() { const args = parseArgs(process.argv.slice(2)); const outputsDir = path.resolve(args.outputs || path.join(ROOT, 'outputs')); const outputDir = path.resolve(args.output || DEFAULT_OUTPUT); const summary = buildRealProofClosureWorkOrder({ root: ROOT, outputsDir }); fs.mkdirSync(outputDir, { recursive: true }); const ownerArtifacts = writeOwnerArtifacts(outputDir, summary.workOrders); summary.ownerArtifacts = ownerArtifacts; const summaryPath = path.join(outputDir, 'real-proof-closure-work-order-summary.json'); const reportPath = path.join(outputDir, 'real-proof-closure-work-order.md'); const csvPath = path.join(outputDir, 'real-proof-closure-work-order.csv'); fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); fs.writeFileSync(csvPath, withBom(renderCsv(summary.workOrders)), 'utf8'); console.log(JSON.stringify({ outputDir, summary: summaryPath, report: reportPath, csv: csvPath, ownerArtifacts: ownerArtifacts.length, passed: summary.passed, complete: summary.complete, workOrderCount: summary.workOrderCount, proofGapOpenCount: summary.sourceState.proofGapOpenCount, canCloseProofGap: summary.canCloseProofGap }, null, 2)); if (args.strict && !summary.passed) process.exitCode = 1; } function buildRealProofClosureWorkOrder({ root, outputsDir }) { const sources = { intakeReadiness: latestSummary(outputsDir, 'intake-readiness-summary.json', 'intake-readiness-latest'), videoReadiness: latestSummary(outputsDir, 'video-resource-readiness-summary.json', 'video-resource-readiness-latest'), proofGapClosure: latestSummary(outputsDir, 'proof-gap-closure-summary.json', 'proof-gap-closure-latest'), optimizationCompletion: latestSummary(outputsDir, 'optimization-completion-summary.json', 'optimization-completion-latest'), localSeedWorklist: latestSummary(outputsDir, 'local-seed-to-intake-worklist-summary.json', 'local-seed-to-intake-worklist-latest'), nextActions: latestSummary(outputsDir, 'business-proof-next-actions-summary.json', 'business-proof-next-actions-latest') }; const intake = sources.intakeReadiness.json || {}; const video = sources.videoReadiness.json || {}; const closure = sources.proofGapClosure.json || {}; const completion = sources.optimizationCompletion.json || {}; const seed = sources.localSeedWorklist.json || {}; const nextActions = sources.nextActions.json || {}; const workOrders = dedupeWorkOrders([ ...workOrdersFromIntake(intake, seed), ...workOrdersFromVideoReadiness(video, seed), ...workOrdersFromProofGapClosure(closure, nextActions, seed), ...workOrdersFromOptimizationCompletion(completion) ]).sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id)); const ownerGroups = groupByOwner(workOrders); const proofGapOpenCount = Number(closure.openCount ?? completion.proofGapOpenCount ?? nextActions.sourceState?.proofGapOpenCount ?? 0); const complete = proofGapOpenCount === 0 && Boolean(closure.complete) && Boolean(completion.complete) && Boolean(completion.readyForClaim); const passed = workOrders.every(item => item.owner && item.targetFile && item.targetFields.length && item.requiredAction && item.acceptanceCommand && item.acceptance && item.requiresHumanRealMaterial === true && item.directCustomerProof === false && item.canCloseProofGap === false ) && ( complete || workOrders.length >= Math.max(5, Number(intake.failureCount || 0), Number(closure.openCount || 0)) ); return { generatedAt: new Date().toISOString(), root, outputsDir, passed, complete, proofLevel: 'not_business_proof', materialType: 'real_proof_closure_work_order', directCustomerProof: false, canCloseProofGap: false, requiresHumanRealMaterial: true, workOrderCount: workOrders.length, ownerGroupCount: ownerGroups.length, seedSuggestionCount: workOrders.filter(item => item.seedSuggestion).length, targetFiles: Array.from(new Set(workOrders.map(item => item.targetFile).filter(Boolean))), ownerGroups, sourceState: { intakeOverallReady: Boolean(intake.acceptance?.overallReady), intakeFailureCount: Number(intake.failureCount || 0), videoReadyForAbPreflight: Boolean(video.acceptance?.readyForVideoAbPreflight), videoFailureCount: Number(video.failureCount || 0), realCandidateRows: Number(video.counts?.realCandidateRows ?? intake.video?.counts?.realCandidateRows ?? 0), proofGapComplete: Boolean(closure.complete), proofGapOpenCount, proofGapClosedCount: Number(closure.closedCount || 0), optimizationReadyForClaim: Boolean(completion.readyForClaim), optimizationBlockingReasonCount: Number(completion.blockingReasons?.length || completion.blockingReasonCount || 0), localSeedCanCloseProofGap: Boolean(seed.canCloseProofGap), localSeedDirectCustomerProof: Boolean(seed.directCustomerProof), localSeedHistoryDraftRows: countRows(seed.historyDraftRows, seed.counts?.historyDraftRows), localSeedCandidateSeedRows: countRows(seed.candidateSeedRows, seed.counts?.candidateSeedRows), localSeedVideoWorklistRows: countRows(seed.videoWorklistRows, seed.counts?.videoWorklistRows) }, files: { summary: 'outputs/real-proof-closure-work-order-latest/real-proof-closure-work-order-summary.json', report: 'outputs/real-proof-closure-work-order-latest/real-proof-closure-work-order.md', csv: 'outputs/real-proof-closure-work-order-latest/real-proof-closure-work-order.csv', byOwnerDir: 'outputs/real-proof-closure-work-order-latest/by-owner' }, sourceFiles: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, value.path ? rel(root, value.path) : ''])), guardrails: [ '本工单只把未关闭证明缺口拆成可填写字段、行范围和复验命令,不写入正式 intake 模板。', '本工单不证明客户效果,也不证明提号率提升或人工补号量下降。', '本地种子和 PoC 候选只能作为转写线索;客户选择、拒绝原因、历史人工补号量和本轮人工补号量必须由商务补真实记录。', '真实候选视频必须由投放补视频 URL、封面、ASR/字幕、帧图或正文证据;参考视频不能替代候选视频。', 'proof-gap.openCount 不为 0 或 optimization-completion.readyForClaim=false 时,不得宣称提号率提升、客户效果达标或人工补号量下降。', '不得在工单、CSV、报告或命令中写入 sessionToken、鉴权头、模型 token 或 npm token。' ], workOrders }; } function workOrdersFromIntake(intake, seed) { const issues = Array.isArray(intake.issues) ? intake.issues : []; return issues.map((issue, index) => { const spec = intakeSpec(issue, seed); return makeWorkOrder({ id: `intake-${issue.section || 'unknown'}-${issue.type || index + 1}`, priority: spec.priority || index + 1, owner: spec.owner, source: 'intake-readiness', gapId: spec.gapId, section: issue.section || spec.section, issueType: issue.type || '', title: spec.title, targetFile: spec.targetFile, targetFields: spec.targetFields, rowSelector: spec.rowSelector, currentEvidence: issue.message || spec.currentEvidence, missingProofRequirements: spec.missingProofRequirements, seedSuggestion: spec.seedSuggestion, requiredAction: spec.requiredAction, acceptanceCommand: spec.acceptanceCommand, acceptance: spec.acceptance }); }); } function workOrdersFromVideoReadiness(video, seed) { const issues = Array.isArray(video.issues) ? video.issues : []; return issues.map((issue, index) => { const spec = videoSpec(issue, seed); return makeWorkOrder({ id: `video-${issue.type || index + 1}`, priority: spec.priority || 30 + index, owner: spec.owner, source: 'video-resource-readiness', gapId: 'video-real-candidate', section: 'video', issueType: issue.type || '', title: spec.title, targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv', targetFields: spec.targetFields, rowSelector: spec.rowSelector, currentEvidence: issue.message || '', missingProofRequirements: [ 'acceptance.readyForVideoAbPreflight=true', 'failureCount=0', 'counts.realCandidateRows>=1' ], seedSuggestion: videoSeedSuggestion(seed), requiredAction: spec.requiredAction, acceptanceCommand: 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict', acceptance: 'readyForVideoAbPreflight=true、failureCount=0、realCandidateRows>=1。' }); }); } function workOrdersFromProofGapClosure(closure, nextActions, seed) { const rows = Array.isArray(closure.rows) ? closure.rows : []; return rows .filter(row => row.status === 'open') .map((row, index) => { const spec = gapSpec(row, nextActions, seed); return makeWorkOrder({ id: `gap-${row.id || index + 1}`, priority: spec.priority, owner: spec.owner, source: 'proof-gap-closure', gapId: row.id || '', section: spec.section, issueType: 'open-proof-gap', title: spec.title, targetFile: spec.targetFile, targetFields: spec.targetFields, rowSelector: spec.rowSelector, currentEvidence: row.evidence || '', missingProofRequirements: normalizeList(row.missingProofRequirements), seedSuggestion: spec.seedSuggestion, requiredAction: spec.requiredAction || row.next || '', acceptanceCommand: row.command || spec.acceptanceCommand, acceptance: spec.acceptance || row.required || '' }); }); } function workOrdersFromOptimizationCompletion(completion) { const reasons = Array.isArray(completion.blockingReasons) ? completion.blockingReasons : []; return reasons .filter(reason => ['optimization-pipeline', 'handoff-summary', 'business-proof-progress', 'longrun-readiness'].includes(reason.id)) .map((reason, index) => makeWorkOrder({ id: `completion-${reason.id || index + 1}`, priority: 80 + index, owner: '技术/AI', source: 'optimization-completion', gapId: reason.id || '', section: 'audit', issueType: 'blocked-gate', title: `复验完成门槛:${reason.title || reason.id}`, targetFile: 'outputs/optimization-completion-latest/optimization-completion-summary.json', targetFields: ['complete', 'readyForClaim', 'proofGapOpenCount', 'blockingReasons'], rowSelector: reason.id || '', currentEvidence: reason.evidence || '', missingProofRequirements: normalizeList(reason.missingProofRequirements), requiredAction: '真实材料补齐并通过对应审计后,刷新 optimization:completion 和 round:refresh;不要用 smoke 或旧 pipeline 结果替代。', acceptanceCommand: reason.command || 'npm run optimization:completion -- --output outputs\\optimization-completion-latest', acceptance: reason.required || 'complete=true、readyForClaim=true、proofGapOpenCount=0。' })); } function intakeSpec(issue, seed) { const section = issue.section || ''; const type = issue.type || ''; if (section === 'history') { const base = { owner: '商务', section, gapId: 'historical-dataset', targetFile: 'outputs/data-intake-pack-latest/history-data-template.csv', acceptanceCommand: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest', acceptance: 'historyReady=true、真实历史 Brief 数 >=5、无模板占位;随后 history:audit readyForCustomerEffectProof=true。', seedSuggestion: historySeedSuggestion(seed), missingProofRequirements: ['acceptance.historyReady=true', 'history:audit readyForCustomerEffectProof=true'] }; const specs = { 'min-briefs': { priority: 1, title: '补足真实历史 Brief 数量', targetFields: ['brief编号', '项目名称', '类目', '客户原始Brief'], rowSelector: '新增至少 4 行真实历史 Brief,使 briefCount>=5。', requiredAction: '补 5-10 个真实历史 Brief;DHA seed 只能作草稿,不足以关闭历史数据缺口。' }, placeholder: { priority: 2, title: '替换历史数据模板占位内容', targetFields: ['整行'], rowSelector: '所有包含 example.com、示例、候选、待补的行。', requiredAction: '把示例项目、example.com 和待补文字替换为真实客户材料;不能用未知、0 或占位值凑数。' }, reference: { priority: 3, title: '补真实参考账号或参考视频', targetFields: ['参考账号或视频'], rowSelector: 'history-data-template.csv 每个 Brief 行。', requiredAction: '填真实参考账号主页或参考视频链接;可参考本地 reference seed,但需人工确认可访问且属于真实业务材料。', seedSuggestion: referenceSeedSuggestion(seed) }, 'manual-list': { priority: 4, title: '补人工最终名单博主和主页', targetFields: ['博主名称', '主页链接'], rowSelector: 'history-data-template.csv 每个 Brief 行。', requiredAction: '填历史人工最终提报名单中的真实博主名称和可访问主页链接;PoC 候选只能作为找回线索。' }, 'manual-baseline': { priority: 5, title: '补历史人工补号量基线', targetFields: ['历史人工补号量基线'], rowSelector: 'history-data-template.csv 每个 Brief 行。', requiredAction: '填写每个历史 Brief 当时人工补号量基线;缺该字段不能证明人工补号量下降。' }, 'customer-decision': { priority: 6, title: '补历史客户最终选择', targetFields: ['客户选择', '拒绝原因'], rowSelector: 'history-data-template.csv 每个 Brief 行。', requiredAction: '填写客户最终选中/拒绝;拒绝样本必须有拒绝原因。' } }; return { ...base, ...(specs[type] || specs.placeholder) }; } if (section === 'review') { return { priority: type === 'customer-decision' ? 12 : 11, owner: '商务', section, gapId: 'manual-review-and-customer-effect', title: type === 'customer-decision' ? '补客户最终选中/拒绝记录' : '替换人工复核模板占位内容', targetFile: 'outputs/data-intake-pack-latest/manual-review-template.csv', targetFields: type === 'customer-decision' ? ['客户选择', '归因类型', '反馈原因', '本轮人工补号量'] : ['整行', '博主名称', '主页链接', '人工复核标签'], rowSelector: type === 'customer-decision' ? 'manual-review-template.csv 至少一批真实候选行。' : '所有包含 example.com、示例、候选、待客户反馈的行。', seedSuggestion: candidateSeedSuggestion(seed), requiredAction: type === 'customer-decision' ? '补客户最终选中/拒绝、负样本归因、反馈原因和本轮人工补号量。' : '把 PoC 候选转成真实人工复核记录,补主页链接和人工复核标签。', missingProofRequirements: ['review:metrics overallPass=true', 'customer-effect:audit overallPass=true'], acceptanceCommand: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest', acceptance: 'reviewMetricsReady=true、customerEffectReady=true;随后 customer-effect:audit overallPass=true。' }; } return { priority: 20, owner: '商务/投放', section: 'video', gapId: 'video-real-candidate', title: '补视频资源真实材料', targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv', targetFields: ['资源角色', '是否真实资源', '视频链接', '封面链接', '字幕或ASR文本', '帧图链接', '内容摘要'], rowSelector: 'video-resource-template.csv 参考视频和候选视频行。', seedSuggestion: videoSeedSuggestion(seed), requiredAction: '替换视频模板占位内容,补真实参考视频和真实候选视频。', missingProofRequirements: ['acceptance.videoReady=true', 'video:resource-readiness readyForVideoAbPreflight=true'], acceptanceCommand: 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict', acceptance: 'readyForVideoAbPreflight=true、failureCount=0。' }; } function videoSpec(issue, seed) { if (issue.type === 'missing-real-candidate' || issue.type === 'real-candidate') { return { priority: 21, owner: '商务/投放', title: '补真实候选视频资源', targetFields: ['资源角色', '博主名称', '主页链接', '视频链接', '封面链接', '字幕或ASR文本', '帧图链接', '内容摘要', '是否真实资源'], rowSelector: '新增或更新至少 1 行资源角色=候选视频、是否真实资源=是。', requiredAction: '为候选账号补真实视频 URL 和至少一种封面/ASR/帧图/正文证据;参考视频不能替代候选视频。' }; } if (issue.type === 'missing-video-url' || issue.type === 'video-url') { return { priority: 22, owner: '商务/投放', title: '补真实视频 URL', targetFields: ['视频链接'], rowSelector: '所有标为真实资源的参考视频和候选视频行。', requiredAction: '填真实可访问视频 URL;不能只用封面、标题、接口 200 或 404 链接。' }; } return { priority: 23, owner: '商务/投放', title: '补视频资源证据字段', targetFields: ['封面链接', '字幕或ASR文本', '帧图链接', '标题', '内容摘要', '风格调性标签', '画面人设场景信号'], rowSelector: 'video-resource-template.csv 真实视频资源行。', requiredAction: '补足封面、ASR/字幕、帧图、标题或正文证据,避免把空资源标成真实。' }; } function gapSpec(row, nextActions, seed) { const id = row.id || ''; const action = findNextActionForGap(nextActions, id); const common = { priority: 40, owner: ownerForGap(id), title: row.title || id, targetFile: 'outputs/proof-gap-closure-latest/proof-gap-closure-summary.json', targetFields: ['status', 'missingProofRequirements'], rowSelector: id, seedSuggestion: '', requiredAction: action?.next || row.next || '', acceptanceCommand: row.command || '', acceptance: row.required || '' }; const specs = { 'historical-dataset': { priority: 31, section: 'history', targetFile: 'outputs/data-intake-pack-latest/history-data-template.csv', targetFields: ['客户原始Brief', '历史人工补号量基线', '参考账号或视频', '博主名称', '主页链接', '客户选择', '拒绝原因'], rowSelector: '至少 5 个真实历史 Brief 行。', seedSuggestion: historySeedSuggestion(seed), requiredAction: '补 5-10 个真实 Brief、人工名单、客户选择、拒绝原因和历史人工补号量基线。', acceptanceCommand: 'npm run history:from-csv -- --input <历史数据CSV> --output && npm run history:audit -- --input --output <历史审计输出目录> --strict', acceptance: 'historical-dataset-audit readyForCustomerEffectProof=true。' }, 'video-real-candidate': { priority: 32, section: 'video', targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv', targetFields: ['资源角色', '博主名称', '主页链接', '视频链接', '封面链接', '字幕或ASR文本', '帧图链接', '是否真实资源'], rowSelector: '至少 1 行真实候选视频。', seedSuggestion: videoSeedSuggestion(seed), requiredAction: '补真实候选视频 URL、封面、ASR/字幕、帧图或正文证据。', acceptanceCommand: 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict', acceptance: 'readyForVideoAbPreflight=true、failureCount=0、realCandidateRows>=1。' }, 'manual-review-and-customer-effect': { priority: 33, section: 'review', targetFile: 'outputs/data-intake-pack-latest/manual-review-template.csv', targetFields: ['人工复核标签', '客户选择', '归因类型', '反馈原因', '本轮人工补号量'], rowSelector: '真实人工复核候选行。', seedSuggestion: candidateSeedSuggestion(seed), requiredAction: '补商务复核标签、负样本归因、客户选择和本轮人工补号量。', acceptanceCommand: 'npm run review:metrics -- --input <已标注CSV> --output <复核指标输出目录> --strict && npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict', acceptance: 'review:metrics overallPass=true、customer-effect:audit overallPass=true。' }, 'video-ab-live-proof': { priority: 60, section: 'video-ab', targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv', targetFields: ['proofContext.mode', 'proofContext.collectionMode', 'requiresVocSocialProvider', 'requiresVideoAnalysisProvider'], rowSelector: '真实视频 A/B 输出目录。', requiredAction: '真实视频资源和真实 provider 就绪后运行 acceptance:video-ab,不得使用 sample 或 unknown proofContext。', acceptanceCommand: 'npm run acceptance:video-ab', acceptance: 'proofContext.mode=live、collectionMode=live、generatedBy=acceptance:video-ab。' }, 'live-provider-overnight-proof': { priority: 61, section: 'longrun', targetFile: '/aggregate-summary.json', targetFields: ['manifest.liveEnabled', 'acceptance.overallPass', 'failureCount', 'failedGateCount'], rowSelector: '真实 live/provider overnight aggregate。', requiredAction: 'longrun:readiness ready=true 后再跑真实 provider overnight:quality。', acceptanceCommand: 'npm run longrun:readiness -- --mode full-matrix --intake-readiness outputs\\intake-readiness-latest\\intake-readiness-summary.json --proof-gap outputs\\proof-gap-request-latest\\proof-gap-request-summary.json --proof-gap-closure outputs\\proof-gap-closure-latest\\proof-gap-closure-summary.json --output outputs\\long-run-readiness-latest', acceptance: 'longrun ready=true;overnight aggregate liveEnabled=true、overallPass=true、failureCount=0。' } }; return { ...common, ...(specs[id] || {}) }; } function makeWorkOrder(item) { return { id: item.id, priority: Number(item.priority || 99), owner: item.owner || '待分配', source: item.source || '', gapId: item.gapId || '', section: item.section || '', issueType: item.issueType || '', title: item.title || item.id, targetFile: item.targetFile || '', targetFields: normalizeList(item.targetFields), rowSelector: item.rowSelector || '', currentEvidence: item.currentEvidence || '', missingProofRequirements: normalizeList(item.missingProofRequirements), seedSuggestion: item.seedSuggestion || '', requiredAction: item.requiredAction || '', acceptanceCommand: item.acceptanceCommand || '', acceptance: item.acceptance || '', requiresHumanRealMaterial: true, directCustomerProof: false, canCloseProofGap: false, proofLevel: 'not_business_proof', boundary: '本工单只指导补真实材料和复验,不证明客户效果。' }; } function historySeedSuggestion(seed) { const row = firstRow(seed.historyDraftRows); if (!row) return ''; return `可参考本地 history 草稿 ${row.briefId || ''}:${truncate(row.customerBrief || row.projectName || '', 90)};仍缺 ${normalizeList(row.missingForProof).join(';')}`; } function referenceSeedSuggestion(seed) { const refs = normalizeRows(seed.referenceSeedRows).filter(row => row.finalLink && !/\/404\b/.test(row.finalLink)); if (!refs.length) return historySeedSuggestion(seed); return refs.slice(0, 3).map(row => `${row.authorName || row.referenceType || '参考'} ${row.finalLink}`).join(';'); } function candidateSeedSuggestion(seed) { const rows = normalizeRows(seed.candidateSeedRows).slice(0, 5); if (!rows.length) return ''; return rows.map(row => `${row.creatorName || '候选'}(${row.xhsId || '缺 xhsId'},${row.suggestedFormat || '形式待定'})`).join(';'); } function videoSeedSuggestion(seed) { const rows = normalizeRows(seed.videoWorklistRows); const candidates = rows.filter(row => row.role === '候选视频'); const picked = (candidates.length ? candidates : rows).slice(0, 4); if (!picked.length) return candidateSeedSuggestion(seed); return picked.map(row => `${row.role || '视频'}:${row.creatorName || '缺博主'},${row.resourceStatus || '状态待复核'},${row.videoLink || '缺视频 URL'}`).join(';'); } function findNextActionForGap(nextActions, gapId) { const actions = Array.isArray(nextActions.actions) ? nextActions.actions : []; return actions.find(action => String(action.id || '').includes(gapId) || String(action.gapId || '') === gapId) || null; } function ownerForGap(id) { if (id === 'historical-dataset' || id === 'manual-review-and-customer-effect') return '商务'; if (id === 'video-real-candidate') return '商务/投放'; return '技术/AI'; } function dedupeWorkOrders(rows) { const seen = new Map(); for (const row of rows.filter(Boolean)) { const key = `${row.source}:${row.id}`; if (!seen.has(key)) { seen.set(key, row); continue; } const existing = seen.get(key); existing.currentEvidence = joinUnique([existing.currentEvidence, row.currentEvidence]); existing.missingProofRequirements = Array.from(new Set([...existing.missingProofRequirements, ...row.missingProofRequirements])); } return [...seen.values()]; } function groupByOwner(rows) { const map = new Map(); for (const row of rows) { if (!map.has(row.owner)) map.set(row.owner, []); map.get(row.owner).push(row); } return [...map.entries()].map(([owner, ownerRows]) => ({ owner, workOrderCount: ownerRows.length, topPriority: Math.min(...ownerRows.map(row => row.priority)), targetFiles: Array.from(new Set(ownerRows.map(row => row.targetFile).filter(Boolean))), gapIds: Array.from(new Set(ownerRows.map(row => row.gapId).filter(Boolean))), missingProofRequirementCount: ownerRows.reduce((total, row) => total + row.missingProofRequirements.length, 0) })); } function writeOwnerArtifacts(outputDir, rows) { const byOwnerDir = path.join(outputDir, 'by-owner'); fs.mkdirSync(byOwnerDir, { recursive: true }); return groupByOwner(rows).map(group => { const ownerRows = rows.filter(row => row.owner === group.owner); const slug = ownerSlug(group.owner); const markdownPath = path.join(byOwnerDir, `${slug}.md`); const csvPath = path.join(byOwnerDir, `${slug}.csv`); fs.writeFileSync(markdownPath, withBom(renderOwnerReport(group.owner, ownerRows)), 'utf8'); fs.writeFileSync(csvPath, withBom(renderCsv(ownerRows)), 'utf8'); return { owner: group.owner, workOrderCount: ownerRows.length, topPriority: group.topPriority, markdown: path.join('outputs', 'real-proof-closure-work-order-latest', 'by-owner', `${slug}.md`).replace(/\\/g, '/'), csv: path.join('outputs', 'real-proof-closure-work-order-latest', 'by-owner', `${slug}.csv`).replace(/\\/g, '/'), targetFiles: group.targetFiles, gapIds: group.gapIds }; }); } function renderReport(summary) { const lines = [ '# 真实证明闭环工单', '', `- 生成时间:${summary.generatedAt}`, `- passed:${summary.passed}`, `- complete:${summary.complete}`, `- proofLevel:${summary.proofLevel}`, `- workOrderCount:${summary.workOrderCount}`, `- proofGapOpenCount:${summary.sourceState.proofGapOpenCount}`, `- intakeFailureCount:${summary.sourceState.intakeFailureCount}`, `- videoFailureCount:${summary.sourceState.videoFailureCount}`, `- realCandidateRows:${summary.sourceState.realCandidateRows}`, `- canCloseProofGap:${summary.canCloseProofGap}`, '', '## 负责人汇总', '', '| 负责人 | 工单数 | 最高优先级 | 目标文件 | 缺口 |', '| --- | ---: | ---: | --- | --- |', ...summary.ownerGroups.map(group => tableRow([ group.owner, group.workOrderCount, group.topPriority, group.targetFiles.join(';'), group.gapIds.join(';') ])), '', '## 负责人附件', '', '| 负责人 | 工单数 | Markdown | CSV |', '| --- | ---: | --- | --- |', ...(summary.ownerArtifacts || []).map(item => tableRow([item.owner, item.workOrderCount, item.markdown, item.csv])), '', '## 工单明细', '', '| 优先级 | 负责人 | 缺口 | 标题 | 目标文件 | 字段 | 行范围 | 当前证据 | 缺失证明要求 | 种子线索 | 补齐动作 | 复验命令 | 验收 |', '| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', ...summary.workOrders.map(row => tableRow([ row.priority, row.owner, row.gapId, row.title, row.targetFile, row.targetFields.join(';'), row.rowSelector, row.currentEvidence, row.missingProofRequirements.join(';'), row.seedSuggestion, row.requiredAction, row.acceptanceCommand, row.acceptance ])), '', '## 源文件', '', ...Object.entries(summary.sourceFiles).map(([key, value]) => `- ${key}:${value || '缺失'}`), '', '## 边界', '', ...summary.guardrails.map(item => `- ${item}`) ]; return lines.join('\n'); } function renderOwnerReport(owner, rows) { return [ `# 真实证明闭环工单 - ${owner}`, '', `- 工单数:${rows.length}`, '- 边界:本附件只指导补真实材料和复验,不证明客户效果。', '', '| 优先级 | 缺口 | 标题 | 目标文件 | 字段 | 当前证据 | 缺失证明要求 | 补齐动作 | 复验命令 | 验收 |', '| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- |', ...rows.map(row => tableRow([ row.priority, row.gapId, row.title, row.targetFile, row.targetFields.join(';'), row.currentEvidence, row.missingProofRequirements.join(';'), row.requiredAction, row.acceptanceCommand, row.acceptance ])) ].join('\n'); } function renderCsv(rows) { const header = [ 'id', '优先级', '负责人', '来源', '缺口ID', '区域', '问题类型', '标题', '目标文件', '目标字段', '行范围', '当前证据', '缺失证明要求', '种子线索', '补齐动作', '复验命令', '验收', '需要人工真实材料', '直接客户证明', '可关闭proof-gap', '证明等级', '边界' ]; return [ header.join(','), ...rows.map(row => [ row.id, row.priority, row.owner, row.source, row.gapId, row.section, row.issueType, row.title, row.targetFile, row.targetFields.join(';'), row.rowSelector, row.currentEvidence, row.missingProofRequirements.join(';'), row.seedSuggestion, row.requiredAction, row.acceptanceCommand, row.acceptance, row.requiresHumanRealMaterial, row.directCustomerProof, row.canCloseProofGap, row.proofLevel, row.boundary ].map(csvCell).join(',')) ].join('\n'); } function latestSummary(outputsDir, fileName, fallbackDir) { const candidates = []; const fallbackPath = path.join(outputsDir, fallbackDir, fileName); if (fs.existsSync(fallbackPath)) candidates.push(fallbackPath); for (const dir of listDirs(outputsDir)) { const file = path.join(dir, fileName); if (fs.existsSync(file)) candidates.push(file); } const picked = candidates.sort((a, b) => mtimeMs(b) - mtimeMs(a))[0] || fallbackPath; return { path: picked, json: readJson(picked) }; } function listDirs(dir) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir, { withFileTypes: true }) .filter(item => item.isDirectory()) .map(item => path.join(dir, item.name)); } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } } function firstRow(value) { const rows = normalizeRows(value); return rows[0] || null; } function normalizeRows(value) { return Array.isArray(value) ? value : []; } function normalizeList(value) { if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean); if (!value) return []; return [String(value).trim()].filter(Boolean); } function countRows(rows, fallback = 0) { if (Array.isArray(rows)) return rows.length; return Number(fallback || 0); } function joinUnique(values) { return Array.from(new Set(values.filter(Boolean))).join(';'); } function truncate(value, maxLength) { const text = String(value || ''); return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; } function tableRow(cells) { return `| ${cells.map(escapeCell).join(' | ')} |`; } function escapeCell(value) { return String(value ?? '').replace(/\r?\n/g, '
').replace(/\|/g, '\\|'); } function csvCell(value) { const text = String(value ?? '').replace(/\r?\n/g, ' '); if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; return text; } function withBom(text) { return `\uFEFF${text}`; } function ownerSlug(owner) { if (owner === '商务') return 'business'; if (owner === '商务/投放') return 'business-media'; if (owner === '技术/AI') return 'tech-ai'; return String(owner || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'unknown'; } function rel(root, file) { return path.relative(root, file).replace(/\\/g, '/'); } function mtimeMs(file) { try { return fs.statSync(file).mtimeMs; } catch { return 0; } } function parseArgs(argv) { const result = {}; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (!arg.startsWith('--')) continue; const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[index + 1]; if (!next || next.startsWith('--')) result[key] = true; else { result[key] = next; index += 1; } } return result; } if (require.main === module) main(); module.exports = { buildRealProofClosureWorkOrder };