#!/usr/bin/env node const fs = require('fs'); const path = require('path'); function main() { const args = parseArgs(process.argv.slice(2)); const input = args.input || args.dir || process.env.TIHAO_HISTORY_DATASET || ''; if (!input) throw new Error('Usage: node scripts/historical-dataset-audit.js --input [--output ] [--min-briefs 5] [--strict]'); const inputPath = path.resolve(input); const outputDir = path.resolve(args.output || process.env.TIHAO_HISTORY_AUDIT_OUTPUT || path.join(inputPath, 'audit-output')); const minBriefs = Number(args.minBriefs || process.env.TIHAO_HISTORY_MIN_BRIEFS || 5); const strict = Boolean(args.strict || process.env.TIHAO_HISTORY_AUDIT_STRICT === 'true'); const records = loadRecords(inputPath); const summary = auditRecords({ inputPath, records, minBriefs }); fs.mkdirSync(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, 'historical-dataset-audit.json'); const reportPath = path.join(outputDir, 'historical-dataset-audit.md'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); console.log(JSON.stringify({ input: inputPath, outputDir, json: jsonPath, report: reportPath, briefCount: summary.briefCount, readyForLongRun: summary.acceptance.readyForLongRun, missingCriticalCount: summary.missingCriticalCount }, null, 2)); if (strict && !summary.acceptance.readyForLongRun) process.exitCode = 2; } function loadRecords(inputPath) { if (!fs.existsSync(inputPath)) throw new Error(`History dataset not found: ${inputPath}`); const stat = fs.statSync(inputPath); const files = stat.isDirectory() ? fs.readdirSync(inputPath).filter(isHistoryRecordFile).map(name => path.join(inputPath, name)) : [inputPath]; return files.sort().map(file => { try { return { file, record: JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) }; } catch (error) { return { file, record: null, parseError: error.message }; } }); } function isHistoryRecordFile(name) { return name.endsWith('.json') && !name.endsWith('-summary.json') && name !== 'historical-dataset-audit.json' && name !== 'history-dataset-from-csv-summary.json'; } function auditRecords({ inputPath, records, minBriefs }) { const items = records.map(item => auditRecord(item)); const validItems = items.filter(item => item.parseOk); const categories = unique(validItems.map(item => item.category).filter(Boolean)); const briefCount = validItems.length; const withReference = validItems.filter(item => item.hasReference).length; const withManualFinalList = validItems.filter(item => item.hasManualFinalList).length; const withCustomerDecision = validItems.filter(item => item.hasCustomerDecision).length; const withRejectionReason = validItems.filter(item => item.hasRejectionReason).length; const withManualSupplementBaseline = validItems.filter(item => item.hasManualSupplementBaseline).length; const missingCriticalCount = items.filter(item => item.missingCritical.length).length; const acceptance = { parseOk: items.every(item => item.parseOk), minBriefsMet: briefCount >= minBriefs, allHaveBriefText: validItems.every(item => item.hasBriefText), allHaveManualFinalList: validItems.every(item => item.hasManualFinalList), allHaveCustomerDecision: validItems.every(item => item.hasCustomerDecision), allHaveFeedbackReason: validItems.every(item => item.hasRejectionReason || item.allSelected), allHaveManualSupplementBaseline: validItems.every(item => item.hasManualSupplementBaseline), categoryCoverageMet: categories.length >= Math.min(3, minBriefs), referenceCoverageMeasured: withReference > 0 }; acceptance.readyForLongRun = acceptance.parseOk && acceptance.minBriefsMet && acceptance.allHaveBriefText && acceptance.allHaveManualFinalList && acceptance.allHaveCustomerDecision && acceptance.allHaveFeedbackReason && acceptance.categoryCoverageMet; acceptance.readyForCustomerEffectProof = acceptance.readyForLongRun && acceptance.allHaveManualSupplementBaseline; return { inputPath, generatedAt: new Date().toISOString(), minBriefs, briefCount, categoryCount: categories.length, categories, withReference, withManualFinalList, withCustomerDecision, withRejectionReason, withManualSupplementBaseline, missingCriticalCount, acceptance, items }; } function auditRecord({ file, record, parseError }) { if (!record) { return { file, parseOk: false, parseError, missingCritical: ['JSON 无法解析'] }; } const briefText = record.briefText || record.brief || record.originalBrief || ''; const manualFinalList = array(record.manualFinalList || record.finalCreatorList || record.manualList); const customerFeedback = array(record.customerFeedback || record.customerDecisions || record.customerSelection); const manualSupplementBaseline = record.manualSupplementBaseline ?? record.manualSupplementCount ?? record.manualAddedCount ?? record.metrics?.manualSupplementCount; const hasCustomerDecision = customerFeedback.some(item => /选中|拒绝|通过|未选|淘汰|selected|rejected/i.test(String(item.finalDecision || item.decision || item.status || item['客户选择'] || ''))) || manualFinalList.some(item => /选中|拒绝|通过|未选|淘汰|selected|rejected/i.test(String(item.finalDecision || item.customerDecision || item['客户选择'] || ''))); const allSelected = customerFeedback.length > 0 && customerFeedback.every(item => /选中|通过|selected/i.test(String(item.finalDecision || item.decision || item.status || item['客户选择'] || ''))); const hasRejectionReason = customerFeedback.some(item => item.reason || item.feedbackReason || item.rejectReason || item['拒绝原因']) || manualFinalList.some(item => item.reason || item.feedbackReason || item.rejectReason || item['拒绝原因']); const hasReference = array(record.referenceLinks).length > 0 || array(record.referenceAccounts).length > 0 || array(record.referenceVideos).length > 0; const hasManualSupplementBaseline = Number.isFinite(Number(manualSupplementBaseline)) && Number(manualSupplementBaseline) >= 0; const missingCritical = []; if (!record.id && !record.name) missingCritical.push('缺少 id/name'); if (!briefText) missingCritical.push('缺少客户原始 Brief'); if (!manualFinalList.length) missingCritical.push('缺少人工最终提报名单'); if (!hasCustomerDecision) missingCritical.push('缺少客户最终选中/拒绝记录'); if (!hasRejectionReason && !allSelected) missingCritical.push('缺少拒绝原因或商务复核原因'); return { file, parseOk: true, id: record.id || record.name || path.basename(file, '.json'), name: record.name || record.id || '', category: record.category || record.input?.category || '', hasBriefText: Boolean(briefText), hasReference, manualFinalListCount: manualFinalList.length, customerFeedbackCount: customerFeedback.length, manualSupplementBaseline: hasManualSupplementBaseline ? Number(manualSupplementBaseline) : null, hasManualFinalList: manualFinalList.length > 0, hasCustomerDecision, hasRejectionReason, hasManualSupplementBaseline, allSelected, missingCritical }; } function renderReport(summary) { const lines = [ '# 历史提号数据集验收报告', '', `- 输入目录:${summary.inputPath}`, `- 生成时间:${summary.generatedAt}`, `- Brief 数:${summary.briefCount}`, `- 最低要求:${summary.minBriefs}`, `- 类目数:${summary.categoryCount}`, `- 是否可用于长期优化长跑:${summary.acceptance.readyForLongRun ? '是' : '否'}`, `- 是否可用于客户效果证明:${summary.acceptance.readyForCustomerEffectProof ? '是' : '否'}`, '', '## 验收门禁', '', '| 门禁 | 状态 |', '| --- | --- |', `| JSON 可解析 | ${passFail(summary.acceptance.parseOk)} |`, `| Brief 数达到要求 | ${passFail(summary.acceptance.minBriefsMet)} |`, `| 每个样本有原始 Brief | ${passFail(summary.acceptance.allHaveBriefText)} |`, `| 每个样本有人工最终名单 | ${passFail(summary.acceptance.allHaveManualFinalList)} |`, `| 每个样本有客户选中/拒绝记录 | ${passFail(summary.acceptance.allHaveCustomerDecision)} |`, `| 拒绝样本有原因 | ${passFail(summary.acceptance.allHaveFeedbackReason)} |`, `| 每个样本有人工补号量基线 | ${passFail(summary.acceptance.allHaveManualSupplementBaseline)} |`, `| 类目覆盖足够 | ${passFail(summary.acceptance.categoryCoverageMet)} |`, '', '## 样本明细', '', '| 样本 | 类目 | 参考账号/视频 | 人工名单 | 客户选择 | 拒绝/复核原因 | 人工补号基线 | 缺口 |', '| --- | --- | --- | ---: | --- | --- | ---: | --- |', ...summary.items.map(item => `| ${escapeCell(item.id || path.basename(item.file))} | ${escapeCell(item.category || '')} | ${item.hasReference ? '有' : '无'} | ${item.manualFinalListCount || 0} | ${item.hasCustomerDecision ? '有' : '无'} | ${item.hasRejectionReason || item.allSelected ? '有' : '无'} | ${item.hasManualSupplementBaseline ? item.manualSupplementBaseline : '缺失'} | ${escapeCell((item.missingCritical || []).join(';') || '无')} |`), '', '## 使用说明', '', '- 只有本报告显示“可用于长期优化长跑=是”,才适合拿来验证客户选中率 30%/50%。', '- 没有客户最终选择字段时,只能做商务复核通过率,不能宣称客户选中率。', '- 没有拒绝原因时,无法做失败样本归因,也无法稳定升级团队规则。', '- 没有人工补号量基线时,不能证明“人工补号量减少 50%”。' ]; return lines.join('\n'); } function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i += 1) { const raw = argv[i]; if (!raw.startsWith('--')) continue; const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[i + 1]; if (!next || next.startsWith('--')) args[key] = true; else { args[key] = next; i += 1; } } return args; } function array(value) { return Array.isArray(value) ? value : []; } function unique(values) { return [...new Set(values.filter(Boolean).map(String))]; } function passFail(value) { return value ? '通过' : '未通过'; } function escapeCell(value) { return String(value || '').replace(/\|/g, '/').replace(/\n/g, ' '); } 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 = { auditRecords, loadRecords };