#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { readCsv: readHistoryCsv } = require('./history-dataset-from-csv');
const { readCsv: readVideoCsv } = require('./video-resource-readiness-audit');
const { readCsv: readReviewCsv } = require('./review-metrics');
const HISTORY_REQUIRED = [
'brief编号',
'项目名称',
'类目',
'客户原始Brief',
'历史人工补号量基线',
'参考账号或视频',
'平台',
'博主名称',
'主页链接',
'人工复核标签',
'客户选择',
'拒绝原因'
];
const REVIEW_REQUIRED = [
'brief编号',
'策略',
'排名',
'平台',
'博主名称',
'综合分',
'brief匹配分',
'参考风格分',
'主页证据分',
'视觉质感分',
'调性一致分',
'证据加分',
'证据风险扣分',
'推荐理由',
'风险提示',
'主页链接',
'人工复核标签',
'客户选择',
'归因类型',
'反馈原因',
'本轮人工补号量'
];
const VIDEO_REQUIRED = [
'brief编号',
'项目名称',
'平台',
'资源角色',
'博主名称',
'主页链接',
'视频链接',
'封面链接',
'字幕或ASR文本',
'帧图链接',
'标题',
'发布时间',
'内容摘要',
'风格调性标签',
'画面人设场景信号',
'风险提示',
'来源接口或备注',
'是否真实资源'
];
const FINAL_CUSTOMER_DECISIONS = new Set(['客户选中', '客户拒绝', '已选中', '已拒绝', '选中', '拒绝', '客户通过', '客户淘汰']);
const NEGATIVE_REVIEW_LABELS = new Set(['跑偏', '硬性规则违规', '硬性规则违约', '调性不符', '主页质感不符', '参考账号不像']);
function main() {
const args = parseArgs(process.argv.slice(2));
const dataPack = args.dataPack ? path.resolve(args.dataPack) : '';
const videoPack = args.videoPack ? path.resolve(args.videoPack) : '';
const historyCsv = path.resolve(args.historyCsv || args.history || (dataPack ? path.join(dataPack, 'history-data-template.csv') : ''));
const reviewCsv = path.resolve(args.reviewCsv || args.review || (dataPack ? path.join(dataPack, 'manual-review-template.csv') : ''));
const videoCsv = path.resolve(args.videoCsv || args.video || (videoPack ? path.join(videoPack, 'video-resource-template.csv') : ''));
if (!historyCsv || !reviewCsv || !videoCsv) {
throw new Error('Usage: node scripts/intake-readiness-audit.js --data-pack
--video-pack [--output ] [--strict]');
}
const outputDir = path.resolve(args.output || process.env.TIHAO_INTAKE_READINESS_OUTPUT || path.join(path.dirname(historyCsv), 'intake-readiness'));
const minBriefs = Number(args.minBriefs || process.env.TIHAO_INTAKE_MIN_BRIEFS || 5);
const summary = buildIntakeReadinessSummary({
historyCsv,
reviewCsv,
videoCsv,
minBriefs
});
fs.mkdirSync(outputDir, { recursive: true });
const jsonPath = path.join(outputDir, 'intake-readiness-summary.json');
const reportPath = path.join(outputDir, 'intake-readiness-report.md');
const repairCsvPath = path.join(outputDir, 'intake-readiness-repair-actions.csv');
fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
fs.writeFileSync(repairCsvPath, withBom(renderRepairCsv(summary.repairActions)), 'utf8');
console.log(JSON.stringify({
outputDir,
json: jsonPath,
report: reportPath,
readyForHistoryAudit: summary.history.ready,
readyForVideoResourceAudit: summary.video.ready,
readyForReviewMetrics: summary.review.readyForReviewMetrics,
readyForCustomerEffectAudit: summary.review.readyForCustomerEffectAudit,
overallReady: summary.acceptance.overallReady,
failureCount: summary.failureCount,
repairActionCount: summary.repairActions.length
}, null, 2));
if (args.strict && !summary.acceptance.overallReady) process.exitCode = 2;
}
function buildIntakeReadinessSummary({ historyCsv, reviewCsv, videoCsv, minBriefs }) {
const historyRows = safeReadCsv(readHistoryCsv, historyCsv);
const reviewRows = safeReadCsv(readReviewCsv, reviewCsv);
const videoRows = safeReadCsv(readVideoCsv, videoCsv);
const history = auditHistory({ file: historyCsv, rows: historyRows, minBriefs });
const review = auditReview({ file: reviewCsv, rows: reviewRows });
const video = auditVideo({ file: videoCsv, rows: videoRows });
const sections = [history, review, video];
const issues = sections.flatMap(section => section.issues.map(issue => ({ section: section.id, ...issue })));
const acceptance = {
historyReady: history.ready,
videoReady: video.ready,
reviewMetricsReady: review.readyForReviewMetrics,
customerEffectReady: review.readyForCustomerEffectAudit
};
acceptance.overallReady = acceptance.historyReady &&
acceptance.videoReady &&
acceptance.reviewMetricsReady &&
acceptance.customerEffectReady;
const files = { historyCsv, reviewCsv, videoCsv };
const repairActions = buildRepairActions({ issues, files });
return {
generatedAt: new Date().toISOString(),
minBriefs,
files,
acceptance,
history,
review,
video,
failureCount: issues.length,
issues,
repairActions,
guardrails: [
'intake readiness 只判断模板是否已被真实材料替换,不替代 history:audit、video:resource-readiness、review:metrics 或 customer-effect:audit。',
'example.com、示例、候选博主、待客户反馈、人工待补等模板占位内容不得进入真实审计。',
'没有客户最终选择和本轮人工补号量时,不得声明客户效果证明完成。',
'没有真实参考视频和真实候选视频时,不得声明视频 A/B 可以开始。'
]
};
}
function auditHistory({ file, rows, minBriefs }) {
const issues = [];
const headerOk = headerStartsWith(rows.header, HISTORY_REQUIRED);
if (!rows.ok) issues.push(issue('read-error', rows.error));
if (!headerOk) issues.push(issue('header', '历史数据 CSV 表头不符合 data-intake-pack 模板。'));
const body = rows.body || [];
const briefIds = unique(body.map(row => value(row['brief编号'])));
if (briefIds.length < minBriefs) issues.push(issue('min-briefs', `真实历史 Brief 数不足:${briefIds.length}/${minBriefs}。`));
if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '历史数据表仍包含 example.com、示例、候选或待补等模板占位内容。'));
if (!body.every(row => value(row['客户原始Brief']).length >= 20)) issues.push(issue('brief-text', '每行必须有足够具体的客户原始 Brief。'));
if (!body.every(row => toNumberOrNull(row['历史人工补号量基线']) !== null)) issues.push(issue('manual-baseline', '每行必须填写历史人工补号量基线。'));
if (!body.every(row => isLikelyRealReference(row['参考账号或视频']))) issues.push(issue('reference', '每行必须填写真实参考账号或参考视频链接。'));
if (!body.every(row => value(row['博主名称']) && isLikelyRealUrl(row['主页链接']))) issues.push(issue('manual-list', '每行必须填写人工最终名单中的博主名称和主页链接。'));
if (!body.every(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择'])))) issues.push(issue('customer-decision', '每行必须填写客户最终选中或客户拒绝,不能只写待客户反馈。'));
const rejectedRows = body.filter(row => /拒绝|淘汰|未选/i.test(value(row['客户选择'])));
if (!rejectedRows.every(row => value(row['拒绝原因']).length >= 2)) issues.push(issue('reject-reason', '客户拒绝样本必须填写拒绝原因。'));
return {
id: 'history',
file,
ready: issues.length === 0,
counts: {
rows: body.length,
briefCount: briefIds.length,
rejectedRows: rejectedRows.length
},
issues
};
}
function auditReview({ file, rows }) {
const issues = [];
const headerOk = headerStartsWith(rows.header, REVIEW_REQUIRED);
if (!rows.ok) issues.push(issue('read-error', rows.error));
if (!headerOk) issues.push(issue('header', '人工复核 CSV 表头不符合 data-intake-pack 模板。'));
const body = rows.body || [];
if (!body.length) issues.push(issue('empty', '人工复核表没有候选行。'));
if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '人工复核表仍包含 example.com、示例、候选或待补等模板占位内容。'));
if (!body.every(row => value(row['人工复核标签']))) issues.push(issue('review-label', '每行必须填写人工复核标签。'));
const negativeRows = body.filter(row => NEGATIVE_REVIEW_LABELS.has(value(row['人工复核标签'])));
if (!negativeRows.every(row => value(row['归因类型']))) issues.push(issue('attribution', '负样本必须填写归因类型。'));
const hasCustomerDecision = body.some(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择'])));
if (!hasCustomerDecision) issues.push(issue('customer-decision', '人工复核表至少需要客户最终选中/拒绝记录,才能进入客户效果审计。'));
const hasCurrentManualSupplement = body.some(row => toNumberOrNull(row['本轮人工补号量']) !== null);
if (!hasCurrentManualSupplement) issues.push(issue('current-manual-supplement', '缺少本轮人工补号量。'));
const readyForReviewMetrics = headerOk && body.length > 0 && !hasPlaceholderRows(body) && body.every(row => value(row['人工复核标签'])) && negativeRows.every(row => value(row['归因类型']));
const readyForCustomerEffectAudit = readyForReviewMetrics && hasCustomerDecision && hasCurrentManualSupplement;
return {
id: 'review',
file,
readyForReviewMetrics,
readyForCustomerEffectAudit,
counts: {
rows: body.length,
negativeRows: negativeRows.length,
rowsWithCustomerDecision: body.filter(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择']))).length,
rowsWithCurrentManualSupplement: body.filter(row => toNumberOrNull(row['本轮人工补号量']) !== null).length
},
issues
};
}
function auditVideo({ file, rows }) {
const issues = [];
const headerOk = headerStartsWith(rows.header, VIDEO_REQUIRED);
if (!rows.ok) issues.push(issue('read-error', rows.error));
if (!headerOk) issues.push(issue('header', '视频资源 CSV 表头不符合 video-intake-pack 模板。'));
const body = rows.body || [];
if (!body.length) issues.push(issue('empty', '视频资源表没有资源行。'));
if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '视频资源表仍包含 example.com、示例、候选、待确认或人工待补等模板占位内容。'));
const realRows = body.filter(row => isYes(row['是否真实资源']));
const realReferences = realRows.filter(row => value(row['资源角色']) === '参考视频');
const realCandidates = realRows.filter(row => value(row['资源角色']) === '候选视频');
if (!realReferences.length) issues.push(issue('real-reference', '缺少真实参考视频资源。'));
if (!realCandidates.length) issues.push(issue('real-candidate', '缺少真实候选视频资源。'));
if (!realRows.some(row => isLikelyRealUrl(row['视频链接']))) issues.push(issue('video-url', '缺少真实视频 URL。'));
if (!realRows.every(row => hasVideoEvidence(row))) issues.push(issue('video-evidence', '真实视频资源必须至少有视频 URL、封面、ASR、帧图或正文证据之一。'));
return {
id: 'video',
file,
ready: issues.length === 0,
counts: {
rows: body.length,
realRows: realRows.length,
realReferenceRows: realReferences.length,
realCandidateRows: realCandidates.length,
realVideoUrlRows: realRows.filter(row => isLikelyRealUrl(row['视频链接'])).length
},
issues
};
}
function renderReport(summary) {
const lines = [
'# 真实材料 Intake Readiness 审计',
'',
`- 生成时间:${summary.generatedAt}`,
`- 是否可进入后续真实审计:${summary.acceptance.overallReady ? '是' : '否'}`,
`- failureCount:${summary.failureCount}`,
'',
'## 文件',
'',
`- 历史数据:${summary.files.historyCsv}`,
`- 人工复核:${summary.files.reviewCsv}`,
`- 视频资源:${summary.files.videoCsv}`,
'',
'## 门禁',
'',
'| 门禁 | 状态 |',
'| --- | --- |',
`| 历史数据可进入 history:from-csv/history:audit | ${passFail(summary.history.ready)} |`,
`| 视频资源可进入 video:resource-readiness | ${passFail(summary.video.ready)} |`,
`| 人工复核可进入 review:metrics | ${passFail(summary.review.readyForReviewMetrics)} |`,
`| 人工复核可进入 customer-effect:audit | ${passFail(summary.review.readyForCustomerEffectAudit)} |`,
'',
'## 统计',
'',
'| 区域 | 指标 | 数量 |',
'| --- | --- | ---: |',
`| 历史数据 | 行数 | ${summary.history.counts.rows} |`,
`| 历史数据 | Brief 数 | ${summary.history.counts.briefCount} |`,
`| 人工复核 | 行数 | ${summary.review.counts.rows} |`,
`| 人工复核 | 有客户选择行 | ${summary.review.counts.rowsWithCustomerDecision} |`,
`| 视频资源 | 真实参考视频 | ${summary.video.counts.realReferenceRows} |`,
`| 视频资源 | 真实候选视频 | ${summary.video.counts.realCandidateRows} |`,
'',
'## 问题',
'',
summary.issues.length
? '| 区域 | 类型 | 说明 |\n| --- | --- | --- |\n' + summary.issues.map(item => `| ${item.section} | ${item.type} | ${escapeCell(item.message)} |`).join('\n')
: '- 暂无问题。',
'',
'## 修复清单',
'',
summary.repairActions.length
? '| 优先级 | 负责人 | 文件 | 字段 | 修复动作 | 通过标准 |\n| ---: | --- | --- | --- | --- | --- |\n' + summary.repairActions.map(item => `| ${item.priority} | ${item.owner} | ${escapeCell(item.file)} | ${escapeCell(item.field)} | ${escapeCell(item.action)} | ${escapeCell(item.acceptance)} |`).join('\n')
: '- 暂无修复动作。',
'',
'## 后续命令',
'',
'```powershell',
'npm run history:from-csv -- --input --output ',
'npm run history:audit -- --input --output <历史审计输出目录> --strict',
'npm run video:resource-readiness -- --input --output <视频资源审计输出目录> --strict',
'npm run review:metrics -- --input --output <复核指标输出目录> --strict',
'npm run customer-effect:audit -- --review-csv --history-audit --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict',
'```',
'',
'## 边界',
'',
...summary.guardrails.map(item => `- ${item}`)
];
return lines.join('\n');
}
function buildRepairActions({ issues, files }) {
return issues.map((item, index) => {
const spec = repairSpec(item);
return {
priority: index + 1,
section: item.section,
type: item.type,
owner: spec.owner,
file: spec.file === 'history' ? files.historyCsv : spec.file === 'review' ? files.reviewCsv : files.videoCsv,
field: spec.field,
action: spec.action,
acceptance: spec.acceptance
};
});
}
function repairSpec(issueItem) {
const key = `${issueItem.section}:${issueItem.type}`;
const specs = {
'history:min-briefs': ['商务', 'history', 'brief编号', '补齐至少 5 个真实历史 Brief,覆盖不同项目或不同客户需求。', 'historyReady=true,真实历史 Brief 数 >= 5。'],
'history:placeholder': ['商务', 'history', '全表', '删除 example.com、示例、候选、待补等占位内容,替换为真实客户材料。', '预审不再出现 placeholder。'],
'history:brief-text': ['商务', 'history', '客户原始Brief', '填写足够具体的客户原始需求,至少覆盖类目、人群、平台、内容方向或禁投项。', '每行客户原始 Brief 长度和信息量达标。'],
'history:manual-baseline': ['商务', 'history', '历史人工补号量基线', '填写数字,表示历史同类需求人工额外补号数量。', '每行都能解析为非负数字。'],
'history:reference': ['商务', 'history', '参考账号或视频', '填写真实参考账号主页或参考视频链接,不只写昵称或截图说明。', '每行都有真实可识别 URL。'],
'history:manual-list': ['商务', 'history', '博主名称/主页链接', '填写人工最终名单中的真实博主名称和主页链接。', '每行博主名称非空且主页链接为真实 URL。'],
'history:customer-decision': ['商务', 'history', '客户选择', '把待反馈替换为客户最终选中或客户拒绝。', '每行客户选择为客户选中或客户拒绝。'],
'history:reject-reason': ['商务', 'history', '拒绝原因', '客户拒绝样本填写具体拒绝原因。', '所有拒绝样本拒绝原因非空。'],
'review:empty': ['商务', 'review', '全表', '把本轮 AI 候选名单粘贴到人工复核模板。', '人工复核表至少有一行候选。'],
'review:placeholder': ['商务', 'review', '全表', '删除 example.com、示例、候选、待补等占位内容,替换为真实候选和复核结果。', '预审不再出现 placeholder。'],
'review:review-label': ['商务', 'review', '人工复核标签', '为每个候选填写人工复核标签。', '每行人工复核标签非空。'],
'review:attribution': ['商务', 'review', '归因类型', '负样本填写需求解析错、隐性规则漏、召回关键词错、主页证据不足、视频证据误判、排序权重错或输出解释错。', '负样本归因覆盖率=100%。'],
'review:customer-decision': ['商务', 'review', '客户选择', '补客户最终选中或客户拒绝记录。', '至少有客户最终选择,客户效果审计字段齐全。'],
'review:current-manual-supplement': ['商务', 'review', '本轮人工补号量', '填写本轮 AI 输出后仍需人工补号的数量。', '至少一行本轮人工补号量为非负数字。'],
'video:empty': ['商务/投放', 'video', '全表', '填写真实参考视频和真实候选视频资源。', '视频资源表至少有参考视频和候选视频。'],
'video:placeholder': ['商务/投放', 'video', '全表', '删除 example.com、示例、候选、待确认或人工待补等占位内容。', '预审不再出现 placeholder。'],
'video:real-reference': ['商务/投放', 'video', '资源角色/是否真实资源', '补至少一个真实参考视频,并将是否真实资源标为是。', '真实参考视频行数 >= 1。'],
'video:real-candidate': ['商务/投放', 'video', '资源角色/是否真实资源', '补至少一个真实候选视频,并将是否真实资源标为是。', '真实候选视频行数 >= 1。'],
'video:video-url': ['商务/投放', 'video', '视频链接', '补真实视频 URL;只有主页链接或接口 200 不算视频资源。', '真实资源中至少一行有真实视频 URL。'],
'video:video-evidence': ['商务/投放', 'video', '封面链接/字幕或ASR文本/帧图链接/内容摘要', '为真实视频补封面、ASR、帧图或正文证据。', '真实资源至少有一类可审计证据。']
};
const fallback = specs[key] || ['技术/AI', issueItem.section === 'video' ? 'video' : issueItem.section === 'review' ? 'review' : 'history', issueItem.type, issueItem.message, '对应 issue 消失。'];
return {
owner: fallback[0],
file: fallback[1],
field: fallback[2],
action: fallback[3],
acceptance: fallback[4]
};
}
function renderRepairCsv(actions) {
const header = ['优先级', '负责人', '区域', '问题类型', '文件', '字段', '修复动作', '通过标准'];
const rows = actions.map(item => [
item.priority,
item.owner,
item.section,
item.type,
item.file,
item.field,
item.action,
item.acceptance
]);
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 safeReadCsv(reader, file) {
try {
if (!file || !fs.existsSync(file)) return { ok: false, header: [], body: [], error: `文件不存在:${file}` };
const rows = reader(file);
return { ok: true, header: rows.header || [], body: rows.body || [] };
} catch (error) {
return { ok: false, header: [], body: [], error: error.message };
}
}
function headerStartsWith(header, required) {
return required.every((key, index) => header[index] === key);
}
function hasPlaceholderRows(rows) {
return rows.some(row => hasPlaceholderText(Object.values(row).join(' ')));
}
function hasPlaceholderText(text) {
return /(example\.com|示例|候选博主|候选视频标题|待确认|人工待补|填写客户原始 Brief|暂时只有封面|内部鉴权|密钥)/i.test(String(text || ''));
}
function isLikelyRealReference(input) {
return isLikelyRealUrl(input) && !hasPlaceholderText(input);
}
function isLikelyRealUrl(input) {
const text = value(input);
return /^https?:\/\/\S+/i.test(text) && !/example\.com/i.test(text);
}
function hasVideoEvidence(row) {
return isLikelyRealUrl(row['视频链接']) ||
isLikelyRealUrl(row['封面链接']) ||
isLikelyRealUrl(row['帧图链接']) ||
value(row['字幕或ASR文本']).length >= 12 ||
value(row['内容摘要']).length >= 12;
}
function isYes(input) {
return ['是', 'true', 'yes', '1'].includes(value(input).toLowerCase());
}
function issue(type, message) {
return { type, message };
}
function value(input) {
return String(input || '').trim();
}
function toNumberOrNull(input) {
const text = value(input);
if (!text) return null;
const numeric = Number(text);
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
}
function unique(values) {
return [...new Set(values.map(value).filter(Boolean))];
}
function passFail(value) {
return value ? '通过' : '未通过';
}
function escapeCell(input) {
return String(input || '').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) {
try {
main();
} catch (error) {
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
}
}
module.exports = {
buildIntakeReadinessSummary,
renderReport
};