| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435 |
- #!/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 <historical-dataset-audit.json> --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 };
|