case-template-import.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { readXlsxRows } = require('../mcp/src/core/files');
  5. // case_template.xlsx 的字段(中文表头 → 内部 key)
  6. const FIELD_MAP = {
  7. '案例ID': 'id',
  8. '品类': 'category',
  9. '投放目标': 'goal',
  10. '目标人群': 'audience',
  11. '品牌调性': 'brandTone',
  12. '平台': 'platform',
  13. '选中账号特征': 'selectedAccountProfile',
  14. '结果标签': 'outcomeLabel',
  15. '关键原因': 'keyReason',
  16. '客户反馈': 'customerFeedback',
  17. '执行数据': 'executionData'
  18. };
  19. // 结果标签枚举(说明行 r3 的值是「爆文 / 达标 / 一般 / 翻车」,含斜杠,会被此白名单排除)
  20. const OUTCOME_LABELS = ['爆文', '达标', '一般', '翻车'];
  21. // 平台中文 → 内部代码
  22. const PLATFORM_CODE = {
  23. '小红书': 'xiaohongshu',
  24. 'xiaohongshu': 'xiaohongshu',
  25. 'xhs': 'xiaohongshu',
  26. '抖音': 'douyin',
  27. 'douyin': 'douyin',
  28. 'b站': 'bilibili',
  29. 'bilibili': 'bilibili'
  30. };
  31. // 结果标签 → outcome 方向
  32. const OUTCOME_BY_LABEL = {
  33. '爆文': 'positive',
  34. '达标': 'positive',
  35. '一般': 'neutral',
  36. '翻车': 'negative'
  37. };
  38. // 结果标签 → 客户选择(用于 history 数据集)
  39. const CUSTOMER_DECISION_BY_LABEL = {
  40. '爆文': '客户选中',
  41. '达标': '客户选中',
  42. '一般': '待客户反馈',
  43. '翻车': '客户拒绝'
  44. };
  45. // 从关键原因抽取的信号标签(用于给案例打标签,便于后续检索同类案例 / 沉淀规则)
  46. const SIGNAL_DICT = [
  47. { tag: '互动率', words: ['互动率', '互动', '赞藏比', '赞评藏', '点赞', '收藏'] },
  48. { tag: '垂直度', words: ['垂直度', '垂直', '垂直度92', '垂直度35'] },
  49. { tag: '画像匹配', words: ['画像匹配', '画像', '粉丝画像', '匹配'] },
  50. { tag: '调性匹配', words: ['调性', '成分党', '硬核测评', '测评', '素人', '生活化', '科普'] },
  51. { tag: '内容质量', words: ['内容', '疲劳', '干货', '深度', '创意'] },
  52. { tag: '数据虚胖', words: ['虚胖', '水分', '刷', '重复'] },
  53. { tag: '转化/ROI', words: ['转化', 'ROI', 'roi', '搜索', '占位', 'GMV', '下单'] },
  54. { tag: '负面反馈', words: ['负面', '差评', '投诉', '无转化', '掉粉', '翻车'] },
  55. { tag: '报价匹配', words: ['报价', '预算', 'CPM', 'CPE', '性价比', '成本'] },
  56. { tag: '人群契合', words: ['契合', '对口', '精准', '人群', '年龄'] }
  57. ];
  58. function main() {
  59. const args = parseArgs(process.argv.slice(2));
  60. const input = path.resolve(
  61. args.input || args.xlsx || process.env.TIHAO_CASE_TEMPLATE ||
  62. path.join(__dirname, '..', '..', 'docs', '20260813 - AI提号规则及喂养资料', '历史选号案例', 'case_template.xlsx')
  63. );
  64. const outputDir = path.resolve(args.output || path.join(path.dirname(input), 'case-import'));
  65. if (!fs.existsSync(input)) throw new Error(`case_template.xlsx 不存在:${input}`);
  66. const cases = readCaseTemplate(input);
  67. if (cases.length === 0) throw new Error(`未在 ${input} 里解析到任何案例数据行`);
  68. fs.mkdirSync(outputDir, { recursive: true });
  69. // 技能包内打包路径:随 npm 包分发(package.json files 含 docs/),运行时 few-shot-cases.js 优先读这里
  70. const pkgCaseLibraryDir = path.join(__dirname, '..', 'docs', 'case-library');
  71. fs.mkdirSync(pkgCaseLibraryDir, { recursive: true });
  72. const files = {
  73. fewshotCases: path.join(outputDir, 'fewshot-cases.json'),
  74. insights: path.join(outputDir, 'case-insights.md'),
  75. historyCsv: path.join(outputDir, 'history-data-template.csv'),
  76. summary: path.join(outputDir, 'case-import-summary.json'),
  77. pkgFewshotCases: path.join(pkgCaseLibraryDir, 'fewshot-cases.json')
  78. };
  79. const fewshotPayload = buildFewshotCases(cases, input);
  80. fs.writeFileSync(files.fewshotCases, JSON.stringify(fewshotPayload, null, 2), 'utf8');
  81. fs.writeFileSync(files.pkgFewshotCases, JSON.stringify(fewshotPayload, null, 2), 'utf8');
  82. fs.writeFileSync(files.insights, withBom(buildInsights(cases)), 'utf8');
  83. fs.writeFileSync(files.historyCsv, withBom(buildHistoryCsv(cases)), 'utf8');
  84. const summary = {
  85. input,
  86. outputDir,
  87. generatedAt: new Date().toISOString(),
  88. caseCount: cases.length,
  89. positive: cases.filter(item => item.outcome === 'positive').length,
  90. neutral: cases.filter(item => item.outcome === 'neutral').length,
  91. negative: cases.filter(item => item.outcome === 'negative').length,
  92. files
  93. };
  94. fs.writeFileSync(files.summary, JSON.stringify(summary, null, 2), 'utf8');
  95. console.log(JSON.stringify(summary, null, 2));
  96. }
  97. // 读取 xlsx,定位表头行,跳过说明行,返回映射后的案例数组
  98. function readCaseTemplate(file) {
  99. return parseCasesFromSheets(readXlsxRows(file));
  100. }
  101. function parseCasesFromSheets(sheets) {
  102. const cases = [];
  103. for (const sheet of sheets) {
  104. const headerIndex = sheet.rows.findIndex(row => hasHeader(row));
  105. if (headerIndex < 0) continue;
  106. const header = sheet.rows[headerIndex];
  107. for (let index = headerIndex + 1; index < sheet.rows.length; index += 1) {
  108. const raw = sheet.rows[index];
  109. const mapped = mapRow(header, raw);
  110. if (!mapped || !OUTCOME_LABELS.includes(mapped.outcomeLabel)) continue; // 跳过说明行和空行
  111. cases.push(mapped);
  112. }
  113. }
  114. return cases;
  115. }
  116. function hasHeader(row) {
  117. const text = row.map(item => String(item || '')).join('|');
  118. return text.includes('案例ID') && text.includes('结果标签');
  119. }
  120. function mapRow(header, raw) {
  121. const record = {};
  122. header.forEach((label, index) => {
  123. const key = FIELD_MAP[String(label || '').trim()];
  124. if (key) record[key] = String(raw[index] || '').trim();
  125. });
  126. if (!record.id) return null;
  127. const platform = normalizePlatform(record.platform);
  128. const outcome = OUTCOME_BY_LABEL[record.outcomeLabel] || 'neutral';
  129. return {
  130. ...record,
  131. platform,
  132. platformLabel: labelPlatform(platform),
  133. outcome,
  134. signalTags: extractSignalTags(record.keyReason)
  135. };
  136. }
  137. function buildFewshotCases(cases, source) {
  138. return {
  139. version: 1,
  140. source,
  141. generatedAt: new Date().toISOString(),
  142. purpose: '案例喂养 few-shot 库:品类+投放目标+人群+调性+平台 -> 选中账号特征 -> 结果标签。用于下次选号时做正/负样本参照。',
  143. cases: cases.map(item => ({
  144. id: item.id,
  145. category: item.category,
  146. goal: item.goal,
  147. audience: item.audience,
  148. brandTone: item.brandTone,
  149. platform: item.platform,
  150. platformLabel: item.platformLabel,
  151. selectedAccountProfile: item.selectedAccountProfile,
  152. outcomeLabel: item.outcomeLabel,
  153. outcome: item.outcome,
  154. keyReason: item.keyReason,
  155. customerFeedback: item.customerFeedback,
  156. executionData: item.executionData,
  157. signalTags: item.signalTags
  158. }))
  159. };
  160. }
  161. function buildInsights(cases) {
  162. const positive = cases.filter(item => item.outcome === 'positive');
  163. const neutral = cases.filter(item => item.outcome === 'neutral');
  164. const negative = cases.filter(item => item.outcome === 'negative');
  165. const lines = [];
  166. lines.push('# 案例归因洞察');
  167. lines.push('');
  168. lines.push(`> 由 case_template.xlsx 自动生成,共 ${cases.length} 条案例(正 ${positive.length} / 中性 ${neutral.length} / 负 ${negative.length})。`);
  169. lines.push('> 用途:把每单执行结果沉淀成可复用信号,人工复核后升级到 experience-rules 或 preference memory。');
  170. lines.push('');
  171. lines.push('## 正样本共性(爆文 / 达标)');
  172. lines.push('');
  173. if (positive.length) {
  174. positive.forEach(item => lines.push(`- ${item.id}|${item.category}|${item.brandTone}|「${item.keyReason}」`));
  175. const tags = countTags(positive);
  176. lines.push('');
  177. lines.push('成功信号频次:');
  178. lines.push('');
  179. lines.push('| 信号 | 次数 |');
  180. lines.push('| --- | --- |');
  181. Object.entries(tags).sort((a, b) => b[1] - a[1]).forEach(([tag, count]) => lines.push(`| ${tag} | ${count} |`));
  182. } else {
  183. lines.push('暂无正样本。');
  184. }
  185. lines.push('');
  186. lines.push('## 负样本共性(翻车)');
  187. lines.push('');
  188. if (negative.length) {
  189. negative.forEach(item => lines.push(`- ${item.id}|${item.category}|${item.platformLabel}|「${item.keyReason}」→ ${item.customerFeedback}`));
  190. const tags = countTags(negative);
  191. lines.push('');
  192. lines.push('失败信号频次:');
  193. lines.push('');
  194. lines.push('| 信号 | 次数 |');
  195. lines.push('| --- | --- |');
  196. Object.entries(tags).sort((a, b) => b[1] - a[1]).forEach(([tag, count]) => lines.push(`| ${tag} | ${count} |`));
  197. } else {
  198. lines.push('暂无负样本。');
  199. }
  200. lines.push('');
  201. lines.push('## 建议沉淀的规则');
  202. lines.push('');
  203. const suggestions = buildRuleSuggestions(cases);
  204. if (suggestions.length) {
  205. suggestions.forEach(item => lines.push(`- ${item}`));
  206. } else {
  207. lines.push('- 案例量不足,暂无高频信号可沉淀。建议按模板每天补充案例后重跑本导入器。');
  208. }
  209. return lines.join('\n');
  210. }
  211. function buildRuleSuggestions(cases) {
  212. const positiveTags = countTags(cases.filter(item => item.outcome === 'positive'));
  213. const negativeTags = countTags(cases.filter(item => item.outcome === 'negative'));
  214. const suggestions = [];
  215. const negativeEntries = Object.entries(negativeTags).sort((a, b) => b[1] - a[1]);
  216. for (const [tag, count] of negativeEntries) {
  217. if (count >= 2) suggestions.push(`负样本信号「${tag}」出现 ${count} 次,建议在 ranker 风险扣分或 experience-rules 里加入对应剔除/降级规则。`);
  218. }
  219. const positiveEntries = Object.entries(positiveTags).sort((a, b) => b[1] - a[1]);
  220. for (const [tag, count] of positiveEntries) {
  221. if (count >= 2) suggestions.push(`正样本信号「${tag}」出现 ${count} 次,建议在 ranker 评分或 brief-parser 风格词表里加入对应加分项。`);
  222. }
  223. return suggestions;
  224. }
  225. // 对齐 HISTORY_HEADER 的 CSV(博主名称为「选中账号特征」摘要占位,需回填真实博主/主页才能进客户效果审计)
  226. function buildHistoryCsv(cases) {
  227. const header = [
  228. 'brief编号', '项目名称', '类目', '客户原始Brief', '历史人工补号量基线',
  229. '参考账号或视频', '平台', '博主名称', '主页链接', '人工复核标签', '客户选择', '拒绝原因'
  230. ];
  231. const rows = [header];
  232. for (const item of cases) {
  233. const isNegative = item.outcome === 'negative';
  234. rows.push([
  235. item.id,
  236. `${item.category}-${item.goal}`,
  237. item.category,
  238. `【${item.brandTone}调性】【${item.audience}人群】【${item.goal}目标】`,
  239. '',
  240. '',
  241. item.platformLabel,
  242. item.selectedAccountProfile,
  243. '',
  244. '',
  245. CUSTOMER_DECISION_BY_LABEL[item.outcomeLabel] || '待客户反馈',
  246. isNegative ? item.keyReason : ''
  247. ]);
  248. }
  249. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  250. }
  251. function extractSignalTags(reason) {
  252. const text = String(reason || '');
  253. return SIGNAL_DICT.filter(item => item.words.some(word => text.includes(word))).map(item => item.tag);
  254. }
  255. function countTags(cases) {
  256. const counter = {};
  257. for (const item of cases) {
  258. for (const tag of item.signalTags || []) counter[tag] = (counter[tag] || 0) + 1;
  259. }
  260. return counter;
  261. }
  262. function normalizePlatform(value) {
  263. return PLATFORM_CODE[String(value || '').trim().toLowerCase()] || String(value || '').trim();
  264. }
  265. function labelPlatform(platform) {
  266. return { xiaohongshu: '小红书', douyin: '抖音', bilibili: 'B站' }[platform] || platform;
  267. }
  268. function csvCell(value) {
  269. const text = String(value ?? '');
  270. return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  271. }
  272. function withBom(text) {
  273. return `${text}`;
  274. }
  275. function parseArgs(argv) {
  276. const args = {};
  277. for (let index = 0; index < argv.length; index += 1) {
  278. const raw = argv[index];
  279. if (!raw.startsWith('--')) continue;
  280. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  281. const next = argv[index + 1];
  282. if (!next || next.startsWith('--')) args[key] = true;
  283. else {
  284. args[key] = next;
  285. index += 1;
  286. }
  287. }
  288. return args;
  289. }
  290. if (require.main === module) {
  291. try {
  292. main();
  293. } catch (error) {
  294. console.error(error && error.stack ? error.stack : String(error));
  295. process.exit(1);
  296. }
  297. }
  298. module.exports = {
  299. readCaseTemplate,
  300. parseCasesFromSheets,
  301. buildFewshotCases,
  302. buildInsights,
  303. buildHistoryCsv,
  304. buildRuleSuggestions,
  305. extractSignalTags
  306. };