#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { buildMetrics, readCsv } = require('./review-metrics'); function main() { const args = parseArgs(process.argv.slice(2)); const reviewCsv = args.reviewCsv || args.input || process.env.TIHAO_REVIEW_CSV || ''; const historyAudit = args.historyAudit || process.env.TIHAO_HISTORY_AUDIT || ''; if (!reviewCsv) throw new Error('Usage: node scripts/customer-effect-audit.js --review-csv [--history-audit ] [--current-manual-supplement-count ] [--output ] [--strict]'); const reviewPath = path.resolve(reviewCsv); const outputDir = path.resolve(args.output || process.env.TIHAO_CUSTOMER_EFFECT_OUTPUT || path.join(path.dirname(reviewPath), 'customer-effect-audit')); const strict = Boolean(args.strict || process.env.TIHAO_CUSTOMER_EFFECT_STRICT === 'true'); const reviewRows = readCsv(reviewPath); const reviewMetrics = buildMetrics({ inputPath: reviewPath, rows: reviewRows }); const history = historyAudit ? readJson(path.resolve(historyAudit)) : null; const summary = buildCustomerEffectSummary({ reviewPath, reviewRows, reviewMetrics, history, historyAuditPath: historyAudit ? path.resolve(historyAudit) : '', currentManualSupplementCountArg: args.currentManualSupplementCount ?? args.currentManualAddedCount ?? args.manualSupplementCount }); fs.mkdirSync(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, 'customer-effect-summary.json'); const reportPath = path.join(outputDir, 'customer-effect-report.md'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); console.log(JSON.stringify({ reviewCsv: reviewPath, historyAudit: summary.historyAuditPath || null, outputDir, json: jsonPath, report: reportPath, customerSelectedRate: summary.customerSelectedRate, referenceCustomerSelectedRate: summary.referenceCustomerSelectedRate, manualSupplementReductionRate: summary.manualSupplementReductionRate, overallPass: summary.acceptance.overallPass }, null, 2)); if (strict && !summary.acceptance.overallPass) process.exitCode = 2; } function buildCustomerEffectSummary({ reviewPath, reviewRows, reviewMetrics, history, historyAuditPath, currentManualSupplementCountArg }) { const currentManualSupplement = resolveCurrentManualSupplementCount(reviewRows, currentManualSupplementCountArg); const manualBaseline = resolveManualSupplementBaseline(history); const manualSupplementReductionRate = manualBaseline.available && currentManualSupplement.available && manualBaseline.total > 0 ? roundRatio((manualBaseline.total - currentManualSupplement.total) / manualBaseline.total) : null; const hasReferenceSelectionData = reviewMetrics.strategyComparison.reference.total > 0 && reviewMetrics.strategyComparison.reference.selected > 0; const acceptance = { reviewMetricsOverallUsable: reviewMetrics.acceptance.overallPass, customerSelectedRateMeasured: reviewMetrics.selectedCount > 0, customerSelectedRate30Pass: reviewMetrics.selectedCount > 0 ? reviewMetrics.customerSelectedRate >= 0.3 : null, customerSelectedRate50Pass: reviewMetrics.selectedCount > 0 ? reviewMetrics.customerSelectedRate >= 0.5 : null, referenceCustomerSelectedRateMeasured: hasReferenceSelectionData, referenceCustomerSelectedRate40Pass: hasReferenceSelectionData ? reviewMetrics.strategyComparison.reference.customerSelectedRate >= 0.4 : null, historyReadyForCustomerEffectProof: Boolean(history?.acceptance?.readyForCustomerEffectProof), manualSupplementBaselineAvailable: manualBaseline.available, currentManualSupplementAvailable: currentManualSupplement.available, manualSupplementReduction50Pass: manualSupplementReductionRate === null ? null : manualSupplementReductionRate >= 0.5 }; acceptance.overallPass = acceptance.reviewMetricsOverallUsable && acceptance.customerSelectedRate30Pass === true && acceptance.referenceCustomerSelectedRate40Pass !== false && acceptance.historyReadyForCustomerEffectProof && acceptance.manualSupplementBaselineAvailable && acceptance.currentManualSupplementAvailable && acceptance.manualSupplementReduction50Pass === true; return { reviewPath, historyAuditPath, generatedAt: new Date().toISOString(), reviewMetrics: { total: reviewMetrics.total, labeled: reviewMetrics.labeled, positiveCount: reviewMetrics.positiveCount, negativeCount: reviewMetrics.negativeCount, selectedCount: reviewMetrics.selectedCount, businessUsableRate: reviewMetrics.businessUsableRate, offTargetHardFailRate: reviewMetrics.offTargetHardFailRate, customerSelectedRate: reviewMetrics.customerSelectedRate, referenceCustomerSelectedRate: reviewMetrics.strategyComparison.reference.customerSelectedRate, referenceSelectedCount: reviewMetrics.strategyComparison.reference.selected, keywordBusinessUsableRate: reviewMetrics.strategyComparison.keyword.businessUsableRate, referenceBusinessUsableRate: reviewMetrics.strategyComparison.reference.businessUsableRate, failureAttributionCoverage: reviewMetrics.failureAttributionCoverage, acceptance: reviewMetrics.acceptance }, customerSelectedRate: reviewMetrics.customerSelectedRate, referenceCustomerSelectedRate: reviewMetrics.strategyComparison.reference.customerSelectedRate, manualSupplementBaseline: manualBaseline, currentManualSupplement, manualSupplementReductionRate, acceptance, missingEvidence: missingEvidence(acceptance) }; } function resolveCurrentManualSupplementCount(rows, argValue) { const argNumber = toNumberOrNull(argValue); if (argNumber !== null) return { available: true, source: 'cli-arg', total: argNumber, byBrief: [] }; const fields = ['本轮人工补号量', '当前人工补号量', '人工补号量', '人工补号数', 'manualSupplementCount', 'currentManualSupplementCount', 'manualAddedCount']; const byBrief = new Map(); let total = 0; let found = false; for (const row of rows.body) { const value = firstNumber(row, fields); if (value === null) continue; found = true; const briefId = row['brief编号'] || '未命名brief'; if (!byBrief.has(briefId)) byBrief.set(briefId, value); } for (const value of byBrief.values()) total += value; return { available: found, source: found ? 'review-csv' : '', total: found ? total : null, byBrief: [...byBrief.entries()].map(([briefId, count]) => ({ briefId, count })) }; } function resolveManualSupplementBaseline(history) { if (!history) return { available: false, source: '', total: null, byBrief: [] }; const values = []; for (const item of history.items || []) { if (item.manualSupplementBaseline !== null && item.manualSupplementBaseline !== undefined && item.manualSupplementBaseline !== '' && Number.isFinite(Number(item.manualSupplementBaseline))) { values.push({ briefId: item.id || item.name || path.basename(item.file || ''), count: Number(item.manualSupplementBaseline) }); } } if (!values.length) return { available: false, source: 'history-audit', total: null, byBrief: [] }; return { available: true, source: 'history-audit', total: values.reduce((sum, item) => sum + item.count, 0), byBrief: values }; } function missingEvidence(acceptance) { const missing = []; if (!acceptance.customerSelectedRateMeasured) missing.push('缺少客户最终选择字段,不能证明客户选中率。'); if (acceptance.customerSelectedRate30Pass === false) missing.push('客户选中率未达到 30%。'); if (acceptance.customerSelectedRate50Pass === false) missing.push('客户选中率未达到长期 50%。'); if (!acceptance.referenceCustomerSelectedRateMeasured) missing.push('缺少参考账号/主页/视频策略客户选择数据,不能证明参考链路 40%。'); if (acceptance.referenceCustomerSelectedRate40Pass === false) missing.push('参考链路客户选中率未达到 40%。'); if (!acceptance.historyReadyForCustomerEffectProof) missing.push('历史数据集未通过客户效果证明准备门禁。'); if (!acceptance.manualSupplementBaselineAvailable) missing.push('缺少历史人工补号量基线。'); if (!acceptance.currentManualSupplementAvailable) missing.push('缺少本轮人工补号量。'); if (acceptance.manualSupplementReduction50Pass === false) missing.push('人工补号量减少未达到 50%。'); return missing; } function renderReport(summary) { const lines = [ '# 客户效果证明审计报告', '', `- 复核表:${summary.reviewPath}`, `- 历史审计:${summary.historyAuditPath || '未提供'}`, `- 生成时间:${summary.generatedAt}`, `- 总体验收:${summary.acceptance.overallPass ? '通过' : '未通过'}`, '', '## 核心门禁', '', '| 门禁 | 当前值 | 目标 | 状态 |', '| --- | ---: | ---: | --- |', `| 客户选中率 | ${measuredPct(summary.acceptance.customerSelectedRateMeasured, summary.customerSelectedRate)} | >= 30% | ${pendingPassFail(summary.acceptance.customerSelectedRate30Pass)} |`, `| 长期客户选中率 | ${measuredPct(summary.acceptance.customerSelectedRateMeasured, summary.customerSelectedRate)} | >= 50% | ${pendingPassFail(summary.acceptance.customerSelectedRate50Pass)} |`, `| 参考链路客户选中率 | ${measuredPct(summary.acceptance.referenceCustomerSelectedRateMeasured, summary.referenceCustomerSelectedRate)} | >= 40% | ${pendingPassFail(summary.acceptance.referenceCustomerSelectedRate40Pass)} |`, `| 历史数据可用于客户效果证明 | ${summary.acceptance.historyReadyForCustomerEffectProof ? '是' : '否'} | 是 | ${passFail(summary.acceptance.historyReadyForCustomerEffectProof)} |`, `| 历史人工补号基线 | ${summary.manualSupplementBaseline.available ? summary.manualSupplementBaseline.total : '缺失'} | 已提供 | ${passFail(summary.acceptance.manualSupplementBaselineAvailable)} |`, `| 本轮人工补号量 | ${summary.currentManualSupplement.available ? summary.currentManualSupplement.total : '缺失'} | 已提供 | ${passFail(summary.acceptance.currentManualSupplementAvailable)} |`, `| 人工补号减少率 | ${summary.manualSupplementReductionRate === null ? '待补数据' : pct(summary.manualSupplementReductionRate)} | >= 50% | ${pendingPassFail(summary.acceptance.manualSupplementReduction50Pass)} |`, '', '## 复核质量摘要', '', '| 指标 | 当前值 |', '| --- | ---: |', `| 已标注行数 | ${summary.reviewMetrics.labeled} |`, `| 商务可用率 | ${pct(summary.reviewMetrics.businessUsableRate)} |`, `| 负样本率 | ${pct(summary.reviewMetrics.offTargetHardFailRate)} |`, `| 负样本归因覆盖率 | ${pct(summary.reviewMetrics.failureAttributionCoverage)} |`, `| 参考链路商务可用率 | ${pct(summary.reviewMetrics.referenceBusinessUsableRate)} |`, `| 关键词基线商务可用率 | ${pct(summary.reviewMetrics.keywordBusinessUsableRate)} |`, '', '## 待补证据', '', ...(summary.missingEvidence.length ? summary.missingEvidence.map(item => `- ${item}`) : ['- 无']), '', '## 说明', '', '- 本报告只在客户最终选择、历史人工补号基线和本轮人工补号量同时存在时,才判断“人工补号量减少 50%”。', '- 只有客户选中率达到 30%,才能证明短期客户效果;达到 50% 才能证明长期目标。', '- 有参考账号/参考视频的 Brief,需要参考链路客户选中率达到 40%。' ]; return lines.join('\n'); } function firstNumber(row, fields) { for (const field of fields) { const value = toNumberOrNull(row[field]); if (value !== null) return value; } return null; } function toNumberOrNull(value) { if (value === undefined || value === null || value === '') return null; const numeric = Number(value); return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; } function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } 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 roundRatio(value) { return Math.round(Number(value || 0) * 10000) / 10000; } function pct(value) { return `${Math.round(Number(value || 0) * 100)}%`; } function measuredPct(measured, value) { return measured ? pct(value) : '未标注'; } function passFail(value) { return value ? '通过' : '未通过'; } function pendingPassFail(value) { if (value === null) return '待补数据'; return passFail(value); } 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 = { buildCustomerEffectSummary, resolveCurrentManualSupplementCount, resolveManualSupplementBaseline };