manual-review-label-guide.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const ROOT = path.resolve(__dirname, '..');
  5. const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'manual-review-label-guide-latest');
  6. const LABEL_ROWS = [
  7. {
  8. label: '可直接发客户',
  9. category: 'positive',
  10. whenToUse: '候选人符合 Brief 硬性条件、内容调性、主页证据和风险边界,商务可直接放入客户名单。',
  11. requiresAttribution: false,
  12. requiresFeedbackReason: false,
  13. blocksReviewMetricsStrict: false,
  14. blocksCustomerEffectAudit: false
  15. },
  16. {
  17. label: '商务复核',
  18. category: 'positive',
  19. whenToUse: '候选人大体匹配,但报价、档期、竞品合作、近期内容或平台风险仍需商务确认。',
  20. requiresAttribution: false,
  21. requiresFeedbackReason: false,
  22. blocksReviewMetricsStrict: false,
  23. blocksCustomerEffectAudit: false
  24. },
  25. {
  26. label: '跑偏',
  27. category: 'negative',
  28. whenToUse: '人群、品类、平台、预算、内容方向或合作场景明显偏离 Brief。',
  29. requiresAttribution: true,
  30. requiresFeedbackReason: true,
  31. blocksReviewMetricsStrict: true,
  32. blocksCustomerEffectAudit: true
  33. },
  34. {
  35. label: '硬性规则违约',
  36. category: 'negative',
  37. whenToUse: '命中必须剔除项,例如平台不符、预算超限、地域/粉丝量/类目硬约束不符、客户明确排除。',
  38. requiresAttribution: true,
  39. requiresFeedbackReason: true,
  40. blocksReviewMetricsStrict: true,
  41. blocksCustomerEffectAudit: true
  42. },
  43. {
  44. label: '调性不符',
  45. category: 'negative',
  46. whenToUse: '硬指标可用,但内容表达、视觉质感、语气、人设或参考账号风格明显不一致。',
  47. requiresAttribution: true,
  48. requiresFeedbackReason: true,
  49. blocksReviewMetricsStrict: true,
  50. blocksCustomerEffectAudit: true
  51. },
  52. {
  53. label: '主页质感不符',
  54. category: 'negative',
  55. whenToUse: '主页最近内容质量、封面、互动、更新频率、商业化痕迹或风险信号不支持强推荐。',
  56. requiresAttribution: true,
  57. requiresFeedbackReason: true,
  58. blocksReviewMetricsStrict: true,
  59. blocksCustomerEffectAudit: true
  60. },
  61. {
  62. label: '参考账号不像',
  63. category: 'negative',
  64. whenToUse: '客户给了参考账号或参考视频,但候选人与参考账号的人群、内容结构或风格相似度不足。',
  65. requiresAttribution: true,
  66. requiresFeedbackReason: true,
  67. blocksReviewMetricsStrict: true,
  68. blocksCustomerEffectAudit: true
  69. },
  70. {
  71. label: '待客户反馈',
  72. category: 'pending',
  73. whenToUse: '商务已发客户或准备发客户,但客户尚未给最终选择/拒绝结果。',
  74. requiresAttribution: false,
  75. requiresFeedbackReason: false,
  76. blocksReviewMetricsStrict: false,
  77. blocksCustomerEffectAudit: true
  78. },
  79. {
  80. label: '客户选中',
  81. category: 'customer_result',
  82. whenToUse: '客户最终确认选择该候选人,可用于 customer-effect:audit 的客户选中率统计。',
  83. requiresAttribution: false,
  84. requiresFeedbackReason: false,
  85. blocksReviewMetricsStrict: false,
  86. blocksCustomerEffectAudit: false
  87. },
  88. {
  89. label: '客户拒绝',
  90. category: 'customer_result',
  91. whenToUse: '客户最终拒绝该候选人,必须补拒绝原因或反馈原因,用于下一轮优化。',
  92. requiresAttribution: true,
  93. requiresFeedbackReason: true,
  94. blocksReviewMetricsStrict: false,
  95. blocksCustomerEffectAudit: true
  96. }
  97. ];
  98. const ATTRIBUTION_ROWS = [
  99. ['需求解析错', 'Brief 里的品类、人群、预算、平台、地域或硬性限制被解析错。'],
  100. ['隐性规则漏', '客户偏好、历史排除项、风格禁区或团队经验规则没有进入本轮筛选。'],
  101. ['召回关键词错', '关键词召回方向太宽、太窄或平台搜索词不贴合真实场景。'],
  102. ['主页证据不足', '主页最近内容、封面、标题、互动、发布时间或风险证据不足。'],
  103. ['视频证据误判', '参考视频或候选视频风格、场景、口播、画面质感、ASR 被误判。'],
  104. ['排序权重错', '候选已召回但排序把弱相关、低质感或高风险账号排得过高。'],
  105. ['输出解释错', '推荐理由、风险提示或证据卡表达不清,导致商务难以判断。'],
  106. ['软件端表重复或排名不连续', '同一候选重复出现、重复键异常、排名断档或表格字段不稳定。']
  107. ];
  108. function main() {
  109. const args = parseArgs(process.argv.slice(2));
  110. const outputDir = path.resolve(args.output || DEFAULT_OUTPUT);
  111. const summary = buildGuideSummary();
  112. fs.mkdirSync(outputDir, { recursive: true });
  113. const summaryPath = path.join(outputDir, 'manual-review-label-guide-summary.json');
  114. const reportPath = path.join(outputDir, 'manual-review-label-guide.md');
  115. const csvPath = path.join(outputDir, 'manual-review-label-guide.csv');
  116. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  117. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  118. fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8');
  119. console.log(JSON.stringify({
  120. outputDir,
  121. summary: summaryPath,
  122. report: reportPath,
  123. csv: csvPath,
  124. passed: summary.passed,
  125. labelCount: summary.labelCount,
  126. attributionTypeCount: summary.attributionTypeCount
  127. }, null, 2));
  128. if (args.strict && !summary.passed) process.exitCode = 1;
  129. }
  130. function buildGuideSummary() {
  131. const negativeLabels = LABEL_ROWS.filter(item => item.category === 'negative' || item.label === '客户拒绝');
  132. const positiveLabels = LABEL_ROWS.filter(item => item.category === 'positive');
  133. const pendingLabels = LABEL_ROWS.filter(item => item.category === 'pending');
  134. const negativeLabelsMissingAttributionRule = negativeLabels.filter(item => !item.requiresAttribution);
  135. const rows = LABEL_ROWS.map((item, index) => ({
  136. id: `MRL-${String(index + 1).padStart(3, '0')}`,
  137. ...item,
  138. customerEffectBoundary: item.blocksCustomerEffectAudit
  139. ? '缺少客户最终选择、反馈原因或负样本归因时,不得进入客户效果证明。'
  140. : '只能作为复核/客户结果字段使用,仍需 customer-effect:audit 通过后才能宣称客户效果。'
  141. }));
  142. const attributionTypes = ATTRIBUTION_ROWS.map(([type, usage], index) => ({
  143. id: `ATR-${String(index + 1).padStart(3, '0')}`,
  144. type,
  145. usage,
  146. appliesTo: '负样本、客户拒绝、跑偏或调性不符候选'
  147. }));
  148. const summary = {
  149. generatedAt: new Date().toISOString(),
  150. directCustomerProof: false,
  151. proofLevel: 'smoke_or_local',
  152. passed: negativeLabelsMissingAttributionRule.length === 0 &&
  153. positiveLabels.length >= 2 &&
  154. negativeLabels.length >= 5 &&
  155. attributionTypes.length >= 8,
  156. labelCount: rows.length,
  157. positiveLabelCount: positiveLabels.length,
  158. negativeLabelCount: negativeLabels.length,
  159. pendingLabelCount: pendingLabels.length,
  160. attributionTypeCount: attributionTypes.length,
  161. negativeLabelsRequireAttribution: negativeLabelsMissingAttributionRule.length === 0,
  162. reviewMetricsStrictRules: [
  163. '人工复核标签不能为空。',
  164. '负样本标签必须填写归因类型。',
  165. '重复键必须为 0。',
  166. '同一 Brief 内排名必须连续。',
  167. '商务可用率、负样本率和归因覆盖率必须达到 review:metrics 门槛。'
  168. ],
  169. customerEffectAuditRules: [
  170. '客户选中率必须来自真实客户最终选择字段。',
  171. '客户拒绝样本必须填写拒绝原因或反馈原因。',
  172. '必须有历史人工补号量基线和本轮人工补号量。',
  173. '缺少 customer-effect-summary.json 或 overallPass=true 时,不得宣称客户效果。'
  174. ],
  175. labels: rows,
  176. attributionTypes
  177. };
  178. return summary;
  179. }
  180. function renderReport(summary) {
  181. return [
  182. '# 人工复核标签与归因填写指南',
  183. '',
  184. `- 生成时间:${summary.generatedAt}`,
  185. `- 标签数:${summary.labelCount}`,
  186. `- 归因类型数:${summary.attributionTypeCount}`,
  187. `- directCustomerProof:${summary.directCustomerProof}`,
  188. `- proofLevel:${summary.proofLevel}`,
  189. '',
  190. '## 使用边界',
  191. '',
  192. '- 本指南只规范商务复核字段、负样本归因和客户效果审计前置条件。',
  193. '- 它不证明命中率提升,也不证明客户效果完成。',
  194. '- 只有真实客户选择、历史人工补号基线、本轮人工补号量和 `customer-effect:audit` 同时通过,才能进入客户效果结论。',
  195. '',
  196. '## 人工复核标签',
  197. '',
  198. '| ID | 标签 | 分类 | 什么时候用 | 需要归因 | 需要反馈原因 | 会阻塞 review:metrics --strict | 会阻塞 customer-effect:audit | 边界 |',
  199. '| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
  200. ...summary.labels.map(item => tableRow([
  201. item.id,
  202. item.label,
  203. item.category,
  204. item.whenToUse,
  205. yesNo(item.requiresAttribution),
  206. yesNo(item.requiresFeedbackReason),
  207. yesNo(item.blocksReviewMetricsStrict),
  208. yesNo(item.blocksCustomerEffectAudit),
  209. item.customerEffectBoundary
  210. ])),
  211. '',
  212. '## 负样本归因类型',
  213. '',
  214. '| ID | 归因类型 | 适用场景 | 说明 |',
  215. '| --- | --- | --- | --- |',
  216. ...summary.attributionTypes.map(item => tableRow([item.id, item.type, item.appliesTo, item.usage])),
  217. '',
  218. '## 严格复核门禁',
  219. '',
  220. ...summary.reviewMetricsStrictRules.map(item => `- ${item}`),
  221. '',
  222. '## 客户效果门禁',
  223. '',
  224. ...summary.customerEffectAuditRules.map(item => `- ${item}`),
  225. '',
  226. '## 建议填写顺序',
  227. '',
  228. '1. 先给每个候选填写 `人工复核标签`。',
  229. '2. 所有负样本和客户拒绝样本补 `归因类型`。',
  230. '3. 负样本补 `反馈原因`,写到能指导下一轮规则或权重调整。',
  231. '4. 客户有最终选择后,再填写 `客户选择`。',
  232. '5. 跑 `npm run review:metrics -- --input <已标注CSV> --output <复核指标输出目录> --strict`。',
  233. '6. 历史基线和本轮人工补号量到位后,再跑 `customer-effect:audit`。',
  234. ''
  235. ].join('\n');
  236. }
  237. function renderCsv(summary) {
  238. const header = [
  239. '类型',
  240. 'ID',
  241. '名称',
  242. '分类',
  243. '说明',
  244. '需要归因',
  245. '需要反馈原因',
  246. '阻塞review_metrics_strict',
  247. '阻塞customer_effect_audit',
  248. '客户效果边界'
  249. ];
  250. const labelRows = summary.labels.map(item => [
  251. 'label',
  252. item.id,
  253. item.label,
  254. item.category,
  255. item.whenToUse,
  256. yesNo(item.requiresAttribution),
  257. yesNo(item.requiresFeedbackReason),
  258. yesNo(item.blocksReviewMetricsStrict),
  259. yesNo(item.blocksCustomerEffectAudit),
  260. item.customerEffectBoundary
  261. ]);
  262. const attributionRows = summary.attributionTypes.map(item => [
  263. 'attribution',
  264. item.id,
  265. item.type,
  266. 'negative_attribution',
  267. item.usage,
  268. '是',
  269. '建议',
  270. '负样本缺失时阻塞',
  271. '负样本缺失时阻塞',
  272. '只作为归因字段,不证明客户效果'
  273. ]);
  274. return [header, ...labelRows, ...attributionRows].map(row => row.map(csvCell).join(',')).join('\n');
  275. }
  276. function parseArgs(argv) {
  277. const args = {};
  278. for (let index = 0; index < argv.length; index += 1) {
  279. const raw = argv[index];
  280. if (!raw.startsWith('--')) continue;
  281. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  282. const next = argv[index + 1];
  283. if (!next || next.startsWith('--')) args[key] = true;
  284. else {
  285. args[key] = next;
  286. index += 1;
  287. }
  288. }
  289. return args;
  290. }
  291. function yesNo(value) {
  292. return value ? '是' : '否';
  293. }
  294. function csvCell(value) {
  295. const text = String(value ?? '');
  296. return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  297. }
  298. function tableRow(cells) {
  299. return `| ${cells.map(cell => String(cell ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' ')).join(' | ')} |`;
  300. }
  301. function withBom(text) {
  302. return `\uFEFF${text}`;
  303. }
  304. if (require.main === module) main();
  305. module.exports = {
  306. buildGuideSummary,
  307. renderReport,
  308. renderCsv
  309. };