| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434 |
- #!/usr/bin/env node
- const fs = require('fs');
- const path = require('path');
- const POSITIVE_LABELS = new Set(['可直接发客户', '商务复核']);
- const NEGATIVE_LABELS = new Set(['跑偏', '硬性规则违规', '调性不符', '主页质感不符', '参考账号不像']);
- const SELECTED_LABELS = new Set(['客户选中', '已选中', '选中', '客户通过']);
- const ATTRIBUTION_FIELDS = ['归因类型', '失败归因', '归因'];
- const REASON_FIELDS = ['反馈原因', '拒绝原因', '复核备注', '备注'];
- const REFERENCE_STRATEGIES = new Set(['reference-account', 'homepage-evidence', 'video-enhanced']);
- const KEYWORD_BASELINE_STRATEGIES = new Set(['baseline-live', 'brief-only', 'keyword-only']);
- const HEADER = [
- 'brief编号',
- '策略',
- '排名',
- '平台',
- '博主名称',
- '综合分',
- 'brief匹配分',
- '参考风格分',
- '主页证据分',
- '视觉质感分',
- '调性一致分',
- '证据加分',
- '证据风险扣分',
- '推荐理由',
- '风险提示',
- '主页链接',
- '人工复核标签'
- ];
- function main() {
- const args = parseArgs(process.argv.slice(2));
- const input = args.input || args.csv || args.review || process.env.TIHAO_REVIEW_CSV || '';
- if (!input) throw new Error('Usage: node scripts/review-metrics.js --input <reviewed.csv> [--output <dir>] [--strict]');
- const inputPath = path.resolve(input);
- const outputDir = path.resolve(args.output || process.env.TIHAO_REVIEW_OUTPUT || path.join(path.dirname(inputPath), 'review-metrics'));
- const strict = Boolean(args.strict || process.env.TIHAO_REVIEW_STRICT === 'true');
- const rows = readCsv(inputPath);
- const metrics = buildMetrics({ inputPath, rows });
- const report = renderReport(metrics);
- fs.mkdirSync(outputDir, { recursive: true });
- const jsonPath = path.join(outputDir, 'review-metrics-summary.json');
- const mdPath = path.join(outputDir, 'review-metrics-report.md');
- fs.writeFileSync(jsonPath, JSON.stringify(metrics, null, 2), 'utf8');
- fs.writeFileSync(mdPath, withBom(report), 'utf8');
- console.log(JSON.stringify({
- input: inputPath,
- outputDir,
- json: jsonPath,
- report: mdPath,
- total: metrics.total,
- labeled: metrics.labeled,
- businessUsableRate: metrics.businessUsableRate,
- offTargetHardFailRate: metrics.offTargetHardFailRate,
- customerSelectedRate: metrics.customerSelectedRate,
- failureAttributionCoverage: metrics.failureAttributionCoverage,
- pass: metrics.acceptance.overallPass
- }, null, 2));
- if (strict && !metrics.acceptance.overallPass) process.exitCode = 2;
- }
- function buildMetrics({ inputPath, rows }) {
- const headerOk = HEADER.every((key, index) => rows.header[index] === key);
- const body = rows.body;
- const labeledRows = body.filter(row => normalizeLabel(row['人工复核标签']));
- const positiveRows = labeledRows.filter(row => POSITIVE_LABELS.has(normalizeLabel(row['人工复核标签'])));
- const negativeRows = labeledRows.filter(row => NEGATIVE_LABELS.has(normalizeLabel(row['人工复核标签'])));
- const selectedRows = labeledRows.filter(row => SELECTED_LABELS.has(normalizeLabel(row['客户选择'] || row['客户选中'] || row['最终结果'] || row['人工复核标签'])));
- const attribution = summarizeFailureAttribution(negativeRows);
- const duplicateKeyCount = countDuplicateKeys(body);
- const rankIssues = findRankIssues(body);
- const byBrief = summarizeByBrief(labeledRows);
- const byStrategy = summarizeByStrategy(labeledRows);
- const strategyComparison = compareReferenceAndKeywordStrategies(byStrategy);
- const total = body.length;
- const labeled = labeledRows.length;
- const metrics = {
- inputPath,
- generatedAt: new Date().toISOString(),
- headerOk,
- total,
- labeled,
- unlabeled: total - labeled,
- positiveCount: positiveRows.length,
- negativeCount: negativeRows.length,
- selectedCount: selectedRows.length,
- businessUsableRate: ratio(positiveRows.length, Math.max(labeled, 1)),
- offTargetHardFailRate: ratio(negativeRows.length, Math.max(labeled, 1)),
- customerSelectedRate: ratio(selectedRows.length, Math.max(labeled, 1)),
- failureAttribution: attribution,
- failureAttributionCoverage: ratio(attribution.attributedCount, Math.max(attribution.failureRows, 1)),
- duplicateKeyCount,
- rankContinuous: rankIssues.length === 0,
- rankIssues,
- byBrief,
- byStrategy,
- strategyComparison
- };
- metrics.acceptance = {
- headerOk,
- duplicateKeyCountZero: duplicateKeyCount === 0,
- rankContinuous: metrics.rankContinuous,
- enoughLabeledRows: labeled > 0,
- businessUsableRatePass: metrics.businessUsableRate >= 0.6,
- offTargetHardFailRatePass: metrics.offTargetHardFailRate <= 0.1,
- customerSelectedRateMeasured: selectedRows.length > 0,
- customerSelectedRatePass: selectedRows.length === 0 ? null : metrics.customerSelectedRate >= 0.3,
- referenceCustomerSelectedRatePass: strategyComparison.reference.selected === 0 ? null : strategyComparison.reference.customerSelectedRate >= 0.4,
- referencePassRateHigherThanKeyword: strategyComparison.keyword.total === 0 || strategyComparison.reference.total === 0
- ? null
- : strategyComparison.reference.businessUsableRate > strategyComparison.keyword.businessUsableRate,
- failureAttributionCoveragePass: attribution.failureRows === 0 || attribution.missingAttributionRows.length === 0
- };
- metrics.acceptance.overallPass = metrics.acceptance.headerOk &&
- metrics.acceptance.duplicateKeyCountZero &&
- metrics.acceptance.rankContinuous &&
- metrics.acceptance.enoughLabeledRows &&
- metrics.acceptance.businessUsableRatePass &&
- metrics.acceptance.offTargetHardFailRatePass &&
- metrics.acceptance.failureAttributionCoveragePass &&
- (metrics.acceptance.customerSelectedRatePass !== false) &&
- (metrics.acceptance.referenceCustomerSelectedRatePass !== false) &&
- (metrics.acceptance.referencePassRateHigherThanKeyword !== false);
- return metrics;
- }
- function summarizeFailureAttribution(rows) {
- const byAttribution = {};
- const missingAttributionRows = [];
- let attributedCount = 0;
- for (const row of rows) {
- const attribution = firstNonEmpty(row, ATTRIBUTION_FIELDS);
- const reason = firstNonEmpty(row, REASON_FIELDS);
- if (attribution) {
- attributedCount += 1;
- byAttribution[attribution] = (byAttribution[attribution] || 0) + 1;
- } else {
- missingAttributionRows.push({
- brief编号: row['brief编号'] || '',
- 策略: row['策略'] || '',
- 排名: row['排名'] || '',
- 平台: row['平台'] || '',
- 博主名称: row['博主名称'] || '',
- 人工复核标签: row['人工复核标签'] || '',
- 主页链接: row['主页链接'] || '',
- 反馈原因: reason
- });
- }
- }
- return {
- failureRows: rows.length,
- attributedCount,
- missingAttributionCount: missingAttributionRows.length,
- byAttribution,
- missingAttributionRows
- };
- }
- function firstNonEmpty(row, fields) {
- for (const field of fields) {
- const value = normalizeLabel(row[field]);
- if (value) return value;
- }
- return '';
- }
- function summarizeByBrief(rows) {
- const groups = new Map();
- for (const row of rows) {
- const briefId = row['brief编号'] || '未命名brief';
- const group = groups.get(briefId) || { briefId, total: 0, positive: 0, negative: 0, selected: 0, byLabel: {} };
- const label = normalizeLabel(row['人工复核标签']) || '未标注';
- group.total += 1;
- if (POSITIVE_LABELS.has(label)) group.positive += 1;
- if (NEGATIVE_LABELS.has(label)) group.negative += 1;
- if (SELECTED_LABELS.has(normalizeLabel(row['客户选择'] || row['客户选中'] || row['最终结果'] || row['人工复核标签']))) group.selected += 1;
- group.byLabel[label] = (group.byLabel[label] || 0) + 1;
- groups.set(briefId, group);
- }
- return [...groups.values()].map(group => ({
- ...group,
- businessUsableRate: ratio(group.positive, Math.max(group.total, 1)),
- offTargetHardFailRate: ratio(group.negative, Math.max(group.total, 1)),
- customerSelectedRate: ratio(group.selected, Math.max(group.total, 1))
- }));
- }
- function summarizeByStrategy(rows) {
- const groups = new Map();
- for (const row of rows) {
- const strategy = normalizeStrategy(row['策略']);
- const group = groups.get(strategy) || { strategy, total: 0, positive: 0, negative: 0, selected: 0, byLabel: {} };
- const label = normalizeLabel(row['人工复核标签']) || '未标注';
- group.total += 1;
- if (POSITIVE_LABELS.has(label)) group.positive += 1;
- if (NEGATIVE_LABELS.has(label)) group.negative += 1;
- if (SELECTED_LABELS.has(normalizeLabel(row['客户选择'] || row['客户选中'] || row['最终结果'] || row['人工复核标签']))) group.selected += 1;
- group.byLabel[label] = (group.byLabel[label] || 0) + 1;
- groups.set(strategy, group);
- }
- return [...groups.values()].map(group => ({
- ...group,
- businessUsableRate: ratio(group.positive, Math.max(group.total, 1)),
- offTargetHardFailRate: ratio(group.negative, Math.max(group.total, 1)),
- customerSelectedRate: ratio(group.selected, Math.max(group.total, 1))
- }));
- }
- function compareReferenceAndKeywordStrategies(byStrategy) {
- const referenceRows = byStrategy.filter(item => REFERENCE_STRATEGIES.has(item.strategy));
- const keywordRows = byStrategy.filter(item => KEYWORD_BASELINE_STRATEGIES.has(item.strategy));
- return {
- reference: aggregateStrategyGroup('reference', referenceRows),
- keyword: aggregateStrategyGroup('keyword-baseline', keywordRows)
- };
- }
- function aggregateStrategyGroup(name, groups) {
- const total = groups.reduce((sum, item) => sum + item.total, 0);
- const positive = groups.reduce((sum, item) => sum + item.positive, 0);
- const negative = groups.reduce((sum, item) => sum + item.negative, 0);
- const selected = groups.reduce((sum, item) => sum + item.selected, 0);
- return {
- name,
- strategies: groups.map(item => item.strategy),
- total,
- positive,
- negative,
- selected,
- businessUsableRate: ratio(positive, Math.max(total, 1)),
- offTargetHardFailRate: ratio(negative, Math.max(total, 1)),
- customerSelectedRate: ratio(selected, Math.max(total, 1))
- };
- }
- function countDuplicateKeys(rows) {
- const seen = new Set();
- let duplicates = 0;
- for (const row of rows) {
- const key = row['主页链接']
- ? `${row['brief编号']}|${row['平台']}|${row['主页链接']}`
- : `${row['brief编号']}|${row['平台']}|${row['博主名称']}`;
- if (seen.has(key)) duplicates += 1;
- seen.add(key);
- }
- return duplicates;
- }
- function findRankIssues(rows) {
- const groups = new Map();
- for (const row of rows) {
- const briefId = row['brief编号'] || '未命名brief';
- if (!groups.has(briefId)) groups.set(briefId, []);
- groups.get(briefId).push(Number(row['排名']));
- }
- const issues = [];
- for (const [briefId, ranks] of groups.entries()) {
- const ok = ranks.every((rank, index) => rank === index + 1);
- if (!ok) issues.push({ briefId, ranks: ranks.join(',') });
- }
- return issues;
- }
- function renderReport(metrics) {
- const lines = [
- '# 人工复核质量指标报告',
- '',
- `- 输入文件:${metrics.inputPath}`,
- `- 生成时间:${metrics.generatedAt}`,
- `- 总行数:${metrics.total}`,
- `- 已标注:${metrics.labeled}`,
- `- 未标注:${metrics.unlabeled}`,
- `- 总体验收:${metrics.acceptance.overallPass ? '通过' : '未通过'}`,
- '',
- '## 核心指标',
- '',
- '| 指标 | 当前值 | 目标 | 状态 |',
- '| --- | ---: | ---: | --- |',
- `| 商务可用率 | ${pct(metrics.businessUsableRate)} | >= 60% | ${passFail(metrics.acceptance.businessUsableRatePass)} |`,
- `| 负样本率 | ${pct(metrics.offTargetHardFailRate)} | <= 10% | ${passFail(metrics.acceptance.offTargetHardFailRatePass)} |`,
- `| 客户选中率 | ${metrics.selectedCount ? pct(metrics.customerSelectedRate) : '未标注'} | >= 30% | ${metrics.acceptance.customerSelectedRatePass === null ? '待补客户选择' : passFail(metrics.acceptance.customerSelectedRatePass)} |`,
- `| 负样本归因覆盖率 | ${metrics.failureAttribution.failureRows ? pct(metrics.failureAttributionCoverage) : '无负样本'} | 100% | ${passFail(metrics.acceptance.failureAttributionCoveragePass)} |`,
- `| 参考链路客户选中率 | ${metrics.strategyComparison.reference.selected ? pct(metrics.strategyComparison.reference.customerSelectedRate) : '未标注'} | >= 40% | ${metrics.acceptance.referenceCustomerSelectedRatePass === null ? '待补客户选择' : passFail(metrics.acceptance.referenceCustomerSelectedRatePass)} |`,
- `| 参考链路通过率高于关键词基线 | ${formatReferenceVsKeyword(metrics.strategyComparison)} | > 关键词基线 | ${metrics.acceptance.referencePassRateHigherThanKeyword === null ? '待补对照组' : passFail(metrics.acceptance.referencePassRateHigherThanKeyword)} |`,
- `| 重复键 | ${metrics.duplicateKeyCount} | 0 | ${passFail(metrics.acceptance.duplicateKeyCountZero)} |`,
- `| 排名连续 | ${metrics.rankContinuous ? '是' : '否'} | 是 | ${passFail(metrics.acceptance.rankContinuous)} |`,
- '',
- '## 分 Brief 指标',
- '',
- '| Brief | 已标注 | 商务可用率 | 负样本率 | 客户选中率 |',
- '| --- | ---: | ---: | ---: | ---: |',
- ...metrics.byBrief.map(item => `| ${escapeCell(item.briefId)} | ${item.total} | ${pct(item.businessUsableRate)} | ${pct(item.offTargetHardFailRate)} | ${item.selected ? pct(item.customerSelectedRate) : '未标注'} |`),
- '',
- '## 分策略指标',
- '',
- '| 策略 | 已标注 | 商务可用率 | 负样本率 | 客户选中率 |',
- '| --- | ---: | ---: | ---: | ---: |',
- ...metrics.byStrategy.map(item => `| ${escapeCell(item.strategy)} | ${item.total} | ${pct(item.businessUsableRate)} | ${pct(item.offTargetHardFailRate)} | ${item.selected ? pct(item.customerSelectedRate) : '未标注'} |`),
- '',
- '## 参考链路对照',
- '',
- '| 组别 | 策略 | 已标注 | 商务可用率 | 客户选中率 |',
- '| --- | --- | ---: | ---: | ---: |',
- `| 参考/主页/视频增强 | ${escapeCell(metrics.strategyComparison.reference.strategies.join('、') || '无')} | ${metrics.strategyComparison.reference.total} | ${pct(metrics.strategyComparison.reference.businessUsableRate)} | ${metrics.strategyComparison.reference.selected ? pct(metrics.strategyComparison.reference.customerSelectedRate) : '未标注'} |`,
- `| 关键词/基础基线 | ${escapeCell(metrics.strategyComparison.keyword.strategies.join('、') || '无')} | ${metrics.strategyComparison.keyword.total} | ${pct(metrics.strategyComparison.keyword.businessUsableRate)} | ${metrics.strategyComparison.keyword.selected ? pct(metrics.strategyComparison.keyword.customerSelectedRate) : '未标注'} |`,
- '',
- '## 负样本归因',
- '',
- `- 负样本数:${metrics.failureAttribution.failureRows}`,
- `- 已归因:${metrics.failureAttribution.attributedCount}`,
- `- 缺归因:${metrics.failureAttribution.missingAttributionCount}`,
- '',
- '| 归因类型 | 数量 |',
- '| --- | ---: |',
- ...Object.entries(metrics.failureAttribution.byAttribution).map(([key, value]) => `| ${escapeCell(key)} | ${value} |`),
- ...(Object.keys(metrics.failureAttribution.byAttribution).length ? [] : ['| 无 | 0 |']),
- '',
- '## 缺归因负样本',
- '',
- '| Brief | 排名 | 平台 | 博主名称 | 标签 | 反馈原因 |',
- '| --- | ---: | --- | --- | --- | --- |',
- ...metrics.failureAttribution.missingAttributionRows.slice(0, 50).map(row => `| ${escapeCell(row['brief编号'])} | ${escapeCell(row['排名'])} | ${escapeCell(row['平台'])} | ${escapeCell(row['博主名称'])} | ${escapeCell(row['人工复核标签'])} | ${escapeCell(row['反馈原因'])} |`),
- ...(metrics.failureAttribution.missingAttributionRows.length ? [] : ['| 无 | | | | | |']),
- '',
- '## 结论',
- '',
- '- “可直接发客户 + 商务复核”用于衡量短期商务可用率。',
- '- 负样本标签用于衡量需求解析、隐性规则、主页质感、参考风格和调性命中问题。',
- '- 负样本必须填写“归因类型/失败归因/归因”,否则不能进入可反哺优化的复盘样本。',
- '- 客户选中率需要客户最终选择字段;没有该字段时不能宣称已达到 30%/50%。',
- '- 参考链路是否优于纯关键词召回,必须同时存在参考策略和关键词/基础基线策略的标注样本才能判断。'
- ];
- return lines.join('\n');
- }
- function readCsv(file) {
- const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
- const lines = text.split(/\r?\n/).filter(Boolean);
- const header = parseCsvLine(lines.shift() || '');
- return {
- header,
- body: lines.map(line => {
- const cells = parseCsvLine(line);
- const row = {};
- header.forEach((key, index) => {
- row[key] = cells[index] || '';
- });
- return row;
- })
- };
- }
- function parseCsvLine(line) {
- const cells = [];
- let current = '';
- let quoted = false;
- for (let i = 0; i < line.length; i += 1) {
- const char = line[i];
- if (char === '"' && quoted && line[i + 1] === '"') {
- current += '"';
- i += 1;
- } else if (char === '"') quoted = !quoted;
- else if (char === ',' && !quoted) {
- cells.push(current);
- current = '';
- } else current += char;
- }
- cells.push(current);
- return cells;
- }
- 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 normalizeLabel(value) {
- return String(value || '').trim();
- }
- function normalizeStrategy(value) {
- return normalizeLabel(value).toLowerCase();
- }
- function ratio(numerator, denominator) {
- return denominator ? Math.round((Number(numerator || 0) / Number(denominator)) * 10000) / 10000 : 0;
- }
- function pct(value) {
- return `${Math.round(Number(value || 0) * 100)}%`;
- }
- function passFail(value) {
- return value ? '通过' : '未通过';
- }
- function formatReferenceVsKeyword(strategyComparison) {
- if (!strategyComparison.reference.total || !strategyComparison.keyword.total) return '待补对照组';
- return `${pct(strategyComparison.reference.businessUsableRate)} / ${pct(strategyComparison.keyword.businessUsableRate)}`;
- }
- 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 = {
- buildMetrics,
- readCsv
- };
|