customer-effect-audit.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { buildMetrics, readCsv } = require('./review-metrics');
  5. function main() {
  6. const args = parseArgs(process.argv.slice(2));
  7. const reviewCsv = args.reviewCsv || args.input || process.env.TIHAO_REVIEW_CSV || '';
  8. const historyAudit = args.historyAudit || process.env.TIHAO_HISTORY_AUDIT || '';
  9. 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]');
  10. const reviewPath = path.resolve(reviewCsv);
  11. const outputDir = path.resolve(args.output || process.env.TIHAO_CUSTOMER_EFFECT_OUTPUT || path.join(path.dirname(reviewPath), 'customer-effect-audit'));
  12. const strict = Boolean(args.strict || process.env.TIHAO_CUSTOMER_EFFECT_STRICT === 'true');
  13. const reviewRows = readCsv(reviewPath);
  14. const reviewMetrics = buildMetrics({ inputPath: reviewPath, rows: reviewRows });
  15. const history = historyAudit ? readJson(path.resolve(historyAudit)) : null;
  16. const summary = buildCustomerEffectSummary({
  17. reviewPath,
  18. reviewRows,
  19. reviewMetrics,
  20. history,
  21. historyAuditPath: historyAudit ? path.resolve(historyAudit) : '',
  22. currentManualSupplementCountArg: args.currentManualSupplementCount ?? args.currentManualAddedCount ?? args.manualSupplementCount
  23. });
  24. fs.mkdirSync(outputDir, { recursive: true });
  25. const jsonPath = path.join(outputDir, 'customer-effect-summary.json');
  26. const reportPath = path.join(outputDir, 'customer-effect-report.md');
  27. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  28. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  29. console.log(JSON.stringify({
  30. reviewCsv: reviewPath,
  31. historyAudit: summary.historyAuditPath || null,
  32. outputDir,
  33. json: jsonPath,
  34. report: reportPath,
  35. customerSelectedRate: summary.customerSelectedRate,
  36. referenceCustomerSelectedRate: summary.referenceCustomerSelectedRate,
  37. manualSupplementReductionRate: summary.manualSupplementReductionRate,
  38. overallPass: summary.acceptance.overallPass
  39. }, null, 2));
  40. if (strict && !summary.acceptance.overallPass) process.exitCode = 2;
  41. }
  42. function buildCustomerEffectSummary({ reviewPath, reviewRows, reviewMetrics, history, historyAuditPath, currentManualSupplementCountArg }) {
  43. const currentManualSupplement = resolveCurrentManualSupplementCount(reviewRows, currentManualSupplementCountArg);
  44. const manualBaseline = resolveManualSupplementBaseline(history);
  45. const manualSupplementReductionRate = manualBaseline.available && currentManualSupplement.available && manualBaseline.total > 0
  46. ? roundRatio((manualBaseline.total - currentManualSupplement.total) / manualBaseline.total)
  47. : null;
  48. const hasReferenceSelectionData = reviewMetrics.strategyComparison.reference.total > 0 &&
  49. reviewMetrics.strategyComparison.reference.selected > 0;
  50. const acceptance = {
  51. reviewMetricsOverallUsable: reviewMetrics.acceptance.overallPass,
  52. customerSelectedRateMeasured: reviewMetrics.selectedCount > 0,
  53. customerSelectedRate30Pass: reviewMetrics.selectedCount > 0 ? reviewMetrics.customerSelectedRate >= 0.3 : null,
  54. customerSelectedRate50Pass: reviewMetrics.selectedCount > 0 ? reviewMetrics.customerSelectedRate >= 0.5 : null,
  55. referenceCustomerSelectedRateMeasured: hasReferenceSelectionData,
  56. referenceCustomerSelectedRate40Pass: hasReferenceSelectionData ? reviewMetrics.strategyComparison.reference.customerSelectedRate >= 0.4 : null,
  57. historyReadyForCustomerEffectProof: Boolean(history?.acceptance?.readyForCustomerEffectProof),
  58. manualSupplementBaselineAvailable: manualBaseline.available,
  59. currentManualSupplementAvailable: currentManualSupplement.available,
  60. manualSupplementReduction50Pass: manualSupplementReductionRate === null ? null : manualSupplementReductionRate >= 0.5
  61. };
  62. acceptance.overallPass = acceptance.reviewMetricsOverallUsable &&
  63. acceptance.customerSelectedRate30Pass === true &&
  64. acceptance.referenceCustomerSelectedRate40Pass !== false &&
  65. acceptance.historyReadyForCustomerEffectProof &&
  66. acceptance.manualSupplementBaselineAvailable &&
  67. acceptance.currentManualSupplementAvailable &&
  68. acceptance.manualSupplementReduction50Pass === true;
  69. return {
  70. reviewPath,
  71. historyAuditPath,
  72. generatedAt: new Date().toISOString(),
  73. reviewMetrics: {
  74. total: reviewMetrics.total,
  75. labeled: reviewMetrics.labeled,
  76. positiveCount: reviewMetrics.positiveCount,
  77. negativeCount: reviewMetrics.negativeCount,
  78. selectedCount: reviewMetrics.selectedCount,
  79. businessUsableRate: reviewMetrics.businessUsableRate,
  80. offTargetHardFailRate: reviewMetrics.offTargetHardFailRate,
  81. customerSelectedRate: reviewMetrics.customerSelectedRate,
  82. referenceCustomerSelectedRate: reviewMetrics.strategyComparison.reference.customerSelectedRate,
  83. referenceSelectedCount: reviewMetrics.strategyComparison.reference.selected,
  84. keywordBusinessUsableRate: reviewMetrics.strategyComparison.keyword.businessUsableRate,
  85. referenceBusinessUsableRate: reviewMetrics.strategyComparison.reference.businessUsableRate,
  86. failureAttributionCoverage: reviewMetrics.failureAttributionCoverage,
  87. acceptance: reviewMetrics.acceptance
  88. },
  89. customerSelectedRate: reviewMetrics.customerSelectedRate,
  90. referenceCustomerSelectedRate: reviewMetrics.strategyComparison.reference.customerSelectedRate,
  91. manualSupplementBaseline: manualBaseline,
  92. currentManualSupplement,
  93. manualSupplementReductionRate,
  94. acceptance,
  95. missingEvidence: missingEvidence(acceptance)
  96. };
  97. }
  98. function resolveCurrentManualSupplementCount(rows, argValue) {
  99. const argNumber = toNumberOrNull(argValue);
  100. if (argNumber !== null) return { available: true, source: 'cli-arg', total: argNumber, byBrief: [] };
  101. const fields = ['本轮人工补号量', '当前人工补号量', '人工补号量', '人工补号数', 'manualSupplementCount', 'currentManualSupplementCount', 'manualAddedCount'];
  102. const byBrief = new Map();
  103. let total = 0;
  104. let found = false;
  105. for (const row of rows.body) {
  106. const value = firstNumber(row, fields);
  107. if (value === null) continue;
  108. found = true;
  109. const briefId = row['brief编号'] || '未命名brief';
  110. if (!byBrief.has(briefId)) byBrief.set(briefId, value);
  111. }
  112. for (const value of byBrief.values()) total += value;
  113. return {
  114. available: found,
  115. source: found ? 'review-csv' : '',
  116. total: found ? total : null,
  117. byBrief: [...byBrief.entries()].map(([briefId, count]) => ({ briefId, count }))
  118. };
  119. }
  120. function resolveManualSupplementBaseline(history) {
  121. if (!history) return { available: false, source: '', total: null, byBrief: [] };
  122. const values = [];
  123. for (const item of history.items || []) {
  124. if (item.manualSupplementBaseline !== null && item.manualSupplementBaseline !== undefined && item.manualSupplementBaseline !== '' && Number.isFinite(Number(item.manualSupplementBaseline))) {
  125. values.push({ briefId: item.id || item.name || path.basename(item.file || ''), count: Number(item.manualSupplementBaseline) });
  126. }
  127. }
  128. if (!values.length) return { available: false, source: 'history-audit', total: null, byBrief: [] };
  129. return {
  130. available: true,
  131. source: 'history-audit',
  132. total: values.reduce((sum, item) => sum + item.count, 0),
  133. byBrief: values
  134. };
  135. }
  136. function missingEvidence(acceptance) {
  137. const missing = [];
  138. if (!acceptance.customerSelectedRateMeasured) missing.push('缺少客户最终选择字段,不能证明客户选中率。');
  139. if (acceptance.customerSelectedRate30Pass === false) missing.push('客户选中率未达到 30%。');
  140. if (acceptance.customerSelectedRate50Pass === false) missing.push('客户选中率未达到长期 50%。');
  141. if (!acceptance.referenceCustomerSelectedRateMeasured) missing.push('缺少参考账号/主页/视频策略客户选择数据,不能证明参考链路 40%。');
  142. if (acceptance.referenceCustomerSelectedRate40Pass === false) missing.push('参考链路客户选中率未达到 40%。');
  143. if (!acceptance.historyReadyForCustomerEffectProof) missing.push('历史数据集未通过客户效果证明准备门禁。');
  144. if (!acceptance.manualSupplementBaselineAvailable) missing.push('缺少历史人工补号量基线。');
  145. if (!acceptance.currentManualSupplementAvailable) missing.push('缺少本轮人工补号量。');
  146. if (acceptance.manualSupplementReduction50Pass === false) missing.push('人工补号量减少未达到 50%。');
  147. return missing;
  148. }
  149. function renderReport(summary) {
  150. const lines = [
  151. '# 客户效果证明审计报告',
  152. '',
  153. `- 复核表:${summary.reviewPath}`,
  154. `- 历史审计:${summary.historyAuditPath || '未提供'}`,
  155. `- 生成时间:${summary.generatedAt}`,
  156. `- 总体验收:${summary.acceptance.overallPass ? '通过' : '未通过'}`,
  157. '',
  158. '## 核心门禁',
  159. '',
  160. '| 门禁 | 当前值 | 目标 | 状态 |',
  161. '| --- | ---: | ---: | --- |',
  162. `| 客户选中率 | ${measuredPct(summary.acceptance.customerSelectedRateMeasured, summary.customerSelectedRate)} | >= 30% | ${pendingPassFail(summary.acceptance.customerSelectedRate30Pass)} |`,
  163. `| 长期客户选中率 | ${measuredPct(summary.acceptance.customerSelectedRateMeasured, summary.customerSelectedRate)} | >= 50% | ${pendingPassFail(summary.acceptance.customerSelectedRate50Pass)} |`,
  164. `| 参考链路客户选中率 | ${measuredPct(summary.acceptance.referenceCustomerSelectedRateMeasured, summary.referenceCustomerSelectedRate)} | >= 40% | ${pendingPassFail(summary.acceptance.referenceCustomerSelectedRate40Pass)} |`,
  165. `| 历史数据可用于客户效果证明 | ${summary.acceptance.historyReadyForCustomerEffectProof ? '是' : '否'} | 是 | ${passFail(summary.acceptance.historyReadyForCustomerEffectProof)} |`,
  166. `| 历史人工补号基线 | ${summary.manualSupplementBaseline.available ? summary.manualSupplementBaseline.total : '缺失'} | 已提供 | ${passFail(summary.acceptance.manualSupplementBaselineAvailable)} |`,
  167. `| 本轮人工补号量 | ${summary.currentManualSupplement.available ? summary.currentManualSupplement.total : '缺失'} | 已提供 | ${passFail(summary.acceptance.currentManualSupplementAvailable)} |`,
  168. `| 人工补号减少率 | ${summary.manualSupplementReductionRate === null ? '待补数据' : pct(summary.manualSupplementReductionRate)} | >= 50% | ${pendingPassFail(summary.acceptance.manualSupplementReduction50Pass)} |`,
  169. '',
  170. '## 复核质量摘要',
  171. '',
  172. '| 指标 | 当前值 |',
  173. '| --- | ---: |',
  174. `| 已标注行数 | ${summary.reviewMetrics.labeled} |`,
  175. `| 商务可用率 | ${pct(summary.reviewMetrics.businessUsableRate)} |`,
  176. `| 负样本率 | ${pct(summary.reviewMetrics.offTargetHardFailRate)} |`,
  177. `| 负样本归因覆盖率 | ${pct(summary.reviewMetrics.failureAttributionCoverage)} |`,
  178. `| 参考链路商务可用率 | ${pct(summary.reviewMetrics.referenceBusinessUsableRate)} |`,
  179. `| 关键词基线商务可用率 | ${pct(summary.reviewMetrics.keywordBusinessUsableRate)} |`,
  180. '',
  181. '## 待补证据',
  182. '',
  183. ...(summary.missingEvidence.length ? summary.missingEvidence.map(item => `- ${item}`) : ['- 无']),
  184. '',
  185. '## 说明',
  186. '',
  187. '- 本报告只在客户最终选择、历史人工补号基线和本轮人工补号量同时存在时,才判断“人工补号量减少 50%”。',
  188. '- 只有客户选中率达到 30%,才能证明短期客户效果;达到 50% 才能证明长期目标。',
  189. '- 有参考账号/参考视频的 Brief,需要参考链路客户选中率达到 40%。'
  190. ];
  191. return lines.join('\n');
  192. }
  193. function firstNumber(row, fields) {
  194. for (const field of fields) {
  195. const value = toNumberOrNull(row[field]);
  196. if (value !== null) return value;
  197. }
  198. return null;
  199. }
  200. function toNumberOrNull(value) {
  201. if (value === undefined || value === null || value === '') return null;
  202. const numeric = Number(value);
  203. return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
  204. }
  205. function readJson(file) {
  206. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  207. }
  208. function parseArgs(argv) {
  209. const args = {};
  210. for (let i = 0; i < argv.length; i += 1) {
  211. const raw = argv[i];
  212. if (!raw.startsWith('--')) continue;
  213. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  214. const next = argv[i + 1];
  215. if (!next || next.startsWith('--')) args[key] = true;
  216. else {
  217. args[key] = next;
  218. i += 1;
  219. }
  220. }
  221. return args;
  222. }
  223. function roundRatio(value) {
  224. return Math.round(Number(value || 0) * 10000) / 10000;
  225. }
  226. function pct(value) {
  227. return `${Math.round(Number(value || 0) * 100)}%`;
  228. }
  229. function measuredPct(measured, value) {
  230. return measured ? pct(value) : '未标注';
  231. }
  232. function passFail(value) {
  233. return value ? '通过' : '未通过';
  234. }
  235. function pendingPassFail(value) {
  236. if (value === null) return '待补数据';
  237. return passFail(value);
  238. }
  239. function withBom(text) {
  240. return `\uFEFF${text}`;
  241. }
  242. if (require.main === module) {
  243. try {
  244. main();
  245. } catch (error) {
  246. console.error(error && error.stack ? error.stack : String(error));
  247. process.exit(1);
  248. }
  249. }
  250. module.exports = {
  251. buildCustomerEffectSummary,
  252. resolveCurrentManualSupplementCount,
  253. resolveManualSupplementBaseline
  254. };