| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 |
- #!/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 <reviewed.csv> [--history-audit <historical-dataset-audit.json>] [--current-manual-supplement-count <n>] [--output <dir>] [--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
- };
|