#!/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 };