#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const OUTPUTS = path.join(ROOT, 'outputs'); const HISTORY_FIELDS = [ ['客户原始Brief', '填写真实客户原始 Brief,至少能看出产品、人群、投放诉求。'], ['历史人工补号量基线', '填写该 Brief 历史人工补号量数字,用于后续计算人工补号减少率。'], ['参考账号或视频', '填写真实参考账号主页或参考视频 URL。'], ['平台', '填写小红书、抖音等真实平台。'], ['博主名称', '填写人工最终名单里的真实博主名称。'], ['主页链接', '填写人工最终名单里的真实主页 URL。'], ['人工复核标签', '填写可直接发客户、商务复核、跑偏、调性不符等复核标签。'], ['客户选择', '填写客户选中或客户拒绝,不能停留在待反馈。'] ]; const REVIEW_FIELDS = [ ['策略', '填写候选来源策略,如 baseline-live、reference-account、homepage-evidence、video-enhanced。'], ['排名', '填写同一 brief 内连续排名。'], ['平台', '填写候选平台。'], ['博主名称', '填写候选博主真实名称。'], ['主页链接', '填写候选主页 URL。'], ['人工复核标签', '填写商务复核结论。'], ['客户选择', '填写客户选中或客户拒绝;缺失时不能做客户效果审计。'], ['归因类型', '负样本必须填写归因类型。'], ['反馈原因', '客户拒绝或商务负样本必须填写具体原因。'], ['本轮人工补号量', '填写本轮额外人工补号量数字。'] ]; const VIDEO_FIELDS = [ ['资源角色', '填写参考视频或候选视频。'], ['博主名称', '填写视频所属博主真实名称。'], ['主页链接', '填写视频所属博主主页 URL。'], ['视频链接', '填写真实视频 URL。'], ['标题', '填写视频标题。'], ['内容摘要', '填写视频内容摘要。'], ['风格调性标签', '填写画面、表达、场景、内容结构等调性标签。'], ['是否真实资源', '真实资源填“是”;模板、示例、占位不能填“是”。'] ]; function main() { const args = parseArgs(process.argv.slice(2)); const dataPack = path.resolve(args.dataPack || path.join(OUTPUTS, 'data-intake-pack-latest')); const videoPack = path.resolve(args.videoPack || path.join(OUTPUTS, 'video-intake-pack-latest')); const outputDir = path.resolve(args.output || path.join(OUTPUTS, 'intake-field-checklist-latest')); const minBriefs = Number(args.minBriefs || 5); const summary = buildChecklist({ historyCsv: path.resolve(args.historyCsv || path.join(dataPack, 'history-data-template.csv')), reviewCsv: path.resolve(args.reviewCsv || path.join(dataPack, 'manual-review-template.csv')), videoCsv: path.resolve(args.videoCsv || path.join(videoPack, 'video-resource-template.csv')), minBriefs }); fs.mkdirSync(outputDir, { recursive: true }); const summaryPath = path.join(outputDir, 'intake-field-checklist-summary.json'); const reportPath = path.join(outputDir, 'intake-field-checklist.md'); const csvPath = path.join(outputDir, 'intake-field-checklist.csv'); fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); fs.writeFileSync(csvPath, withBom(renderCsv(summary.items)), 'utf8'); console.log(JSON.stringify({ outputDir, summary: summaryPath, report: reportPath, csv: csvPath, passed: summary.passed, itemCount: summary.itemCount, missingRequiredCount: summary.missingRequiredCount, placeholderCount: summary.placeholderCount }, null, 2)); if (args.strict && !summary.passed) process.exitCode = 1; } function buildChecklist({ historyCsv, reviewCsv, videoCsv, minBriefs }) { const history = readCsvFile(historyCsv); const review = readCsvFile(reviewCsv); const video = readCsvFile(videoCsv); const items = [ ...historyItems(history, historyCsv, minBriefs), ...reviewItems(review, reviewCsv), ...videoItems(video, videoCsv) ].map((item, index) => ({ id: `FIELD-${String(index + 1).padStart(3, '0')}`, ...item })); const missingRequiredCount = items.filter(item => item.issueType === 'missing_required' || item.issueType === 'missing_minimum_row').length; const placeholderCount = items.filter(item => item.issueType === 'placeholder').length; const ownerGroups = buildOwnerGroups(items); const recheckCommands = buildRecheckCommands(); return { generatedAt: new Date().toISOString(), purpose: '给商务/投放逐字段补齐真实历史 Brief、人工复核、客户选择和视频资源;本表不证明客户效果。', proofLevel: 'smoke_or_local', directCustomerProof: false, passed: items.length > 0, minBriefs, files: { historyCsv, reviewCsv, videoCsv }, counts: { historyRows: history.rows.length, reviewRows: review.rows.length, videoRows: video.rows.length }, itemCount: items.length, missingRequiredCount, placeholderCount, ownerGroups, recheckCommands, items, guardrails: [ '本表只用于补齐真实材料,不证明命中率、客户选中率或人工补号减少率。', 'example.com、示例、候选、待补、待客户反馈、填写说明等内容必须替换为真实客户材料。', '补齐后必须重跑 intake:readiness、round:refresh 和 customer-effect:audit/acceptance:video-ab 的对应真实验收。' ] }; } function historyItems(table, file, minBriefs) { const rows = table.rows; const items = []; const briefIds = new Set(rows.map(row => clean(row['brief编号'])).filter(Boolean)); for (let index = briefIds.size + 1; index <= minBriefs; index += 1) { items.push(item('商务', 'history', 'missing_minimum_row', file, index, '整行', '', `补第 ${index} 个真实历史 Brief,覆盖不同项目或客户需求。`, '真实历史 Brief 数达到最少要求。')); } rows.forEach((row, index) => { addFieldItems(items, '商务', 'history', file, index + 2, row, HISTORY_FIELDS); const decision = clean(row['客户选择']); if (/拒绝|淘汰|未选/i.test(decision) && isBlankOrPlaceholder(row['拒绝原因'])) { items.push(item('商务', 'history', 'missing_required', file, index + 2, '拒绝原因', row['拒绝原因'], '客户拒绝样本必须补拒绝原因。', '负样本归因覆盖率=100%。')); } }); return items; } function reviewItems(table, file) { const items = []; table.rows.forEach((row, index) => { addFieldItems(items, '商务', 'review', file, index + 2, row, REVIEW_FIELDS); const label = clean(row['人工复核标签']); const decision = clean(row['客户选择']); if ((/拒绝|淘汰|跑偏|不符|负样本/i.test(`${label} ${decision}`)) && isBlankOrPlaceholder(row['反馈原因'])) { items.push(item('商务', 'review', 'missing_required', file, index + 2, '反馈原因', row['反馈原因'], '补商务或客户拒绝的具体原因。', '负样本归因覆盖率=100%。')); } }); return items; } function videoItems(table, file) { const items = []; table.rows.forEach((row, index) => addFieldItems(items, '商务/投放', 'video', file, index + 2, row, VIDEO_FIELDS)); const realCandidates = table.rows.filter(row => clean(row['资源角色']) === '候选视频' && clean(row['是否真实资源']) === '是'); if (!realCandidates.length) { items.push(item('商务/投放', 'video', 'missing_minimum_row', file, table.rows.length + 2, '整行', '', '补至少 1 条真实候选视频资源。', 'videoReady=true,真实候选视频行数 >= 1。')); } return items; } function addFieldItems(items, owner, section, file, rowNumber, row, fields) { for (const [field, fixAction] of fields) { const current = row[field]; if (isBlankOrPlaceholder(current)) { items.push(item(owner, section, isPlaceholder(current) ? 'placeholder' : 'missing_required', file, rowNumber, field, current, fixAction, acceptanceFor(section, field))); } } } function item(owner, section, issueType, file, rowNumber, field, currentValue, fixAction, acceptance) { return { owner, section, issueType, file, rowNumber, field, currentValue: clean(currentValue), fixAction, acceptance, boundary: '补齐材料,不证明客户效果' }; } function buildOwnerGroups(items) { const groups = new Map(); for (const item of items) { const key = `${item.owner}|${item.section}`; if (!groups.has(key)) { groups.set(key, { owner: item.owner, section: item.section, itemCount: 0, missingRequiredCount: 0, placeholderCount: 0, files: new Set(), firstItemIds: [] }); } const group = groups.get(key); group.itemCount += 1; if (item.issueType === 'missing_required' || item.issueType === 'missing_minimum_row') group.missingRequiredCount += 1; if (item.issueType === 'placeholder') group.placeholderCount += 1; group.files.add(item.file); if (group.firstItemIds.length < 5) group.firstItemIds.push(item.id); } return Array.from(groups.values()) .map(group => ({ ...group, files: Array.from(group.files), nextAction: nextActionForGroup(group.section), acceptance: groupAcceptanceFor(group.section), boundary: '补齐材料,不证明客户效果' })) .sort((a, b) => groupPriority(a.section) - groupPriority(b.section) || a.owner.localeCompare(b.owner, 'zh-Hans-CN')); } function buildRecheckCommands() { return [ { stage: '真实材料预审', owner: '技术/AI', command: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest', acceptance: 'overallReady=true 且 failureCount=0', boundary: '预审通过只代表材料齐全,不证明客户效果' }, { stage: '视频资源就绪审计', owner: '技术/AI', command: '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', boundary: '只证明视频 A/B 前置资源齐备' }, { stage: '刷新本轮交接', owner: '技术/AI', command: 'npm run round:refresh -- --output-root outputs --strict', acceptance: 'round-deposition.passed=true 且 failCount=0', boundary: '刷新交接不证明业务效果' }, { stage: '客户效果审计', owner: '技术/AI', command: 'npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict', acceptance: 'customer-effect:audit overallPass=true', boundary: '缺客户选择、历史人工补号基线或本轮人工补号量时不得运行或宣称效果' }, { stage: '视频 A/B 验收', owner: '技术/AI', command: 'npm run acceptance:video-ab', acceptance: '真实 provider、真实参考视频和真实候选视频下通过', boundary: '接口 200 或 sample 不证明视频提升提号率' } ]; } function nextActionForGroup(section) { if (section === 'history') return '先补真实历史 Brief、参考账号或视频、人工最终名单、客户选择和历史人工补号量基线。'; if (section === 'review') return '补候选人工复核标签、客户选择、负样本归因、反馈原因和本轮人工补号量。'; if (section === 'video') return '补真实参考视频和真实候选视频 URL,并补封面、ASR、帧图、标题或内容摘要证据。'; return '按字段核对表补齐真实材料。'; } function groupAcceptanceFor(section) { if (section === 'history') return 'historyReady=true,真实历史 Brief 数 >= minBriefs,且无模板占位。'; if (section === 'review') return 'reviewMetricsReady=true,customerEffectReady=true 所需字段齐全。'; if (section === 'video') return 'videoReady=true,至少有真实参考视频和真实候选视频。'; return '对应 readiness failureCount=0。'; } function groupPriority(section) { if (section === 'history') return 1; if (section === 'video') return 2; if (section === 'review') return 3; return 99; } function acceptanceFor(section, field) { if (section === 'history') return 'historyReady=true,且无模板占位。'; if (section === 'review') return field === '客户选择' ? 'customerEffectReady=true 所需字段齐全。' : 'reviewMetricsReady=true。'; return 'videoReady=true,真实视频资源可进入视频 A/B 前置验收。'; } function isBlankOrPlaceholder(value) { return !clean(value) || isPlaceholder(value); } function isPlaceholder(value) { return /example\.com|示例|候选|待补|待确认|待客户反馈|填写|人工待补/i.test(clean(value)); } function clean(value) { return String(value ?? '').trim(); } function readCsvFile(file) { if (!fs.existsSync(file)) return { header: [], rows: [] }; const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); const records = parseCsv(text); const header = records[0] || []; return { header, rows: records.slice(1).filter(row => row.some(cell => clean(cell))).map(row => Object.fromEntries(header.map((name, index) => [name, row[index] || '']))) }; } function parseCsv(text) { const rows = []; let row = []; let cell = ''; let quoted = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; const next = text[index + 1]; if (quoted) { if (char === '"' && next === '"') { cell += '"'; index += 1; } else if (char === '"') { quoted = false; } else { cell += char; } continue; } if (char === '"') quoted = true; else if (char === ',') { row.push(cell); cell = ''; } else if (char === '\n') { row.push(cell.replace(/\r$/, '')); rows.push(row); row = []; cell = ''; } else { cell += char; } } if (cell || row.length) { row.push(cell.replace(/\r$/, '')); rows.push(row); } return rows; } function renderReport(summary) { const lines = [ '# 真实材料字段级补齐核对表', '', `- 生成时间:${summary.generatedAt}`, `- 是否通过:${summary.passed ? '是' : '否'}`, `- 待补字段/行数:${summary.itemCount}`, `- 缺必填:${summary.missingRequiredCount}`, `- 占位内容:${summary.placeholderCount}`, `- directCustomerProof:${summary.directCustomerProof ? 'true' : 'false'}`, '', '## 文件', '', `- 历史数据:${summary.files.historyCsv}`, `- 人工复核:${summary.files.reviewCsv}`, `- 视频资源:${summary.files.videoCsv}`, '', '## 按负责人补齐顺序', '', '| 负责人 | 区域 | 问题数 | 缺必填 | 占位 | 文件 | 优先处理项 | 修复动作 | 验收 |', '| --- | --- | ---: | ---: | ---: | --- | --- | --- | --- |', ...summary.ownerGroups.map(row => `| ${escapeCell(row.owner)} | ${escapeCell(row.section)} | ${row.itemCount} | ${row.missingRequiredCount} | ${row.placeholderCount} | ${escapeCell(row.files.join(';'))} | ${escapeCell(row.firstItemIds.join('、'))} | ${escapeCell(row.nextAction)} | ${escapeCell(row.acceptance)} |`), '', '## 补齐后复验命令', '', '| 阶段 | 负责人 | 命令 | 通过标准 | 边界 |', '| --- | --- | --- | --- | --- |', ...summary.recheckCommands.map(row => `| ${escapeCell(row.stage)} | ${escapeCell(row.owner)} | \`${escapeCell(row.command)}\` | ${escapeCell(row.acceptance)} | ${escapeCell(row.boundary)} |`), '', '## 核对表', '', '| ID | 负责人 | 区域 | 行号 | 字段 | 问题 | 当前值 | 修复动作 | 验收 |', '| --- | --- | --- | ---: | --- | --- | --- | --- | --- |', ...summary.items.map(row => `| ${escapeCell(row.id)} | ${escapeCell(row.owner)} | ${escapeCell(row.section)} | ${row.rowNumber} | ${escapeCell(row.field)} | ${escapeCell(row.issueType)} | ${escapeCell(row.currentValue)} | ${escapeCell(row.fixAction)} | ${escapeCell(row.acceptance)} |`), '', '## 边界', '', ...summary.guardrails.map(item => `- ${item}`) ]; return lines.join('\n'); } function renderCsv(items) { const header = ['ID', '负责人', '区域', '问题类型', '文件', '行号', '字段', '当前值', '修复动作', '验收标准', '边界']; const rows = items.map(item => [ item.id, item.owner, item.section, item.issueType, item.file, item.rowNumber, item.field, item.currentValue, item.fixAction, item.acceptance, item.boundary ]); return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n'); } function csvCell(value) { const text = String(value ?? ''); return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function escapeCell(value) { return String(value ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' '); } 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 withBom(text) { return `\uFEFF${text}`; } if (require.main === module) main(); module.exports = { buildChecklist };