voc-report-auditor.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. const fs = require('fs');
  2. const path = require('path');
  3. function parseArgs(argv) {
  4. const args = {};
  5. for (let i = 0; i < argv.length; i++) {
  6. const token = argv[i];
  7. if (!token.startsWith('--')) continue;
  8. const eq = token.indexOf('=');
  9. if (eq >= 0) {
  10. args[token.slice(2, eq)] = token.slice(eq + 1);
  11. } else {
  12. const key = token.slice(2);
  13. const next = argv[i + 1];
  14. if (next && !next.startsWith('--')) {
  15. args[key] = next;
  16. i++;
  17. } else {
  18. args[key] = true;
  19. }
  20. }
  21. }
  22. return args;
  23. }
  24. function usage() {
  25. return [
  26. 'Usage:',
  27. ' node scripts/tools/voc-report-auditor.js --report <report.md|html> --merged <_merged.json> --comments <comments-flat.jsonl> [--matrix <matrix.md|json>] [--output <out-dir>]',
  28. '',
  29. 'Outputs:',
  30. ' audit-result.json',
  31. ' audit-report.md'
  32. ].join('\n');
  33. }
  34. function readText(filePath) {
  35. return fs.readFileSync(filePath, 'utf8');
  36. }
  37. function readJson(filePath) {
  38. return JSON.parse(readText(filePath));
  39. }
  40. function readJsonl(filePath) {
  41. return readText(filePath)
  42. .split(/\r?\n/)
  43. .map(line => line.trim())
  44. .filter(Boolean)
  45. .map(line => JSON.parse(line));
  46. }
  47. function ensureDir(dirPath) {
  48. fs.mkdirSync(dirPath, { recursive: true });
  49. }
  50. function addIssue(issues, severity, check, message, suggestion, details = {}) {
  51. issues.push({ severity, check, message, suggestion, details });
  52. }
  53. function uniq(values) {
  54. return Array.from(new Set(values.filter(value => value !== undefined && value !== null && value !== '')));
  55. }
  56. function asArray(value) {
  57. if (!value) return [];
  58. return Array.isArray(value) ? value : [value];
  59. }
  60. function cleanText(value) {
  61. if (value === undefined || value === null) return '';
  62. return String(value).replace(/\s+/g, ' ').trim();
  63. }
  64. function getHeadings(reportText) {
  65. return reportText
  66. .split(/\r?\n/)
  67. .map((line, index) => ({ line: index + 1, text: line }))
  68. .filter(row => /^#{1,6}\s+/.test(row.text))
  69. .map(row => ({ ...row, level: row.text.match(/^#{1,6}/)[0].length }));
  70. }
  71. function extractReportNumbers(reportText) {
  72. const normalized = reportText.replace(/,/g, '');
  73. const noteMatch = normalized.match(/(\d+)\s*(?:篇)?\s*(?:笔记|note|notes|item|items)/i);
  74. const commentMatch = normalized.match(/(\d+)\s*(?:条)?\s*(?:评论|VOC|voc|原声|comment|comments|review|reviews)/i);
  75. const keywordMatch = normalized.match(/(\d+)\s*(?:个)?\s*(?:关键词|keyword|keywords)/i);
  76. return {
  77. itemCount: noteMatch ? Number(noteMatch[1]) : undefined,
  78. vocCount: commentMatch ? Number(commentMatch[1]) : undefined,
  79. keywordCount: keywordMatch ? Number(keywordMatch[1]) : undefined
  80. };
  81. }
  82. function dataStats(merged, comments) {
  83. const items = asArray(merged.items);
  84. const mergedComments = asArray(merged.comments);
  85. const vocs = comments.length ? comments : mergedComments;
  86. const platforms = uniq([
  87. ...asArray(merged.metadata?.platforms),
  88. ...items.map(item => item.platform),
  89. ...vocs.map(comment => comment.platform)
  90. ]);
  91. const keywords = uniq([
  92. ...asArray(merged.metadata?.keywords),
  93. ...items.map(item => item.keyword),
  94. ...vocs.map(comment => comment.keyword)
  95. ]);
  96. const batches = uniq([
  97. ...asArray(merged.metadata?.batches),
  98. ...items.map(item => item.batch),
  99. ...vocs.map(comment => comment.batch)
  100. ]);
  101. const hypothesisTags = uniq([
  102. ...asArray(merged.metadata?.hypothesisTags),
  103. ...items.flatMap(item => asArray(item.hypothesisTags)),
  104. ...vocs.flatMap(comment => asArray(comment.hypothesisTags))
  105. ]);
  106. return {
  107. itemCount: Number(merged.metadata?.itemCount ?? items.length),
  108. vocCount: Number(merged.metadata?.validVocCount ?? vocs.length),
  109. rawSampleCount: Number(merged.metadata?.rawSampleCount ?? items.length + vocs.length),
  110. platforms,
  111. keywords,
  112. batches,
  113. hypothesisTags,
  114. items,
  115. vocs
  116. };
  117. }
  118. function buildEvidenceIndex(stats) {
  119. const index = new Set();
  120. stats.items.forEach(item => {
  121. [item.id, item.productId, item.url, item.parentId].forEach(value => value && index.add(String(value)));
  122. });
  123. stats.vocs.forEach(voc => {
  124. [voc.id, voc.commentId, voc.parentId, voc.url].forEach(value => value && index.add(String(value)));
  125. });
  126. return index;
  127. }
  128. function extractEvidenceReferences(reportText) {
  129. const refs = new Set();
  130. const idPattern = /\b(?:xhs|douyin|amazon|voc|note|video|review|comment)[a-zA-Z0-9_-]*\b/g;
  131. let match;
  132. while ((match = idPattern.exec(reportText)) !== null) {
  133. refs.add(match[0]);
  134. }
  135. const urlPattern = /https?:\/\/[^\s)\]}>"']+/g;
  136. while ((match = urlPattern.exec(reportText)) !== null) {
  137. refs.add(match[0]);
  138. }
  139. return Array.from(refs);
  140. }
  141. function hasDataNatureText(reportText) {
  142. return /(课程合成样例|真实采集|混合数据|模拟数据|合成样例|real\s+data|synthetic)/i.test(reportText);
  143. }
  144. function hasMedicalBoundary(reportText) {
  145. return /(不能替代|不代表|医学建议|临床|功效|合规|边界|风险)/i.test(reportText);
  146. }
  147. function checkPlaceholder(reportText, issues) {
  148. const patterns = [/\bTODO\b/i, /placeholder/i, /待补充/, /待完善/, /xxx/i, /TBD/i];
  149. const hits = [];
  150. reportText.split(/\r?\n/).forEach((line, index) => {
  151. if (patterns.some(pattern => pattern.test(line))) hits.push({ line: index + 1, text: line.trim() });
  152. });
  153. if (hits.length) {
  154. addIssue(issues, 'fail', 'placeholder', `发现 ${hits.length} 处 placeholder 或待补内容`, '删除或填充所有 TODO、placeholder、待补充内容。', { hits: hits.slice(0, 20) });
  155. }
  156. }
  157. function checkReportIntegrity(reportText, reportPath, issues) {
  158. const ext = path.extname(reportPath).toLowerCase();
  159. const headings = getHeadings(reportText);
  160. if (!reportText.trim()) {
  161. addIssue(issues, 'fail', 'report-integrity', '报告文件为空', '检查报告生成流程并重新导出。');
  162. return;
  163. }
  164. if (ext === '.html' || /<html[\s>]/i.test(reportText)) {
  165. if (!/<\/html>/i.test(reportText)) addIssue(issues, 'fail', 'html-integrity', 'HTML 缺少结束标签 </html>', '重新渲染 HTML 报告。');
  166. if (!/<body[\s>]/i.test(reportText)) addIssue(issues, 'warn', 'html-integrity', 'HTML 缺少 body 标签', '确认 HTML 是否为完整交付文件。');
  167. } else if (!headings.length) {
  168. addIssue(issues, 'fail', 'markdown-integrity', 'Markdown 报告缺少标题结构', '至少添加一级标题和主要章节标题。');
  169. }
  170. const emptyHeadings = headings.filter((heading, index) => {
  171. const next = headings[index + 1];
  172. if (next && next.level > heading.level) return false;
  173. const lines = reportText.split(/\r?\n/).slice(heading.line, next ? next.line - 1 : undefined).join('').trim();
  174. return !lines;
  175. });
  176. if (emptyHeadings.length) {
  177. addIssue(issues, 'fail', 'empty-section', `发现 ${emptyHeadings.length} 个空章节`, '为空章节补充结论、证据和业务解释,或删除空章节。', { headings: emptyHeadings });
  178. }
  179. }
  180. function checkSampleCounts(reportText, stats, issues) {
  181. const numbers = extractReportNumbers(reportText);
  182. if (numbers.itemCount !== undefined && numbers.itemCount !== stats.itemCount) {
  183. addIssue(issues, 'fail', 'sample-count', `报告笔记/item 数为 ${numbers.itemCount},数据为 ${stats.itemCount}`, '同步报告封面和数据概况中的样本量。');
  184. }
  185. if (numbers.vocCount !== undefined && numbers.vocCount !== stats.vocCount) {
  186. addIssue(issues, 'fail', 'sample-count', `报告评论/VOC 数为 ${numbers.vocCount},数据为 ${stats.vocCount}`, '同步报告封面、正文和 comments-flat.jsonl 的样本量。');
  187. }
  188. if (numbers.keywordCount !== undefined && numbers.keywordCount !== stats.keywords.length) {
  189. addIssue(issues, 'warn', 'keyword-count', `报告关键词数为 ${numbers.keywordCount},数据为 ${stats.keywords.length}`, '确认关键词去重口径,并同步报告数字。');
  190. }
  191. if (numbers.itemCount === undefined && numbers.vocCount === undefined) {
  192. addIssue(issues, 'warn', 'sample-count', '报告中未识别到样本量数字', '在报告信息或数据概况中写明 item 数和 VOC 数。');
  193. }
  194. }
  195. function checkHypothesis(reportText, stats, issues) {
  196. const reportTags = uniq((reportText.match(/\bH[1-8]\b/g) || []));
  197. const dataTags = stats.hypothesisTags.filter(tag => /^H[1-8]$/.test(tag));
  198. if (!dataTags.length) {
  199. addIssue(issues, 'fail', 'hypothesis-coverage', '数据中没有 H1-H8 标签', '在采集矩阵或 normalizer 输入中补充 hypothesisTags。');
  200. return;
  201. }
  202. const missingInReport = dataTags.filter(tag => !reportTags.includes(tag));
  203. if (missingInReport.length) {
  204. addIssue(issues, 'warn', 'hypothesis-coverage', `报告未提及数据中的假设标签:${missingInReport.join(', ')}`, '在报告假设覆盖或证据卡中补充对应 H 标签。');
  205. }
  206. const counts = {};
  207. stats.vocs.forEach(voc => {
  208. asArray(voc.hypothesisTags).forEach(tag => {
  209. counts[tag] = (counts[tag] || 0) + 1;
  210. });
  211. });
  212. const zeroEvidence = dataTags.filter(tag => !counts[tag]);
  213. if (zeroEvidence.length) {
  214. addIssue(issues, 'fail', 'hypothesis-evidence', `以下假设标签没有 VOC 证据:${zeroEvidence.join(', ')}`, '补采或从结论中移除无证据假设。');
  215. }
  216. }
  217. function checkEvidenceTraceability(reportText, stats, issues) {
  218. const refs = extractEvidenceReferences(reportText);
  219. const evidenceIndex = buildEvidenceIndex(stats);
  220. const knownRefs = refs.filter(ref => evidenceIndex.has(ref) || Array.from(evidenceIndex).some(value => value.includes(ref) || ref.includes(value)));
  221. const evidenceMarkers = (reportText.match(/VOC|原声|来源|证据|commentId|noteId|videoId|asin|review/gi) || []).length;
  222. if (!knownRefs.length && evidenceMarkers < 3) {
  223. addIssue(issues, 'fail', 'evidence-traceability', '报告缺少可回溯的 VOC 证据引用', '在证据卡中加入 platform、keyword、batch、noteId/commentId/url。');
  224. }
  225. const suspiciousRefs = refs
  226. .filter(ref => /^(?:xhs|douyin|amazon|voc|note|video|review|comment)/i.test(ref))
  227. .filter(ref => !knownRefs.includes(ref));
  228. if (suspiciousRefs.length) {
  229. addIssue(issues, 'warn', 'evidence-traceability', `发现 ${suspiciousRefs.length} 个未匹配到数据的证据 ID`, '检查报告证据 ID 是否与 _merged.json 或 comments-flat.jsonl 一致。', { refs: suspiciousRefs.slice(0, 20) });
  230. }
  231. }
  232. function checkDistribution(stats, issues) {
  233. if (!stats.platforms.length) addIssue(issues, 'fail', 'platform-distribution', '数据缺少 platform 分布', '检查 normalizer 输出中的 platform 字段。');
  234. if (!stats.keywords.length) addIssue(issues, 'fail', 'keyword-distribution', '数据缺少 keyword 分布', '检查采集矩阵和 raw 数据 keyword 字段。');
  235. if (!stats.batches.length) addIssue(issues, 'warn', 'batch-distribution', '数据缺少 batch 分布', '补充 P0/P1/P2 批次信息,方便审计采集优先级。');
  236. const missingRequired = stats.vocs.filter(voc => !voc.platform || !voc.keyword || !voc.batch || !cleanText(voc.text) || !asArray(voc.hypothesisTags).length);
  237. if (missingRequired.length) {
  238. addIssue(issues, 'fail', 'field-completeness', `comments-flat 中有 ${missingRequired.length} 条 VOC 缺少必填字段`, '补齐 platform、keyword、batch、hypothesisTags、text。', { ids: missingRequired.slice(0, 20).map(voc => voc.id) });
  239. }
  240. }
  241. function checkActions(reportText, issues) {
  242. const hasPriority = /\bP0\b|\bP1\b|\bP2\b/.test(reportText);
  243. const hasOwner = /负责人|负责角色|owner/i.test(reportText);
  244. const hasMetric = /验证指标|指标|转化率|点击率|满意度|复购率|metric/i.test(reportText);
  245. if (!hasPriority) addIssue(issues, 'fail', 'action-priority', '行动建议缺少 P0/P1/P2 优先级', '为关键行动补充 P0/P1/P2。');
  246. if (!hasOwner) addIssue(issues, 'warn', 'action-owner', '行动建议缺少负责人或负责角色', '为 P0 行动补充负责人或负责角色。');
  247. if (!hasMetric) addIssue(issues, 'warn', 'action-metric', '行动建议缺少验证指标', '为每条关键行动补充验证方式或指标。');
  248. }
  249. function checkBoundaries(reportText, issues) {
  250. if (!hasDataNatureText(reportText)) {
  251. addIssue(issues, 'fail', 'data-nature', '报告未标注数据性质', '标注真实采集、课程样例或混合数据。');
  252. }
  253. if (!hasMedicalBoundary(reportText)) {
  254. addIssue(issues, 'warn', 'risk-boundary', '报告未识别到明显风险/医学/功效边界说明', '补充数据边界、平台噪声、医学/功效边界和不能推出的结论。');
  255. }
  256. }
  257. function checkMatrix(matrixPath, issues) {
  258. if (!matrixPath) return;
  259. if (!fs.existsSync(matrixPath)) {
  260. addIssue(issues, 'warn', 'collection-matrix', '传入的采集矩阵文件不存在', '检查 --matrix 路径。', { matrixPath });
  261. return;
  262. }
  263. const text = readText(matrixPath);
  264. if (!/keyword|关键词/i.test(text) || !/platform|平台/i.test(text) || !/batch|P0|P1|P2/i.test(text)) {
  265. addIssue(issues, 'warn', 'collection-matrix', '采集矩阵缺少 keyword/platform/batch 基础字段', '补齐采集矩阵字段,保证样本可回溯。');
  266. }
  267. }
  268. function statusFromIssues(issues) {
  269. const failCount = issues.filter(issue => issue.severity === 'fail').length;
  270. const warnCount = issues.filter(issue => issue.severity === 'warn').length;
  271. if (failCount) return 'fail';
  272. if (warnCount) return 'warn';
  273. return 'pass';
  274. }
  275. function renderAuditMarkdown(result) {
  276. const lines = [];
  277. lines.push('# VOC Report Audit Result');
  278. lines.push('');
  279. lines.push(`- **Status**: ${result.status}`);
  280. lines.push(`- **Report**: ${result.inputs.report}`);
  281. lines.push(`- **Merged**: ${result.inputs.merged}`);
  282. lines.push(`- **Comments**: ${result.inputs.comments}`);
  283. lines.push(`- **GeneratedAt**: ${result.generatedAt}`);
  284. lines.push('');
  285. lines.push('## Summary');
  286. lines.push('');
  287. lines.push('| Metric | Value |');
  288. lines.push('|---|---:|');
  289. lines.push(`| Fail | ${result.summary.fail} |`);
  290. lines.push(`| Warn | ${result.summary.warn} |`);
  291. lines.push(`| Pass checks | ${result.summary.passChecks} |`);
  292. lines.push(`| Item count | ${result.data.itemCount} |`);
  293. lines.push(`| VOC count | ${result.data.vocCount} |`);
  294. lines.push(`| Platform count | ${result.data.platforms.length} |`);
  295. lines.push(`| Keyword count | ${result.data.keywords.length} |`);
  296. lines.push('');
  297. lines.push('## Data Distribution');
  298. lines.push('');
  299. lines.push(`- **Platforms**: ${result.data.platforms.join(', ') || 'N/A'}`);
  300. lines.push(`- **Keywords**: ${result.data.keywords.join(', ') || 'N/A'}`);
  301. lines.push(`- **Batches**: ${result.data.batches.join(', ') || 'N/A'}`);
  302. lines.push(`- **HypothesisTags**: ${result.data.hypothesisTags.join(', ') || 'N/A'}`);
  303. lines.push('');
  304. lines.push('## Issues');
  305. lines.push('');
  306. if (!result.issues.length) {
  307. lines.push('No issues found.');
  308. } else {
  309. result.issues.forEach((issue, index) => {
  310. lines.push(`### ${index + 1}. [${issue.severity}] ${issue.check}`);
  311. lines.push('');
  312. lines.push(`- **Message**: ${issue.message}`);
  313. lines.push(`- **Suggestion**: ${issue.suggestion}`);
  314. if (issue.details && Object.keys(issue.details).length) {
  315. lines.push(`- **Details**: ${JSON.stringify(issue.details)}`);
  316. }
  317. lines.push('');
  318. });
  319. }
  320. return lines.join('\n') + '\n';
  321. }
  322. function main() {
  323. const args = parseArgs(process.argv.slice(2));
  324. if (args.help || !args.report || !args.merged || !args.comments) {
  325. console.log(usage());
  326. process.exit(args.help ? 0 : 1);
  327. }
  328. const reportPath = path.resolve(args.report);
  329. const mergedPath = path.resolve(args.merged);
  330. const commentsPath = path.resolve(args.comments);
  331. const matrixPath = args.matrix ? path.resolve(args.matrix) : undefined;
  332. const outputDir = path.resolve(args.output || path.dirname(reportPath));
  333. const reportText = readText(reportPath);
  334. const merged = readJson(mergedPath);
  335. const comments = readJsonl(commentsPath);
  336. const stats = dataStats(merged, comments);
  337. const issues = [];
  338. checkPlaceholder(reportText, issues);
  339. checkReportIntegrity(reportText, reportPath, issues);
  340. checkSampleCounts(reportText, stats, issues);
  341. checkHypothesis(reportText, stats, issues);
  342. checkEvidenceTraceability(reportText, stats, issues);
  343. checkDistribution(stats, issues);
  344. checkActions(reportText, issues);
  345. checkBoundaries(reportText, issues);
  346. checkMatrix(matrixPath, issues);
  347. const status = statusFromIssues(issues);
  348. const result = {
  349. status,
  350. generatedAt: new Date().toISOString(),
  351. inputs: {
  352. report: reportPath,
  353. merged: mergedPath,
  354. comments: commentsPath,
  355. matrix: matrixPath
  356. },
  357. summary: {
  358. fail: issues.filter(issue => issue.severity === 'fail').length,
  359. warn: issues.filter(issue => issue.severity === 'warn').length,
  360. passChecks: 8 - uniq(issues.map(issue => issue.check)).length
  361. },
  362. data: {
  363. itemCount: stats.itemCount,
  364. vocCount: stats.vocCount,
  365. rawSampleCount: stats.rawSampleCount,
  366. platforms: stats.platforms,
  367. keywords: stats.keywords,
  368. batches: stats.batches,
  369. hypothesisTags: stats.hypothesisTags
  370. },
  371. issues
  372. };
  373. ensureDir(outputDir);
  374. fs.writeFileSync(path.join(outputDir, 'audit-result.json'), JSON.stringify(result, null, 2) + '\n', 'utf8');
  375. fs.writeFileSync(path.join(outputDir, 'audit-report.md'), renderAuditMarkdown(result), 'utf8');
  376. console.log(JSON.stringify({
  377. status: result.status,
  378. fail: result.summary.fail,
  379. warn: result.summary.warn,
  380. outputDir,
  381. files: ['audit-result.json', 'audit-report.md']
  382. }, null, 2));
  383. if (status === 'fail' && args.strict) process.exit(1);
  384. }
  385. if (require.main === module) {
  386. try {
  387. main();
  388. } catch (error) {
  389. console.error(error.message);
  390. process.exit(1);
  391. }
  392. }
  393. module.exports = {
  394. parseArgs,
  395. dataStats,
  396. checkPlaceholder,
  397. checkSampleCounts,
  398. checkHypothesis,
  399. checkEvidenceTraceability,
  400. statusFromIssues
  401. };