platform-mini-report-generator.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  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 PLATFORM_NAMES = {
  14. xiaohongshu: '小红书',
  15. xhs: '小红书',
  16. douyin: '抖音',
  17. amazon: 'Amazon',
  18. tiktok: 'TikTok',
  19. instagram: 'Instagram'
  20. };
  21. const PLATFORM_PURPOSES = {
  22. xiaohongshu: ['获客', '建信任', '转化', '复购'],
  23. xhs: ['获客', '建信任', '转化', '复购'],
  24. douyin: ['曝光', '互动', '转化', '复购'],
  25. amazon: ['转化', '留评', 'Listing 优化', '复购'],
  26. tiktok: ['曝光', '种草', '互动', '转化'],
  27. instagram: ['品牌感知', '种草', '互动', '转化']
  28. };
  29. const THEME_EXPLANATIONS = {
  30. 痛点: '用户正在规避闲置、无效、焦虑或使用失败风险,应优先转成产品改进和 FAQ。',
  31. 场景: '需求由具体生活时刻触发,适合拆成场景化内容、组合装或使用说明。',
  32. 决策: '用户需要明确选择标准,应把专业参数翻译成能下单的判断表。',
  33. 反馈: '使用反馈能暴露观察周期、体感边界和售后解释需求。',
  34. 信任: '信任门槛来自适用边界、合规表达和不夸大的风险提示。',
  35. 价格: '价格接受度与规格、试用成本、复购压力和效果确定性绑定。',
  36. 竞品: '竞品/替代方案能暴露现有方案的赢点、输点和差异化切口。',
  37. 话术: '用户原话可转成标题、详情页模块、客服脚本和内容 AB 测试。'
  38. };
  39. function parseArgs(argv) {
  40. const args = {};
  41. for (let i = 0; i < argv.length; i++) {
  42. const token = argv[i];
  43. if (!token.startsWith('--')) continue;
  44. const eq = token.indexOf('=');
  45. if (eq >= 0) {
  46. args[token.slice(2, eq)] = token.slice(eq + 1);
  47. } else {
  48. const key = token.slice(2);
  49. const next = argv[i + 1];
  50. if (next && !next.startsWith('--')) {
  51. args[key] = next;
  52. i++;
  53. } else {
  54. args[key] = true;
  55. }
  56. }
  57. }
  58. return args;
  59. }
  60. function usage() {
  61. return [
  62. 'Usage:',
  63. ' node scripts/tools/platform-mini-report-generator.js --input <normalized-dir|_merged.json|comments-flat.jsonl> --output <out-dir> [--project <name>] [--category <name>] [--platform xiaohongshu|douyin|amazon|tiktok|instagram] [--data-nature <text>] [--owner <name>] [--date YYYY-MM-DD]',
  64. '',
  65. 'Outputs:',
  66. ' platform-mini-report.md',
  67. ' platform-mini-report.json'
  68. ].join('\n');
  69. }
  70. function ensureDir(dirPath) {
  71. fs.mkdirSync(dirPath, { recursive: true });
  72. }
  73. function readJson(filePath) {
  74. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  75. }
  76. function readJsonl(filePath) {
  77. return fs.readFileSync(filePath, 'utf8')
  78. .split(/\r?\n/)
  79. .map(line => line.trim())
  80. .filter(Boolean)
  81. .map(line => JSON.parse(line));
  82. }
  83. function asArray(value) {
  84. if (!value) return [];
  85. if (Array.isArray(value)) return value;
  86. return [value];
  87. }
  88. function uniq(values) {
  89. return [...new Set(values.filter(value => value !== undefined && value !== null && value !== ''))];
  90. }
  91. function cleanText(value) {
  92. return String(value || '').replace(/\s+/g, ' ').trim();
  93. }
  94. function truncate(value, length = 72) {
  95. const text = cleanText(value);
  96. return text.length > length ? `${text.slice(0, length - 1)}…` : text;
  97. }
  98. function resolveInput(inputPath) {
  99. const absolute = path.resolve(inputPath);
  100. const stat = fs.statSync(absolute);
  101. if (stat.isDirectory()) {
  102. const merged = path.join(absolute, '_merged.json');
  103. const flat = path.join(absolute, 'comments-flat.jsonl');
  104. if (fs.existsSync(merged)) return { type: 'merged', filePath: merged, data: readJson(merged) };
  105. if (fs.existsSync(flat)) return { type: 'jsonl', filePath: flat, data: readJsonl(flat) };
  106. throw new Error(`no _merged.json or comments-flat.jsonl found in ${absolute}`);
  107. }
  108. if (absolute.endsWith('.jsonl')) return { type: 'jsonl', filePath: absolute, data: readJsonl(absolute) };
  109. return { type: 'merged', filePath: absolute, data: readJson(absolute) };
  110. }
  111. function normalizeInput(resolved) {
  112. if (resolved.type === 'jsonl') {
  113. return { metadata: {}, items: [], comments: resolved.data, sources: [{ file: path.basename(resolved.filePath), platform: 'jsonl', commentCount: resolved.data.length }] };
  114. }
  115. const data = resolved.data;
  116. if (Array.isArray(data)) return { metadata: {}, items: [], comments: data, sources: [] };
  117. return { metadata: data.metadata || {}, items: asArray(data.items), comments: asArray(data.comments), sources: asArray(data.sources) };
  118. }
  119. function countBy(records, getter) {
  120. const counts = new Map();
  121. records.forEach(record => {
  122. const key = getter(record) || '未标注';
  123. counts.set(key, (counts.get(key) || 0) + 1);
  124. });
  125. return [...counts.entries()]
  126. .map(([label, count]) => ({ label, count }))
  127. .sort((a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label)));
  128. }
  129. function scoreEvidence(record) {
  130. return Number(record.likeCount || 0) + Number(record.replyCount || 0) * 2 + cleanText(record.text || record.content).length / 80;
  131. }
  132. function platformKey(value) {
  133. const raw = String(value || '').toLowerCase();
  134. if (raw.includes('xiaohongshu') || raw === 'xhs' || raw.includes('小红书')) return 'xiaohongshu';
  135. if (raw.includes('douyin') || raw.includes('抖音')) return 'douyin';
  136. if (raw.includes('amazon')) return 'amazon';
  137. if (raw.includes('tiktok')) return 'tiktok';
  138. if (raw.includes('instagram')) return 'instagram';
  139. return raw || 'unknown';
  140. }
  141. function displayPlatform(value) {
  142. const key = platformKey(value);
  143. return PLATFORM_NAMES[key] || value || '未标注平台';
  144. }
  145. function evidenceCard(record) {
  146. return {
  147. id: record.id || record.commentId,
  148. source: `${displayPlatform(record.platform)} / ${record.keyword || '未标注关键词'} / ${record.batch || 'P0'} / ${record.commentId || record.id || 'no-id'}`,
  149. platform: record.platform,
  150. keyword: record.keyword,
  151. batch: record.batch || 'P0',
  152. text: cleanText(record.text || record.content),
  153. hypothesisTags: asArray(record.hypothesisTags),
  154. theme: record.theme || '未标注',
  155. likeCount: Number(record.likeCount || 0),
  156. replyCount: Number(record.replyCount || 0),
  157. url: record.url,
  158. parentId: record.parentId,
  159. commentId: record.commentId || record.id
  160. };
  161. }
  162. function selectEvidence(records, limit = 6) {
  163. const seen = new Set();
  164. return [...records]
  165. .filter(record => cleanText(record.text || record.content))
  166. .sort((a, b) => scoreEvidence(b) - scoreEvidence(a))
  167. .filter(record => {
  168. const key = cleanText(record.text || record.content).slice(0, 80);
  169. if (seen.has(key)) return false;
  170. seen.add(key);
  171. return true;
  172. })
  173. .slice(0, limit)
  174. .map(evidenceCard);
  175. }
  176. function highInteractionCount(items) {
  177. return items.filter(item => Number(item.likeCount || 0) > 500 || Number(item.commentCount || 0) > 100 || Number(item.raw?.metrics?.collectCount || 0) > 300).length;
  178. }
  179. function buildStats(normalized, comments) {
  180. const items = normalized.items;
  181. const keywords = uniq([...items.map(item => item.keyword), ...comments.map(comment => comment.keyword)]);
  182. const platforms = uniq([...items.map(item => item.platform), ...comments.map(comment => comment.platform)]);
  183. return {
  184. platformCount: platforms.length,
  185. keywordCount: keywords.length,
  186. itemCount: items.length,
  187. commentCount: comments.length,
  188. highInteractionItemCount: highInteractionCount(items),
  189. keywords,
  190. platforms,
  191. hypothesisTags: uniq(comments.flatMap(comment => asArray(comment.hypothesisTags))),
  192. batches: uniq(comments.map(comment => comment.batch))
  193. };
  194. }
  195. function buildHypothesisCoverage(comments) {
  196. return Object.keys(HYPOTHESIS_LABELS).map(tag => {
  197. const records = comments.filter(comment => asArray(comment.hypothesisTags).includes(tag));
  198. return {
  199. tag,
  200. label: HYPOTHESIS_LABELS[tag],
  201. count: records.length,
  202. status: records.length >= 3 ? '已覆盖' : records.length > 0 ? '弱覆盖' : '未覆盖',
  203. usage: inferHypothesisUsage(tag)
  204. };
  205. });
  206. }
  207. function inferHypothesisUsage(tag) {
  208. const usage = {
  209. H1: '用于判断品类机会、进入动机和核心阻力',
  210. H2: '用于提炼用户痛点、场景和真实需求',
  211. H3: '用于定位产品缺口、体验问题和详情页改进',
  212. H4: '用于判断价格、规格、周期和价值感',
  213. H5: '用于识别平台内容形式、种草路径和渠道触点',
  214. H6: '用于分析竞品替代、对比和差异化空间',
  215. H7: '用于抽取定位表达、标题和话术偏好',
  216. H8: '用于输出下一轮行动、验证指标和优先级'
  217. };
  218. return usage[tag] || '用于支撑报告判断';
  219. }
  220. function buildThemeClusters(comments) {
  221. const total = Math.max(comments.length, 1);
  222. return countBy(comments, comment => comment.theme).slice(0, 8).map(item => {
  223. const records = comments.filter(comment => (comment.theme || '未标注') === item.label);
  224. const evidence = selectEvidence(records, 1)[0];
  225. return {
  226. theme: item.label,
  227. count: item.count,
  228. ratio: `${Math.round(item.count / total * 100)}%`,
  229. quote: evidence ? evidence.text : '',
  230. source: evidence ? evidence.source : '',
  231. explanation: THEME_EXPLANATIONS[item.label] || '该主题需要人工结合原声继续解释。'
  232. };
  233. });
  234. }
  235. function evidenceStrength(comments, coverage) {
  236. const covered = coverage.filter(item => item.status === '已覆盖').length;
  237. if (comments.length >= 100 && covered >= 8) return '高';
  238. if (comments.length >= 30 && covered >= 4) return '中';
  239. return '低';
  240. }
  241. function buildConclusion(context, themes, comments, coverage) {
  242. if (!comments.length) return '当前没有可引用 VOC,不能生成核心结论。';
  243. const topThemes = themes.slice(0, 3).map(item => `${item.theme}(${item.count})`).join('、');
  244. const covered = coverage.filter(item => item.status !== '未覆盖').map(item => item.tag).join(', ');
  245. return `${context.platformName} ${comments.length} 条可追溯 VOC 显示,${context.category || '该品类'} 当前最稳定的用户信号集中在 ${topThemes || '未标注主题'};这些证据覆盖 ${covered || '暂无 H1-H8'},适合先形成单平台阶段性判断,再进入下一轮采集或报告定稿。`;
  246. }
  247. function buildOpportunities(themes, evidenceCards) {
  248. const selected = themes.slice(0, 5);
  249. return selected.map((theme, index) => {
  250. const evidence = evidenceCards.find(card => card.theme === theme.theme) || {};
  251. return {
  252. opportunity: `${theme.theme}机会`,
  253. evidence: evidence.text ? truncate(evidence.text, 42) : truncate(theme.quote, 42),
  254. action: opportunityAction(theme.theme),
  255. priority: index < 3 ? 'P0' : 'P1'
  256. };
  257. });
  258. }
  259. function opportunityAction(theme) {
  260. const actions = {
  261. 痛点: '把高频痛点转成详情页 FAQ、客服脚本和反向避坑内容。',
  262. 场景: '围绕高触发场景设计内容专题、组合装或使用指南。',
  263. 决策: '输出选择标准表,把专业参数翻译成用户能判断的语言。',
  264. 反馈: '补充观察周期、适应期和售后解释,降低误解与退货。',
  265. 信任: '强化适用/不适用人群、风险边界和合规表达。',
  266. 价格: '设计试用装、月装和每天成本解释,降低首次购买压力。',
  267. 竞品: '提炼竞品漏洞,形成差异化卖点或替代理由。',
  268. 话术: '把用户原话改写成标题、开头和内容 AB 测试。'
  269. };
  270. return actions[theme] || '把该主题对应原声转成一个可验证动作。';
  271. }
  272. function buildContentRecommendations(context, themes, evidenceCards) {
  273. const purposes = PLATFORM_PURPOSES[context.platformKey] || ['获客', '建信任', '转化', '复购'];
  274. return themes.slice(0, 5).map((theme, index) => {
  275. const evidence = evidenceCards.find(card => card.theme === theme.theme) || {};
  276. return {
  277. angle: theme.theme,
  278. title: titleForTheme(context.category, theme.theme, context.platformName),
  279. source: evidence.source || theme.source || '待关联证据',
  280. purpose: purposes[index % purposes.length]
  281. };
  282. });
  283. }
  284. function titleForTheme(category, theme, platformName) {
  285. const subject = category || '这个产品';
  286. const titles = {
  287. 痛点: `《${subject}到底解决什么问题?先看这些真实顾虑》`,
  288. 场景: `《什么时候才需要${subject}?把场景讲清楚》`,
  289. 决策: `《选${subject}别只看参数:先看这几条标准》`,
  290. 反馈: `《用${subject}多久观察一次?真实反馈怎么判断》`,
  291. 信任: `《${subject}适合谁、不适合谁,一次说清楚》`,
  292. 价格: `《先买试用装还是月装?${subject}新手这样选》`,
  293. 竞品: `《${subject}和常见替代方案怎么选?真实对比清单》`,
  294. 话术: `《把${subject}参数翻译成生活语言:${platformName}用户更爱看这个》`
  295. };
  296. return titles[theme] || `《${subject}用户最关心的 ${theme} 问题》`;
  297. }
  298. function buildRisks(context, comments) {
  299. const synthetic = String(context.dataNature || '').includes('样例') || String(context.dataNature || '').includes('合成');
  300. return {
  301. dataBoundary: synthetic ? `本报告使用${context.dataNature},不能代表真实市场规模、真实互动分布或真实品牌口碑。` : `本报告仅代表当前输入数据和采集窗口,不能直接外推为全平台或全市场结论。`,
  302. noiseSource: `${context.platformName} 内容可能包含商业合作、测评号、重复评论、算法推荐和主观体验偏差。`,
  303. cannotConclude: '不能把用户原声直接写成未经验证的功效、医学、安全或全市场结论。',
  304. nextValidation: comments.length < 100 ? '建议补足更多关键词和评论样本,再进行跨平台交叉验证。' : '建议用竞品词、差评词和成交评价做交叉验证。'
  305. };
  306. }
  307. function buildActions(context, opportunities) {
  308. return opportunities.slice(0, 4).map((item, index) => ({
  309. action: item.action,
  310. owner: ['产品经理', '内容运营', '电商/客服', '项目负责人'][index] || '项目负责人',
  311. deadline: ['1 周', '1 周', '2 周', '2 周'][index] || '2 周',
  312. metric: actionMetric(item.opportunity)
  313. }));
  314. }
  315. function actionMetric(opportunity) {
  316. if (opportunity.includes('价格')) return '试用装转月装率、咨询转化率';
  317. if (opportunity.includes('信任')) return 'FAQ 点击率、客服重复问题下降';
  318. if (opportunity.includes('话术')) return '收藏率、评论追问率、标题点击率';
  319. if (opportunity.includes('场景')) return '场景内容互动率、搜索词覆盖数';
  320. return '有效评论数、转化线索数、负反馈下降';
  321. }
  322. function buildReport(normalized, args) {
  323. const comments = normalized.comments.filter(comment => cleanText(comment.text || comment.content));
  324. const stats = buildStats(normalized, comments);
  325. const inferredPlatform = args.platform || stats.platforms[0] || normalized.metadata.platform || 'unknown';
  326. const context = {
  327. project: args.project || normalized.metadata.project || 'platform-mini-report',
  328. category: args.category || normalized.metadata.category || '',
  329. platformKey: platformKey(inferredPlatform),
  330. platformName: displayPlatform(inferredPlatform),
  331. dataNature: args['data-nature'] || args.dataNature || '未标注',
  332. owner: args.owner || 'VOC Skills 课程 demo',
  333. date: args.date || new Date().toISOString().slice(0, 10),
  334. generatedAt: new Date().toISOString()
  335. };
  336. const coverage = buildHypothesisCoverage(comments);
  337. const themes = buildThemeClusters(comments);
  338. const evidenceCards = selectEvidence(comments, Number(args['evidence-limit'] || 6));
  339. const strength = evidenceStrength(comments, coverage);
  340. const conclusion = buildConclusion(context, themes, comments, coverage);
  341. const opportunities = buildOpportunities(themes, evidenceCards);
  342. const contentRecommendations = buildContentRecommendations(context, themes, evidenceCards);
  343. const risks = buildRisks(context, comments);
  344. const actions = buildActions(context, opportunities);
  345. const audit = {
  346. generatedAt: context.generatedAt,
  347. commentCount: comments.length,
  348. evidenceCardCount: evidenceCards.length,
  349. warningCount: 0,
  350. uncoveredHypothesisCount: coverage.filter(item => item.status === '未覆盖').length,
  351. emptyConclusion: !comments.length,
  352. hasTraceableEvidence: evidenceCards.every(card => card.source && card.text)
  353. };
  354. if (!comments.length) audit.warningCount++;
  355. if (!audit.hasTraceableEvidence) audit.warningCount++;
  356. return { metadata: context, stats, conclusion, evidenceStrength: strength, hypothesisCoverage: coverage, themes, evidenceCards, opportunities, contentRecommendations, risks, actions, audit };
  357. }
  358. function renderMarkdown(report) {
  359. const meta = report.metadata;
  360. const lines = [];
  361. lines.push(`# Platform VOC Mini Report:${meta.platformName}${meta.category ? meta.category : ''}`);
  362. lines.push('');
  363. lines.push(`> 本报告由 \`platform-mini-report-generator\` 基于 normalized VOC 数据自动生成,所有关键结论必须回溯到 VOC 证据卡。`);
  364. lines.push('');
  365. lines.push('## 0. 报告信息');
  366. lines.push('');
  367. lines.push('| 字段 | 填写 |');
  368. lines.push('|---|---|');
  369. lines.push(`| 项目/品类 | ${meta.project}${meta.category ? ` / ${meta.category}` : ''} |`);
  370. lines.push(`| 分析平台 | ${meta.platformName} |`);
  371. lines.push(`| 分析关键词 | ${report.stats.keywords.join('、') || '未标注'} |`);
  372. lines.push(`| 样本范围 | ${report.stats.itemCount} 条内容 / ${report.stats.commentCount} 条 VOC |`);
  373. lines.push(`| 数据性质 | ${meta.dataNature} |`);
  374. lines.push(`| 完成人 | ${meta.owner} |`);
  375. lines.push(`| 日期 | ${meta.date} |`);
  376. lines.push('');
  377. lines.push('## 1. 一句话结论');
  378. lines.push('');
  379. lines.push(`- **核心结论**:${report.conclusion}`);
  380. lines.push(`- **证据强度**:${report.evidenceStrength}`);
  381. lines.push(`- **最关键证据**:${report.evidenceCards[0] ? `“${report.evidenceCards[0].text}”(${report.evidenceCards[0].source})` : '暂无可引用证据'}`);
  382. lines.push('');
  383. lines.push('## 2. 数据概况');
  384. lines.push('');
  385. lines.push('| 指标 | 数值 | 说明 |');
  386. lines.push('|---|---:|---|');
  387. lines.push(`| 关键词数 | ${report.stats.keywordCount} | ${report.stats.keywords.join('、') || '未标注'} |`);
  388. lines.push(`| 内容数 | ${report.stats.itemCount} | normalized items |`);
  389. lines.push(`| VOC 数 | ${report.stats.commentCount} | normalized comments |`);
  390. lines.push(`| 高互动内容数 | ${report.stats.highInteractionItemCount} | 点赞 > 500 / 收藏 > 300 / 评论 > 100 |`);
  391. lines.push(`| 覆盖假设数 | ${report.hypothesisCoverage.filter(item => item.status !== '未覆盖').length} | H1-H8 覆盖 |`);
  392. lines.push(`| 主要场景/主题 | ${report.themes.slice(0, 3).map(item => item.theme).join('、') || '未标注'} | 按 theme 聚合 |`);
  393. lines.push('');
  394. lines.push('### 2.1 H1-H8 假设覆盖速查');
  395. lines.push('');
  396. lines.push('| 假设 | 覆盖判断 | VOC 数 | 本报告中的使用方式 |');
  397. lines.push('|---|---|---:|---|');
  398. report.hypothesisCoverage.forEach(item => lines.push(`| ${item.tag} ${item.label} | ${item.status} | ${item.count} | ${item.usage} |`));
  399. lines.push('');
  400. lines.push('## 3. 高频主题聚类');
  401. lines.push('');
  402. lines.push('| 主题 | 占比/频次 | 典型原声 | 业务解释 |');
  403. lines.push('|---|---:|---|---|');
  404. report.themes.forEach(item => lines.push(`| ${item.theme} | ${item.ratio} / ${item.count} | “${truncate(item.quote, 52)}” | ${item.explanation} |`));
  405. lines.push('');
  406. lines.push('## 4. VOC 原声证据卡');
  407. lines.push('');
  408. report.evidenceCards.forEach((item, index) => {
  409. lines.push(`### 证据 ${index + 1}:${truncate(item.text, 32)}`);
  410. lines.push('');
  411. lines.push(`- **来源**:${item.source}`);
  412. lines.push(`- **原声**:“${item.text}”`);
  413. lines.push(`- **标签**:${item.hypothesisTags.join(', ') || '未标注'}`);
  414. lines.push(`- **解释**:这条原声可支撑「${item.theme}」相关判断。`);
  415. lines.push('');
  416. });
  417. lines.push('## 5. 产品/内容机会');
  418. lines.push('');
  419. lines.push('| 机会点 | 对应 VOC | 建议动作 | 优先级 |');
  420. lines.push('|---|---|---|---|');
  421. report.opportunities.forEach(item => lines.push(`| ${item.opportunity} | “${item.evidence}” | ${item.action} | ${item.priority} |`));
  422. lines.push('');
  423. lines.push(`## 6. ${meta.platformName}内容建议`);
  424. lines.push('');
  425. lines.push('| 内容角度 | 推荐标题 | 证据来源 | 目的 |');
  426. lines.push('|---|---|---|---|');
  427. report.contentRecommendations.forEach(item => lines.push(`| ${item.angle} | ${item.title} | ${item.source} | ${item.purpose} |`));
  428. lines.push('');
  429. lines.push('## 7. 风险与边界');
  430. lines.push('');
  431. lines.push(`- **数据边界**:${report.risks.dataBoundary}`);
  432. lines.push(`- **噪声来源**:${report.risks.noiseSource}`);
  433. lines.push(`- **不能推出的结论**:${report.risks.cannotConclude}`);
  434. lines.push(`- **下一步需要补采/验证**:${report.risks.nextValidation}`);
  435. lines.push('');
  436. lines.push('## 8. 结课行动清单');
  437. lines.push('');
  438. lines.push('| 行动 | 负责人 | 截止时间 | 验证指标 |');
  439. lines.push('|---|---|---|---|');
  440. report.actions.forEach(item => lines.push(`| ${item.action} | ${item.owner} | ${item.deadline} | ${item.metric} |`));
  441. lines.push('');
  442. lines.push('## 9. 生成审计');
  443. lines.push('');
  444. lines.push(`- **证据卡数**:${report.audit.evidenceCardCount}`);
  445. lines.push(`- **警告数**:${report.audit.warningCount}`);
  446. lines.push(`- **未覆盖假设数**:${report.audit.uncoveredHypothesisCount}`);
  447. lines.push(`- **证据可追溯**:${report.audit.hasTraceableEvidence ? '是' : '否'}`);
  448. return `${lines.join('\n')}\n`;
  449. }
  450. function main() {
  451. const args = parseArgs(process.argv.slice(2));
  452. if (args.help || !args.input || !args.output) {
  453. console.log(usage());
  454. process.exit(args.help ? 0 : 1);
  455. }
  456. const resolved = resolveInput(args.input);
  457. const normalized = normalizeInput(resolved);
  458. const report = buildReport(normalized, args);
  459. const outputDir = path.resolve(args.output);
  460. ensureDir(outputDir);
  461. fs.writeFileSync(path.join(outputDir, 'platform-mini-report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
  462. fs.writeFileSync(path.join(outputDir, 'platform-mini-report.md'), renderMarkdown(report), 'utf8');
  463. console.log(JSON.stringify({
  464. outputDir,
  465. files: ['platform-mini-report.md', 'platform-mini-report.json'],
  466. platform: report.metadata.platformName,
  467. commentCount: report.stats.commentCount,
  468. evidenceCards: report.audit.evidenceCardCount,
  469. warnings: report.audit.warningCount,
  470. uncoveredHypothesisCount: report.audit.uncoveredHypothesisCount
  471. }, null, 2));
  472. }
  473. if (require.main === module) {
  474. try {
  475. main();
  476. } catch (error) {
  477. console.error(error.message);
  478. process.exit(1);
  479. }
  480. }
  481. module.exports = {
  482. parseArgs,
  483. buildReport,
  484. renderMarkdown,
  485. selectEvidence,
  486. buildThemeClusters,
  487. buildHypothesisCoverage
  488. };