review-metrics.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const POSITIVE_LABELS = new Set(['可直接发客户', '商务复核']);
  5. const NEGATIVE_LABELS = new Set(['跑偏', '硬性规则违规', '调性不符', '主页质感不符', '参考账号不像']);
  6. const SELECTED_LABELS = new Set(['客户选中', '已选中', '选中', '客户通过']);
  7. const ATTRIBUTION_FIELDS = ['归因类型', '失败归因', '归因'];
  8. const REASON_FIELDS = ['反馈原因', '拒绝原因', '复核备注', '备注'];
  9. const REFERENCE_STRATEGIES = new Set(['reference-account', 'homepage-evidence', 'video-enhanced']);
  10. const KEYWORD_BASELINE_STRATEGIES = new Set(['baseline-live', 'brief-only', 'keyword-only']);
  11. const HEADER = [
  12. 'brief编号',
  13. '策略',
  14. '排名',
  15. '平台',
  16. '博主名称',
  17. '综合分',
  18. 'brief匹配分',
  19. '参考风格分',
  20. '主页证据分',
  21. '视觉质感分',
  22. '调性一致分',
  23. '证据加分',
  24. '证据风险扣分',
  25. '推荐理由',
  26. '风险提示',
  27. '主页链接',
  28. '人工复核标签'
  29. ];
  30. function main() {
  31. const args = parseArgs(process.argv.slice(2));
  32. const input = args.input || args.csv || args.review || process.env.TIHAO_REVIEW_CSV || '';
  33. if (!input) throw new Error('Usage: node scripts/review-metrics.js --input <reviewed.csv> [--output <dir>] [--strict]');
  34. const inputPath = path.resolve(input);
  35. const outputDir = path.resolve(args.output || process.env.TIHAO_REVIEW_OUTPUT || path.join(path.dirname(inputPath), 'review-metrics'));
  36. const strict = Boolean(args.strict || process.env.TIHAO_REVIEW_STRICT === 'true');
  37. const rows = readCsv(inputPath);
  38. const metrics = buildMetrics({ inputPath, rows });
  39. const report = renderReport(metrics);
  40. fs.mkdirSync(outputDir, { recursive: true });
  41. const jsonPath = path.join(outputDir, 'review-metrics-summary.json');
  42. const mdPath = path.join(outputDir, 'review-metrics-report.md');
  43. fs.writeFileSync(jsonPath, JSON.stringify(metrics, null, 2), 'utf8');
  44. fs.writeFileSync(mdPath, withBom(report), 'utf8');
  45. console.log(JSON.stringify({
  46. input: inputPath,
  47. outputDir,
  48. json: jsonPath,
  49. report: mdPath,
  50. total: metrics.total,
  51. labeled: metrics.labeled,
  52. businessUsableRate: metrics.businessUsableRate,
  53. offTargetHardFailRate: metrics.offTargetHardFailRate,
  54. customerSelectedRate: metrics.customerSelectedRate,
  55. failureAttributionCoverage: metrics.failureAttributionCoverage,
  56. pass: metrics.acceptance.overallPass
  57. }, null, 2));
  58. if (strict && !metrics.acceptance.overallPass) process.exitCode = 2;
  59. }
  60. function buildMetrics({ inputPath, rows }) {
  61. const headerOk = HEADER.every((key, index) => rows.header[index] === key);
  62. const body = rows.body;
  63. const labeledRows = body.filter(row => normalizeLabel(row['人工复核标签']));
  64. const positiveRows = labeledRows.filter(row => POSITIVE_LABELS.has(normalizeLabel(row['人工复核标签'])));
  65. const negativeRows = labeledRows.filter(row => NEGATIVE_LABELS.has(normalizeLabel(row['人工复核标签'])));
  66. const selectedRows = labeledRows.filter(row => SELECTED_LABELS.has(normalizeLabel(row['客户选择'] || row['客户选中'] || row['最终结果'] || row['人工复核标签'])));
  67. const attribution = summarizeFailureAttribution(negativeRows);
  68. const duplicateKeyCount = countDuplicateKeys(body);
  69. const rankIssues = findRankIssues(body);
  70. const byBrief = summarizeByBrief(labeledRows);
  71. const byStrategy = summarizeByStrategy(labeledRows);
  72. const strategyComparison = compareReferenceAndKeywordStrategies(byStrategy);
  73. const total = body.length;
  74. const labeled = labeledRows.length;
  75. const metrics = {
  76. inputPath,
  77. generatedAt: new Date().toISOString(),
  78. headerOk,
  79. total,
  80. labeled,
  81. unlabeled: total - labeled,
  82. positiveCount: positiveRows.length,
  83. negativeCount: negativeRows.length,
  84. selectedCount: selectedRows.length,
  85. businessUsableRate: ratio(positiveRows.length, Math.max(labeled, 1)),
  86. offTargetHardFailRate: ratio(negativeRows.length, Math.max(labeled, 1)),
  87. customerSelectedRate: ratio(selectedRows.length, Math.max(labeled, 1)),
  88. failureAttribution: attribution,
  89. failureAttributionCoverage: ratio(attribution.attributedCount, Math.max(attribution.failureRows, 1)),
  90. duplicateKeyCount,
  91. rankContinuous: rankIssues.length === 0,
  92. rankIssues,
  93. byBrief,
  94. byStrategy,
  95. strategyComparison
  96. };
  97. metrics.acceptance = {
  98. headerOk,
  99. duplicateKeyCountZero: duplicateKeyCount === 0,
  100. rankContinuous: metrics.rankContinuous,
  101. enoughLabeledRows: labeled > 0,
  102. businessUsableRatePass: metrics.businessUsableRate >= 0.6,
  103. offTargetHardFailRatePass: metrics.offTargetHardFailRate <= 0.1,
  104. customerSelectedRateMeasured: selectedRows.length > 0,
  105. customerSelectedRatePass: selectedRows.length === 0 ? null : metrics.customerSelectedRate >= 0.3,
  106. referenceCustomerSelectedRatePass: strategyComparison.reference.selected === 0 ? null : strategyComparison.reference.customerSelectedRate >= 0.4,
  107. referencePassRateHigherThanKeyword: strategyComparison.keyword.total === 0 || strategyComparison.reference.total === 0
  108. ? null
  109. : strategyComparison.reference.businessUsableRate > strategyComparison.keyword.businessUsableRate,
  110. failureAttributionCoveragePass: attribution.failureRows === 0 || attribution.missingAttributionRows.length === 0
  111. };
  112. metrics.acceptance.overallPass = metrics.acceptance.headerOk &&
  113. metrics.acceptance.duplicateKeyCountZero &&
  114. metrics.acceptance.rankContinuous &&
  115. metrics.acceptance.enoughLabeledRows &&
  116. metrics.acceptance.businessUsableRatePass &&
  117. metrics.acceptance.offTargetHardFailRatePass &&
  118. metrics.acceptance.failureAttributionCoveragePass &&
  119. (metrics.acceptance.customerSelectedRatePass !== false) &&
  120. (metrics.acceptance.referenceCustomerSelectedRatePass !== false) &&
  121. (metrics.acceptance.referencePassRateHigherThanKeyword !== false);
  122. return metrics;
  123. }
  124. function summarizeFailureAttribution(rows) {
  125. const byAttribution = {};
  126. const missingAttributionRows = [];
  127. let attributedCount = 0;
  128. for (const row of rows) {
  129. const attribution = firstNonEmpty(row, ATTRIBUTION_FIELDS);
  130. const reason = firstNonEmpty(row, REASON_FIELDS);
  131. if (attribution) {
  132. attributedCount += 1;
  133. byAttribution[attribution] = (byAttribution[attribution] || 0) + 1;
  134. } else {
  135. missingAttributionRows.push({
  136. brief编号: row['brief编号'] || '',
  137. 策略: row['策略'] || '',
  138. 排名: row['排名'] || '',
  139. 平台: row['平台'] || '',
  140. 博主名称: row['博主名称'] || '',
  141. 人工复核标签: row['人工复核标签'] || '',
  142. 主页链接: row['主页链接'] || '',
  143. 反馈原因: reason
  144. });
  145. }
  146. }
  147. return {
  148. failureRows: rows.length,
  149. attributedCount,
  150. missingAttributionCount: missingAttributionRows.length,
  151. byAttribution,
  152. missingAttributionRows
  153. };
  154. }
  155. function firstNonEmpty(row, fields) {
  156. for (const field of fields) {
  157. const value = normalizeLabel(row[field]);
  158. if (value) return value;
  159. }
  160. return '';
  161. }
  162. function summarizeByBrief(rows) {
  163. const groups = new Map();
  164. for (const row of rows) {
  165. const briefId = row['brief编号'] || '未命名brief';
  166. const group = groups.get(briefId) || { briefId, total: 0, positive: 0, negative: 0, selected: 0, byLabel: {} };
  167. const label = normalizeLabel(row['人工复核标签']) || '未标注';
  168. group.total += 1;
  169. if (POSITIVE_LABELS.has(label)) group.positive += 1;
  170. if (NEGATIVE_LABELS.has(label)) group.negative += 1;
  171. if (SELECTED_LABELS.has(normalizeLabel(row['客户选择'] || row['客户选中'] || row['最终结果'] || row['人工复核标签']))) group.selected += 1;
  172. group.byLabel[label] = (group.byLabel[label] || 0) + 1;
  173. groups.set(briefId, group);
  174. }
  175. return [...groups.values()].map(group => ({
  176. ...group,
  177. businessUsableRate: ratio(group.positive, Math.max(group.total, 1)),
  178. offTargetHardFailRate: ratio(group.negative, Math.max(group.total, 1)),
  179. customerSelectedRate: ratio(group.selected, Math.max(group.total, 1))
  180. }));
  181. }
  182. function summarizeByStrategy(rows) {
  183. const groups = new Map();
  184. for (const row of rows) {
  185. const strategy = normalizeStrategy(row['策略']);
  186. const group = groups.get(strategy) || { strategy, total: 0, positive: 0, negative: 0, selected: 0, byLabel: {} };
  187. const label = normalizeLabel(row['人工复核标签']) || '未标注';
  188. group.total += 1;
  189. if (POSITIVE_LABELS.has(label)) group.positive += 1;
  190. if (NEGATIVE_LABELS.has(label)) group.negative += 1;
  191. if (SELECTED_LABELS.has(normalizeLabel(row['客户选择'] || row['客户选中'] || row['最终结果'] || row['人工复核标签']))) group.selected += 1;
  192. group.byLabel[label] = (group.byLabel[label] || 0) + 1;
  193. groups.set(strategy, group);
  194. }
  195. return [...groups.values()].map(group => ({
  196. ...group,
  197. businessUsableRate: ratio(group.positive, Math.max(group.total, 1)),
  198. offTargetHardFailRate: ratio(group.negative, Math.max(group.total, 1)),
  199. customerSelectedRate: ratio(group.selected, Math.max(group.total, 1))
  200. }));
  201. }
  202. function compareReferenceAndKeywordStrategies(byStrategy) {
  203. const referenceRows = byStrategy.filter(item => REFERENCE_STRATEGIES.has(item.strategy));
  204. const keywordRows = byStrategy.filter(item => KEYWORD_BASELINE_STRATEGIES.has(item.strategy));
  205. return {
  206. reference: aggregateStrategyGroup('reference', referenceRows),
  207. keyword: aggregateStrategyGroup('keyword-baseline', keywordRows)
  208. };
  209. }
  210. function aggregateStrategyGroup(name, groups) {
  211. const total = groups.reduce((sum, item) => sum + item.total, 0);
  212. const positive = groups.reduce((sum, item) => sum + item.positive, 0);
  213. const negative = groups.reduce((sum, item) => sum + item.negative, 0);
  214. const selected = groups.reduce((sum, item) => sum + item.selected, 0);
  215. return {
  216. name,
  217. strategies: groups.map(item => item.strategy),
  218. total,
  219. positive,
  220. negative,
  221. selected,
  222. businessUsableRate: ratio(positive, Math.max(total, 1)),
  223. offTargetHardFailRate: ratio(negative, Math.max(total, 1)),
  224. customerSelectedRate: ratio(selected, Math.max(total, 1))
  225. };
  226. }
  227. function countDuplicateKeys(rows) {
  228. const seen = new Set();
  229. let duplicates = 0;
  230. for (const row of rows) {
  231. const key = row['主页链接']
  232. ? `${row['brief编号']}|${row['平台']}|${row['主页链接']}`
  233. : `${row['brief编号']}|${row['平台']}|${row['博主名称']}`;
  234. if (seen.has(key)) duplicates += 1;
  235. seen.add(key);
  236. }
  237. return duplicates;
  238. }
  239. function findRankIssues(rows) {
  240. const groups = new Map();
  241. for (const row of rows) {
  242. const briefId = row['brief编号'] || '未命名brief';
  243. if (!groups.has(briefId)) groups.set(briefId, []);
  244. groups.get(briefId).push(Number(row['排名']));
  245. }
  246. const issues = [];
  247. for (const [briefId, ranks] of groups.entries()) {
  248. const ok = ranks.every((rank, index) => rank === index + 1);
  249. if (!ok) issues.push({ briefId, ranks: ranks.join(',') });
  250. }
  251. return issues;
  252. }
  253. function renderReport(metrics) {
  254. const lines = [
  255. '# 人工复核质量指标报告',
  256. '',
  257. `- 输入文件:${metrics.inputPath}`,
  258. `- 生成时间:${metrics.generatedAt}`,
  259. `- 总行数:${metrics.total}`,
  260. `- 已标注:${metrics.labeled}`,
  261. `- 未标注:${metrics.unlabeled}`,
  262. `- 总体验收:${metrics.acceptance.overallPass ? '通过' : '未通过'}`,
  263. '',
  264. '## 核心指标',
  265. '',
  266. '| 指标 | 当前值 | 目标 | 状态 |',
  267. '| --- | ---: | ---: | --- |',
  268. `| 商务可用率 | ${pct(metrics.businessUsableRate)} | >= 60% | ${passFail(metrics.acceptance.businessUsableRatePass)} |`,
  269. `| 负样本率 | ${pct(metrics.offTargetHardFailRate)} | <= 10% | ${passFail(metrics.acceptance.offTargetHardFailRatePass)} |`,
  270. `| 客户选中率 | ${metrics.selectedCount ? pct(metrics.customerSelectedRate) : '未标注'} | >= 30% | ${metrics.acceptance.customerSelectedRatePass === null ? '待补客户选择' : passFail(metrics.acceptance.customerSelectedRatePass)} |`,
  271. `| 负样本归因覆盖率 | ${metrics.failureAttribution.failureRows ? pct(metrics.failureAttributionCoverage) : '无负样本'} | 100% | ${passFail(metrics.acceptance.failureAttributionCoveragePass)} |`,
  272. `| 参考链路客户选中率 | ${metrics.strategyComparison.reference.selected ? pct(metrics.strategyComparison.reference.customerSelectedRate) : '未标注'} | >= 40% | ${metrics.acceptance.referenceCustomerSelectedRatePass === null ? '待补客户选择' : passFail(metrics.acceptance.referenceCustomerSelectedRatePass)} |`,
  273. `| 参考链路通过率高于关键词基线 | ${formatReferenceVsKeyword(metrics.strategyComparison)} | > 关键词基线 | ${metrics.acceptance.referencePassRateHigherThanKeyword === null ? '待补对照组' : passFail(metrics.acceptance.referencePassRateHigherThanKeyword)} |`,
  274. `| 重复键 | ${metrics.duplicateKeyCount} | 0 | ${passFail(metrics.acceptance.duplicateKeyCountZero)} |`,
  275. `| 排名连续 | ${metrics.rankContinuous ? '是' : '否'} | 是 | ${passFail(metrics.acceptance.rankContinuous)} |`,
  276. '',
  277. '## 分 Brief 指标',
  278. '',
  279. '| Brief | 已标注 | 商务可用率 | 负样本率 | 客户选中率 |',
  280. '| --- | ---: | ---: | ---: | ---: |',
  281. ...metrics.byBrief.map(item => `| ${escapeCell(item.briefId)} | ${item.total} | ${pct(item.businessUsableRate)} | ${pct(item.offTargetHardFailRate)} | ${item.selected ? pct(item.customerSelectedRate) : '未标注'} |`),
  282. '',
  283. '## 分策略指标',
  284. '',
  285. '| 策略 | 已标注 | 商务可用率 | 负样本率 | 客户选中率 |',
  286. '| --- | ---: | ---: | ---: | ---: |',
  287. ...metrics.byStrategy.map(item => `| ${escapeCell(item.strategy)} | ${item.total} | ${pct(item.businessUsableRate)} | ${pct(item.offTargetHardFailRate)} | ${item.selected ? pct(item.customerSelectedRate) : '未标注'} |`),
  288. '',
  289. '## 参考链路对照',
  290. '',
  291. '| 组别 | 策略 | 已标注 | 商务可用率 | 客户选中率 |',
  292. '| --- | --- | ---: | ---: | ---: |',
  293. `| 参考/主页/视频增强 | ${escapeCell(metrics.strategyComparison.reference.strategies.join('、') || '无')} | ${metrics.strategyComparison.reference.total} | ${pct(metrics.strategyComparison.reference.businessUsableRate)} | ${metrics.strategyComparison.reference.selected ? pct(metrics.strategyComparison.reference.customerSelectedRate) : '未标注'} |`,
  294. `| 关键词/基础基线 | ${escapeCell(metrics.strategyComparison.keyword.strategies.join('、') || '无')} | ${metrics.strategyComparison.keyword.total} | ${pct(metrics.strategyComparison.keyword.businessUsableRate)} | ${metrics.strategyComparison.keyword.selected ? pct(metrics.strategyComparison.keyword.customerSelectedRate) : '未标注'} |`,
  295. '',
  296. '## 负样本归因',
  297. '',
  298. `- 负样本数:${metrics.failureAttribution.failureRows}`,
  299. `- 已归因:${metrics.failureAttribution.attributedCount}`,
  300. `- 缺归因:${metrics.failureAttribution.missingAttributionCount}`,
  301. '',
  302. '| 归因类型 | 数量 |',
  303. '| --- | ---: |',
  304. ...Object.entries(metrics.failureAttribution.byAttribution).map(([key, value]) => `| ${escapeCell(key)} | ${value} |`),
  305. ...(Object.keys(metrics.failureAttribution.byAttribution).length ? [] : ['| 无 | 0 |']),
  306. '',
  307. '## 缺归因负样本',
  308. '',
  309. '| Brief | 排名 | 平台 | 博主名称 | 标签 | 反馈原因 |',
  310. '| --- | ---: | --- | --- | --- | --- |',
  311. ...metrics.failureAttribution.missingAttributionRows.slice(0, 50).map(row => `| ${escapeCell(row['brief编号'])} | ${escapeCell(row['排名'])} | ${escapeCell(row['平台'])} | ${escapeCell(row['博主名称'])} | ${escapeCell(row['人工复核标签'])} | ${escapeCell(row['反馈原因'])} |`),
  312. ...(metrics.failureAttribution.missingAttributionRows.length ? [] : ['| 无 | | | | | |']),
  313. '',
  314. '## 结论',
  315. '',
  316. '- “可直接发客户 + 商务复核”用于衡量短期商务可用率。',
  317. '- 负样本标签用于衡量需求解析、隐性规则、主页质感、参考风格和调性命中问题。',
  318. '- 负样本必须填写“归因类型/失败归因/归因”,否则不能进入可反哺优化的复盘样本。',
  319. '- 客户选中率需要客户最终选择字段;没有该字段时不能宣称已达到 30%/50%。',
  320. '- 参考链路是否优于纯关键词召回,必须同时存在参考策略和关键词/基础基线策略的标注样本才能判断。'
  321. ];
  322. return lines.join('\n');
  323. }
  324. function readCsv(file) {
  325. const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
  326. const lines = text.split(/\r?\n/).filter(Boolean);
  327. const header = parseCsvLine(lines.shift() || '');
  328. return {
  329. header,
  330. body: lines.map(line => {
  331. const cells = parseCsvLine(line);
  332. const row = {};
  333. header.forEach((key, index) => {
  334. row[key] = cells[index] || '';
  335. });
  336. return row;
  337. })
  338. };
  339. }
  340. function parseCsvLine(line) {
  341. const cells = [];
  342. let current = '';
  343. let quoted = false;
  344. for (let i = 0; i < line.length; i += 1) {
  345. const char = line[i];
  346. if (char === '"' && quoted && line[i + 1] === '"') {
  347. current += '"';
  348. i += 1;
  349. } else if (char === '"') quoted = !quoted;
  350. else if (char === ',' && !quoted) {
  351. cells.push(current);
  352. current = '';
  353. } else current += char;
  354. }
  355. cells.push(current);
  356. return cells;
  357. }
  358. function parseArgs(argv) {
  359. const args = {};
  360. for (let i = 0; i < argv.length; i += 1) {
  361. const raw = argv[i];
  362. if (!raw.startsWith('--')) continue;
  363. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  364. const next = argv[i + 1];
  365. if (!next || next.startsWith('--')) args[key] = true;
  366. else {
  367. args[key] = next;
  368. i += 1;
  369. }
  370. }
  371. return args;
  372. }
  373. function normalizeLabel(value) {
  374. return String(value || '').trim();
  375. }
  376. function normalizeStrategy(value) {
  377. return normalizeLabel(value).toLowerCase();
  378. }
  379. function ratio(numerator, denominator) {
  380. return denominator ? Math.round((Number(numerator || 0) / Number(denominator)) * 10000) / 10000 : 0;
  381. }
  382. function pct(value) {
  383. return `${Math.round(Number(value || 0) * 100)}%`;
  384. }
  385. function passFail(value) {
  386. return value ? '通过' : '未通过';
  387. }
  388. function formatReferenceVsKeyword(strategyComparison) {
  389. if (!strategyComparison.reference.total || !strategyComparison.keyword.total) return '待补对照组';
  390. return `${pct(strategyComparison.reference.businessUsableRate)} / ${pct(strategyComparison.keyword.businessUsableRate)}`;
  391. }
  392. function escapeCell(value) {
  393. return String(value || '').replace(/\|/g, '/').replace(/\n/g, ' ');
  394. }
  395. function withBom(text) {
  396. return `\uFEFF${text}`;
  397. }
  398. if (require.main === module) {
  399. try {
  400. main();
  401. } catch (error) {
  402. console.error(error && error.stack ? error.stack : String(error));
  403. process.exit(1);
  404. }
  405. }
  406. module.exports = {
  407. buildMetrics,
  408. readCsv
  409. };