chapter-insight-writer.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. const fs = require('fs');
  2. const path = require('path');
  3. const HYPOTHESIS_LABELS = {
  4. H1: '品类机会',
  5. H2: '用户需求',
  6. H3: '产品缺口',
  7. H4: '价格认知',
  8. H5: '渠道内容',
  9. H6: '竞品策略',
  10. H7: '定位表达',
  11. H8: '增长行动'
  12. };
  13. const LIGHT_CHAPTERS = [
  14. { id: 'Ch1', title: '结论先行', hypothesisTags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'H7', 'H8'], focus: '回答核心决策问题' },
  15. { id: 'Ch2', title: '用户声音', hypothesisTags: ['H2', 'H3'], focus: '呈现痛点、场景、需求结构' },
  16. { id: 'Ch3', title: '竞品/平台发现', hypothesisTags: ['H5', 'H6', 'H7'], focus: '解释平台内容、竞品策略和可用话术' },
  17. { id: 'Ch4', title: '行动建议', hypothesisTags: ['H8'], focus: '输出下一步 P0/P1/P2 动作和验证指标' }
  18. ];
  19. const ENTERPRISE_CHAPTERS = [
  20. { id: 'Ch1', title: '执行摘要与核心判断', hypothesisTags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'H7', 'H8'], focus: '面向决策人给出结论' },
  21. { id: 'Ch2', title: '品类与需求全景', hypothesisTags: ['H1', 'H2'], focus: '判断市场机会和需求结构' },
  22. { id: 'Ch3', title: '用户 VOC 与痛点结构', hypothesisTags: ['H2', 'H3'], focus: '展示真实用户语言' },
  23. { id: 'Ch4', title: '竞品与替代方案分析', hypothesisTags: ['H3', 'H6'], focus: '找到竞争位置和缺口' },
  24. { id: 'Ch5', title: '定位、话术、产品/内容机会', hypothesisTags: ['H5', 'H7'], focus: '转化为表达与产品机会' },
  25. { id: 'Ch6', title: '渠道与场景策略', hypothesisTags: ['H5', 'H8'], focus: '明确不同平台打法' },
  26. { id: 'Ch7', title: '行动计划与验证路径', hypothesisTags: ['H8'], focus: '形成落地闭环' }
  27. ];
  28. function parseArgs(argv) {
  29. const args = {};
  30. for (let i = 0; i < argv.length; i++) {
  31. const token = argv[i];
  32. if (!token.startsWith('--')) continue;
  33. const eq = token.indexOf('=');
  34. if (eq >= 0) {
  35. args[token.slice(2, eq)] = token.slice(eq + 1);
  36. } else {
  37. const key = token.slice(2);
  38. const next = argv[i + 1];
  39. if (next && !next.startsWith('--')) {
  40. args[key] = next;
  41. i++;
  42. } else {
  43. args[key] = true;
  44. }
  45. }
  46. }
  47. return args;
  48. }
  49. function usage() {
  50. return [
  51. 'Usage:',
  52. ' node scripts/tools/chapter-insight-writer.js --input <normalized-dir|_merged.json|comments-flat.jsonl> --output <out-dir> [--project <name>] [--category <name>] [--version light|enterprise] [--min-evidence 3]',
  53. '',
  54. 'Outputs:',
  55. ' chapter-insights.md',
  56. ' chapter-insights.json'
  57. ].join('\n');
  58. }
  59. function ensureDir(dirPath) {
  60. fs.mkdirSync(dirPath, { recursive: true });
  61. }
  62. function readJson(filePath) {
  63. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  64. }
  65. function readJsonl(filePath) {
  66. return fs.readFileSync(filePath, 'utf8')
  67. .split(/\r?\n/)
  68. .map(line => line.trim())
  69. .filter(Boolean)
  70. .map(line => JSON.parse(line));
  71. }
  72. function uniq(values) {
  73. return [...new Set(values.filter(value => value !== undefined && value !== null && value !== ''))];
  74. }
  75. function asArray(value) {
  76. if (!value) return [];
  77. if (Array.isArray(value)) return value;
  78. return [value];
  79. }
  80. function countBy(records, getter) {
  81. const counts = new Map();
  82. records.forEach(record => {
  83. const key = getter(record) || '未标注';
  84. counts.set(key, (counts.get(key) || 0) + 1);
  85. });
  86. return [...counts.entries()]
  87. .map(([label, count]) => ({ label, count }))
  88. .sort((a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label)));
  89. }
  90. function scoreEvidence(record) {
  91. return Number(record.likeCount || 0) + Number(record.replyCount || 0) * 2 + String(record.text || '').length / 80;
  92. }
  93. function cleanText(value) {
  94. return String(value || '').replace(/\s+/g, ' ').trim();
  95. }
  96. function truncate(value, length = 96) {
  97. const text = cleanText(value);
  98. return text.length > length ? `${text.slice(0, length - 1)}…` : text;
  99. }
  100. function resolveInput(inputPath) {
  101. const absolute = path.resolve(inputPath);
  102. const stat = fs.statSync(absolute);
  103. if (stat.isDirectory()) {
  104. const merged = path.join(absolute, '_merged.json');
  105. const flat = path.join(absolute, 'comments-flat.jsonl');
  106. if (fs.existsSync(merged)) return { type: 'merged', filePath: merged, data: readJson(merged) };
  107. if (fs.existsSync(flat)) return { type: 'jsonl', filePath: flat, data: readJsonl(flat) };
  108. throw new Error(`no _merged.json or comments-flat.jsonl found in ${absolute}`);
  109. }
  110. if (absolute.endsWith('.jsonl')) return { type: 'jsonl', filePath: absolute, data: readJsonl(absolute) };
  111. return { type: 'merged', filePath: absolute, data: readJson(absolute) };
  112. }
  113. function normalizeInput(resolved) {
  114. if (resolved.type === 'jsonl') {
  115. return {
  116. metadata: {},
  117. items: [],
  118. comments: resolved.data,
  119. sources: [{ file: path.basename(resolved.filePath), platform: 'jsonl', commentCount: resolved.data.length }]
  120. };
  121. }
  122. const data = resolved.data;
  123. if (Array.isArray(data)) return { metadata: {}, items: [], comments: data, sources: [] };
  124. return {
  125. metadata: data.metadata || {},
  126. items: asArray(data.items),
  127. comments: asArray(data.comments),
  128. sources: asArray(data.sources)
  129. };
  130. }
  131. function evidenceCard(record) {
  132. return {
  133. id: record.id || record.commentId,
  134. platform: record.platform,
  135. keyword: record.keyword,
  136. batch: record.batch,
  137. hypothesisTags: asArray(record.hypothesisTags),
  138. text: cleanText(record.text || record.content),
  139. author: record.author,
  140. likeCount: Number(record.likeCount || 0),
  141. replyCount: Number(record.replyCount || 0),
  142. url: record.url,
  143. parentId: record.parentId,
  144. commentId: record.commentId || record.id,
  145. theme: record.theme,
  146. source: `${record.platform || 'unknown'} / ${record.keyword || 'unknown'} / ${record.batch || 'P0'} / ${record.commentId || record.id || 'no-id'}`
  147. };
  148. }
  149. function filterByTags(records, tags) {
  150. const set = new Set(tags);
  151. return records.filter(record => asArray(record.hypothesisTags).some(tag => set.has(tag)));
  152. }
  153. function selectEvidence(records, limit) {
  154. const seen = new Set();
  155. return [...records]
  156. .filter(record => cleanText(record.text || record.content))
  157. .sort((a, b) => scoreEvidence(b) - scoreEvidence(a))
  158. .filter(record => {
  159. const key = cleanText(record.text || record.content).slice(0, 80);
  160. if (seen.has(key)) return false;
  161. seen.add(key);
  162. return true;
  163. })
  164. .slice(0, limit)
  165. .map(evidenceCard);
  166. }
  167. function inferConclusion(chapter, records, category) {
  168. if (!records.length) return `当前样本不足,暂不支持对「${chapter.title}」做结论。`;
  169. const themes = countBy(records, record => record.theme).slice(0, 3);
  170. const keywords = countBy(records, record => record.keyword).slice(0, 3);
  171. const themeText = themes.map(item => `${item.label}(${item.count})`).join('、');
  172. const keywordText = keywords.map(item => item.label).join('、');
  173. return `当前 ${records.length} 条可追溯 VOC 显示,${category || '该品类'} 在「${chapter.title}」中最稳定的信号集中在 ${themeText || '未标注主题'};主要由 ${keywordText || '未标注关键词'} 等关键词触发。`;
  174. }
  175. function inferExplanation(chapter, records) {
  176. if (!records.length) return '该章节没有足够 VOC 支撑,只能作为补采方向,不能写成业务结论。';
  177. const tags = chapter.hypothesisTags.map(tag => `${tag}${HYPOTHESIS_LABELS[tag] ? ` ${HYPOTHESIS_LABELS[tag]}` : ''}`).join('、');
  178. const platforms = countBy(records, record => record.platform).map(item => `${item.label} ${item.count}`).join('、');
  179. return `本章对应 ${tags}。证据来自 ${platforms || '未标注平台'},因此适合写成“当前样本支持的阶段性判断”,不应外推为全市场结论。`;
  180. }
  181. function inferAction(chapter, records, minEvidence) {
  182. if (records.length < minEvidence) return `补采 ${chapter.hypothesisTags.join('/')} 相关关键词,至少补足 ${minEvidence} 条可引用 VOC 后再定稿。`;
  183. if (chapter.id === 'Ch4' || chapter.title.includes('行动')) return '把证据最高的 3 个用户表达转成 P0/P1/P2 行动,并为每条行动指定验证指标。';
  184. if (chapter.title.includes('用户')) return '优先把高频痛点、场景和决策标准转成 VOC 证据卡,进入报告正文。';
  185. if (chapter.title.includes('平台') || chapter.title.includes('定位')) return '把高赞评论中的用户原话转成内容标题、详情页 FAQ 或话术 AB 测试。';
  186. return '保留本章结论作为报告开头判断,并在后续章节用证据卡展开。';
  187. }
  188. function buildChapter(chapter, allComments, context, options) {
  189. const records = filterByTags(allComments, chapter.hypothesisTags);
  190. const evidence = selectEvidence(records, options.evidenceLimit);
  191. const warnings = [];
  192. if (evidence.length < options.minEvidence) warnings.push(`证据不足:${chapter.id} 只有 ${evidence.length} 条去重证据,低于 ${options.minEvidence} 条。`);
  193. chapter.hypothesisTags.forEach(tag => {
  194. const count = filterByTags(allComments, [tag]).length;
  195. if (count < options.minEvidence) warnings.push(`${tag} ${HYPOTHESIS_LABELS[tag] || ''} 证据量不足:${count}/${options.minEvidence}。`);
  196. });
  197. return {
  198. ...chapter,
  199. evidenceCount: records.length,
  200. uniqueEvidenceCount: evidence.length,
  201. topThemes: countBy(records, record => record.theme).slice(0, 6),
  202. topKeywords: countBy(records, record => record.keyword).slice(0, 6),
  203. conclusion: inferConclusion(chapter, records, context.category),
  204. evidence,
  205. explanation: inferExplanation(chapter, records),
  206. action: inferAction(chapter, records, options.minEvidence),
  207. warnings: uniq(warnings)
  208. };
  209. }
  210. function buildDraft(normalized, args) {
  211. const version = args.version === 'enterprise' ? 'enterprise' : 'light';
  212. const chapters = version === 'enterprise' ? ENTERPRISE_CHAPTERS : LIGHT_CHAPTERS;
  213. const comments = normalized.comments.filter(comment => cleanText(comment.text || comment.content));
  214. const context = {
  215. project: args.project || normalized.metadata.project || 'chapter-insight-draft',
  216. category: args.category || normalized.metadata.category || '',
  217. version,
  218. generatedAt: new Date().toISOString(),
  219. platforms: uniq(comments.map(comment => comment.platform)),
  220. keywords: uniq(comments.map(comment => comment.keyword)),
  221. batches: uniq(comments.map(comment => comment.batch)),
  222. hypothesisTags: uniq(comments.flatMap(comment => asArray(comment.hypothesisTags))),
  223. commentCount: comments.length,
  224. itemCount: normalized.items.length
  225. };
  226. const options = {
  227. minEvidence: Number(args['min-evidence'] || 3),
  228. evidenceLimit: Number(args['evidence-limit'] || 5)
  229. };
  230. const chapterDrafts = chapters.map(chapter => buildChapter(chapter, comments, context, options));
  231. const audit = {
  232. generatedAt: context.generatedAt,
  233. minEvidence: options.minEvidence,
  234. chapterCount: chapterDrafts.length,
  235. warningCount: chapterDrafts.reduce((sum, chapter) => sum + chapter.warnings.length, 0),
  236. unsupportedConclusionCount: chapterDrafts.filter(chapter => !chapter.evidence.length && !chapter.conclusion.includes('暂不支持')).length,
  237. totalEvidenceCards: chapterDrafts.reduce((sum, chapter) => sum + chapter.evidence.length, 0)
  238. };
  239. return { metadata: context, chapters: chapterDrafts, audit };
  240. }
  241. function renderEvidenceMd(evidence) {
  242. if (!evidence.length) return '- 暂无可引用证据。';
  243. return evidence.map((item, index) => [
  244. `### 证据 ${index + 1}:${truncate(item.text, 28)}`,
  245. '',
  246. `- **来源**:${item.source}`,
  247. `- **原声**:“${item.text}”`,
  248. `- **标签**:${item.hypothesisTags.join(', ') || '未标注'}`,
  249. `- **互动**:${item.likeCount} 赞 / ${item.replyCount} 回复`,
  250. `- **解释**:这条原声可支撑本章关于「${item.theme || '未标注主题'}」的阶段性判断。`
  251. ].join('\n')).join('\n\n');
  252. }
  253. function renderMarkdown(draft) {
  254. const meta = draft.metadata;
  255. const lines = [];
  256. lines.push('# Chapter Insight Draft');
  257. lines.push('');
  258. lines.push('| 字段 | 内容 |');
  259. lines.push('|---|---|');
  260. lines.push(`| 项目 | ${meta.project} |`);
  261. lines.push(`| 品类 | ${meta.category || '未填写'} |`);
  262. lines.push(`| 版本 | ${meta.version} |`);
  263. lines.push(`| 平台 | ${meta.platforms.join('、') || '未标注'} |`);
  264. lines.push(`| 关键词 | ${meta.keywords.join('、') || '未标注'} |`);
  265. lines.push(`| VOC 数 | ${meta.commentCount} |`);
  266. lines.push(`| 生成时间 | ${meta.generatedAt} |`);
  267. lines.push('');
  268. lines.push('## 0. 审计摘要');
  269. lines.push('');
  270. lines.push(`- **章节数**:${draft.audit.chapterCount}`);
  271. lines.push(`- **证据卡数**:${draft.audit.totalEvidenceCards}`);
  272. lines.push(`- **警告数**:${draft.audit.warningCount}`);
  273. lines.push(`- **无证据结论数**:${draft.audit.unsupportedConclusionCount}`);
  274. lines.push('');
  275. draft.chapters.forEach(chapter => {
  276. lines.push(`## ${chapter.id}. ${chapter.title}`);
  277. lines.push('');
  278. lines.push(`- **章节目标**:${chapter.focus}`);
  279. lines.push(`- **对应假设**:${chapter.hypothesisTags.join(', ')}`);
  280. lines.push(`- **证据量**:${chapter.evidenceCount} 条 VOC / ${chapter.uniqueEvidenceCount} 条证据卡`);
  281. lines.push(`- **章节结论**:${chapter.conclusion}`);
  282. lines.push(`- **业务解释**:${chapter.explanation}`);
  283. lines.push(`- **行动建议**:${chapter.action}`);
  284. if (chapter.warnings.length) lines.push(`- **警告**:${chapter.warnings.join(';')}`);
  285. lines.push('');
  286. lines.push('### 高频主题');
  287. lines.push('');
  288. lines.push('| 主题 | 频次 |');
  289. lines.push('|---|---:|');
  290. chapter.topThemes.forEach(item => lines.push(`| ${item.label} | ${item.count} |`));
  291. lines.push('');
  292. lines.push('### VOC 证据卡');
  293. lines.push('');
  294. lines.push(renderEvidenceMd(chapter.evidence));
  295. lines.push('');
  296. });
  297. return `${lines.join('\n')}\n`;
  298. }
  299. function main() {
  300. const args = parseArgs(process.argv.slice(2));
  301. if (args.help || !args.input || !args.output) {
  302. console.log(usage());
  303. process.exit(args.help ? 0 : 1);
  304. }
  305. const resolved = resolveInput(args.input);
  306. const normalized = normalizeInput(resolved);
  307. const draft = buildDraft(normalized, args);
  308. const outputDir = path.resolve(args.output);
  309. ensureDir(outputDir);
  310. fs.writeFileSync(path.join(outputDir, 'chapter-insights.json'), JSON.stringify(draft, null, 2) + '\n', 'utf8');
  311. fs.writeFileSync(path.join(outputDir, 'chapter-insights.md'), renderMarkdown(draft), 'utf8');
  312. console.log(JSON.stringify({
  313. outputDir,
  314. files: ['chapter-insights.md', 'chapter-insights.json'],
  315. chapterCount: draft.audit.chapterCount,
  316. evidenceCards: draft.audit.totalEvidenceCards,
  317. warnings: draft.audit.warningCount,
  318. unsupportedConclusionCount: draft.audit.unsupportedConclusionCount
  319. }, null, 2));
  320. }
  321. if (require.main === module) {
  322. try {
  323. main();
  324. } catch (error) {
  325. console.error(error.message);
  326. process.exit(1);
  327. }
  328. }
  329. module.exports = {
  330. parseArgs,
  331. buildDraft,
  332. renderMarkdown,
  333. selectEvidence,
  334. countBy
  335. };