#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'manual-review-label-guide-latest'); const LABEL_ROWS = [ { label: '可直接发客户', category: 'positive', whenToUse: '候选人符合 Brief 硬性条件、内容调性、主页证据和风险边界,商务可直接放入客户名单。', requiresAttribution: false, requiresFeedbackReason: false, blocksReviewMetricsStrict: false, blocksCustomerEffectAudit: false }, { label: '商务复核', category: 'positive', whenToUse: '候选人大体匹配,但报价、档期、竞品合作、近期内容或平台风险仍需商务确认。', requiresAttribution: false, requiresFeedbackReason: false, blocksReviewMetricsStrict: false, blocksCustomerEffectAudit: false }, { label: '跑偏', category: 'negative', whenToUse: '人群、品类、平台、预算、内容方向或合作场景明显偏离 Brief。', requiresAttribution: true, requiresFeedbackReason: true, blocksReviewMetricsStrict: true, blocksCustomerEffectAudit: true }, { label: '硬性规则违约', category: 'negative', whenToUse: '命中必须剔除项,例如平台不符、预算超限、地域/粉丝量/类目硬约束不符、客户明确排除。', requiresAttribution: true, requiresFeedbackReason: true, blocksReviewMetricsStrict: true, blocksCustomerEffectAudit: true }, { label: '调性不符', category: 'negative', whenToUse: '硬指标可用,但内容表达、视觉质感、语气、人设或参考账号风格明显不一致。', requiresAttribution: true, requiresFeedbackReason: true, blocksReviewMetricsStrict: true, blocksCustomerEffectAudit: true }, { label: '主页质感不符', category: 'negative', whenToUse: '主页最近内容质量、封面、互动、更新频率、商业化痕迹或风险信号不支持强推荐。', requiresAttribution: true, requiresFeedbackReason: true, blocksReviewMetricsStrict: true, blocksCustomerEffectAudit: true }, { label: '参考账号不像', category: 'negative', whenToUse: '客户给了参考账号或参考视频,但候选人与参考账号的人群、内容结构或风格相似度不足。', requiresAttribution: true, requiresFeedbackReason: true, blocksReviewMetricsStrict: true, blocksCustomerEffectAudit: true }, { label: '待客户反馈', category: 'pending', whenToUse: '商务已发客户或准备发客户,但客户尚未给最终选择/拒绝结果。', requiresAttribution: false, requiresFeedbackReason: false, blocksReviewMetricsStrict: false, blocksCustomerEffectAudit: true }, { label: '客户选中', category: 'customer_result', whenToUse: '客户最终确认选择该候选人,可用于 customer-effect:audit 的客户选中率统计。', requiresAttribution: false, requiresFeedbackReason: false, blocksReviewMetricsStrict: false, blocksCustomerEffectAudit: false }, { label: '客户拒绝', category: 'customer_result', whenToUse: '客户最终拒绝该候选人,必须补拒绝原因或反馈原因,用于下一轮优化。', requiresAttribution: true, requiresFeedbackReason: true, blocksReviewMetricsStrict: false, blocksCustomerEffectAudit: true } ]; const ATTRIBUTION_ROWS = [ ['需求解析错', 'Brief 里的品类、人群、预算、平台、地域或硬性限制被解析错。'], ['隐性规则漏', '客户偏好、历史排除项、风格禁区或团队经验规则没有进入本轮筛选。'], ['召回关键词错', '关键词召回方向太宽、太窄或平台搜索词不贴合真实场景。'], ['主页证据不足', '主页最近内容、封面、标题、互动、发布时间或风险证据不足。'], ['视频证据误判', '参考视频或候选视频风格、场景、口播、画面质感、ASR 被误判。'], ['排序权重错', '候选已召回但排序把弱相关、低质感或高风险账号排得过高。'], ['输出解释错', '推荐理由、风险提示或证据卡表达不清,导致商务难以判断。'], ['软件端表重复或排名不连续', '同一候选重复出现、重复键异常、排名断档或表格字段不稳定。'] ]; function main() { const args = parseArgs(process.argv.slice(2)); const outputDir = path.resolve(args.output || DEFAULT_OUTPUT); const summary = buildGuideSummary(); fs.mkdirSync(outputDir, { recursive: true }); const summaryPath = path.join(outputDir, 'manual-review-label-guide-summary.json'); const reportPath = path.join(outputDir, 'manual-review-label-guide.md'); const csvPath = path.join(outputDir, 'manual-review-label-guide.csv'); fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8'); console.log(JSON.stringify({ outputDir, summary: summaryPath, report: reportPath, csv: csvPath, passed: summary.passed, labelCount: summary.labelCount, attributionTypeCount: summary.attributionTypeCount }, null, 2)); if (args.strict && !summary.passed) process.exitCode = 1; } function buildGuideSummary() { const negativeLabels = LABEL_ROWS.filter(item => item.category === 'negative' || item.label === '客户拒绝'); const positiveLabels = LABEL_ROWS.filter(item => item.category === 'positive'); const pendingLabels = LABEL_ROWS.filter(item => item.category === 'pending'); const negativeLabelsMissingAttributionRule = negativeLabels.filter(item => !item.requiresAttribution); const rows = LABEL_ROWS.map((item, index) => ({ id: `MRL-${String(index + 1).padStart(3, '0')}`, ...item, customerEffectBoundary: item.blocksCustomerEffectAudit ? '缺少客户最终选择、反馈原因或负样本归因时,不得进入客户效果证明。' : '只能作为复核/客户结果字段使用,仍需 customer-effect:audit 通过后才能宣称客户效果。' })); const attributionTypes = ATTRIBUTION_ROWS.map(([type, usage], index) => ({ id: `ATR-${String(index + 1).padStart(3, '0')}`, type, usage, appliesTo: '负样本、客户拒绝、跑偏或调性不符候选' })); const summary = { generatedAt: new Date().toISOString(), directCustomerProof: false, proofLevel: 'smoke_or_local', passed: negativeLabelsMissingAttributionRule.length === 0 && positiveLabels.length >= 2 && negativeLabels.length >= 5 && attributionTypes.length >= 8, labelCount: rows.length, positiveLabelCount: positiveLabels.length, negativeLabelCount: negativeLabels.length, pendingLabelCount: pendingLabels.length, attributionTypeCount: attributionTypes.length, negativeLabelsRequireAttribution: negativeLabelsMissingAttributionRule.length === 0, reviewMetricsStrictRules: [ '人工复核标签不能为空。', '负样本标签必须填写归因类型。', '重复键必须为 0。', '同一 Brief 内排名必须连续。', '商务可用率、负样本率和归因覆盖率必须达到 review:metrics 门槛。' ], customerEffectAuditRules: [ '客户选中率必须来自真实客户最终选择字段。', '客户拒绝样本必须填写拒绝原因或反馈原因。', '必须有历史人工补号量基线和本轮人工补号量。', '缺少 customer-effect-summary.json 或 overallPass=true 时,不得宣称客户效果。' ], labels: rows, attributionTypes }; return summary; } function renderReport(summary) { return [ '# 人工复核标签与归因填写指南', '', `- 生成时间:${summary.generatedAt}`, `- 标签数:${summary.labelCount}`, `- 归因类型数:${summary.attributionTypeCount}`, `- directCustomerProof:${summary.directCustomerProof}`, `- proofLevel:${summary.proofLevel}`, '', '## 使用边界', '', '- 本指南只规范商务复核字段、负样本归因和客户效果审计前置条件。', '- 它不证明命中率提升,也不证明客户效果完成。', '- 只有真实客户选择、历史人工补号基线、本轮人工补号量和 `customer-effect:audit` 同时通过,才能进入客户效果结论。', '', '## 人工复核标签', '', '| ID | 标签 | 分类 | 什么时候用 | 需要归因 | 需要反馈原因 | 会阻塞 review:metrics --strict | 会阻塞 customer-effect:audit | 边界 |', '| --- | --- | --- | --- | --- | --- | --- | --- | --- |', ...summary.labels.map(item => tableRow([ item.id, item.label, item.category, item.whenToUse, yesNo(item.requiresAttribution), yesNo(item.requiresFeedbackReason), yesNo(item.blocksReviewMetricsStrict), yesNo(item.blocksCustomerEffectAudit), item.customerEffectBoundary ])), '', '## 负样本归因类型', '', '| ID | 归因类型 | 适用场景 | 说明 |', '| --- | --- | --- | --- |', ...summary.attributionTypes.map(item => tableRow([item.id, item.type, item.appliesTo, item.usage])), '', '## 严格复核门禁', '', ...summary.reviewMetricsStrictRules.map(item => `- ${item}`), '', '## 客户效果门禁', '', ...summary.customerEffectAuditRules.map(item => `- ${item}`), '', '## 建议填写顺序', '', '1. 先给每个候选填写 `人工复核标签`。', '2. 所有负样本和客户拒绝样本补 `归因类型`。', '3. 负样本补 `反馈原因`,写到能指导下一轮规则或权重调整。', '4. 客户有最终选择后,再填写 `客户选择`。', '5. 跑 `npm run review:metrics -- --input <已标注CSV> --output <复核指标输出目录> --strict`。', '6. 历史基线和本轮人工补号量到位后,再跑 `customer-effect:audit`。', '' ].join('\n'); } function renderCsv(summary) { const header = [ '类型', 'ID', '名称', '分类', '说明', '需要归因', '需要反馈原因', '阻塞review_metrics_strict', '阻塞customer_effect_audit', '客户效果边界' ]; const labelRows = summary.labels.map(item => [ 'label', item.id, item.label, item.category, item.whenToUse, yesNo(item.requiresAttribution), yesNo(item.requiresFeedbackReason), yesNo(item.blocksReviewMetricsStrict), yesNo(item.blocksCustomerEffectAudit), item.customerEffectBoundary ]); const attributionRows = summary.attributionTypes.map(item => [ 'attribution', item.id, item.type, 'negative_attribution', item.usage, '是', '建议', '负样本缺失时阻塞', '负样本缺失时阻塞', '只作为归因字段,不证明客户效果' ]); return [header, ...labelRows, ...attributionRows].map(row => row.map(csvCell).join(',')).join('\n'); } function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { const raw = argv[index]; if (!raw.startsWith('--')) continue; const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[index + 1]; if (!next || next.startsWith('--')) args[key] = true; else { args[key] = next; index += 1; } } return args; } function yesNo(value) { return value ? '是' : '否'; } function csvCell(value) { const text = String(value ?? ''); return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function tableRow(cells) { return `| ${cells.map(cell => String(cell ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' ')).join(' | ')} |`; } function withBom(text) { return `\uFEFF${text}`; } if (require.main === module) main(); module.exports = { buildGuideSummary, renderReport, renderCsv };