const fs = require('fs'); const path = require('path'); function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); } else { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; } function usage() { return [ 'Usage:', ' node scripts/tools/voc-report-auditor.js --report --merged <_merged.json> --comments [--matrix ] [--output ]', '', 'Outputs:', ' audit-result.json', ' audit-report.md' ].join('\n'); } function readText(filePath) { return fs.readFileSync(filePath, 'utf8'); } function readJson(filePath) { return JSON.parse(readText(filePath)); } function readJsonl(filePath) { return readText(filePath) .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) .map(line => JSON.parse(line)); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function addIssue(issues, severity, check, message, suggestion, details = {}) { issues.push({ severity, check, message, suggestion, details }); } function uniq(values) { return Array.from(new Set(values.filter(value => value !== undefined && value !== null && value !== ''))); } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function cleanText(value) { if (value === undefined || value === null) return ''; return String(value).replace(/\s+/g, ' ').trim(); } function getHeadings(reportText) { return reportText .split(/\r?\n/) .map((line, index) => ({ line: index + 1, text: line })) .filter(row => /^#{1,6}\s+/.test(row.text)) .map(row => ({ ...row, level: row.text.match(/^#{1,6}/)[0].length })); } function extractReportNumbers(reportText) { const normalized = reportText.replace(/,/g, ''); const noteMatch = normalized.match(/(\d+)\s*(?:篇)?\s*(?:笔记|note|notes|item|items)/i); const commentMatch = normalized.match(/(\d+)\s*(?:条)?\s*(?:评论|VOC|voc|原声|comment|comments|review|reviews)/i); const keywordMatch = normalized.match(/(\d+)\s*(?:个)?\s*(?:关键词|keyword|keywords)/i); return { itemCount: noteMatch ? Number(noteMatch[1]) : undefined, vocCount: commentMatch ? Number(commentMatch[1]) : undefined, keywordCount: keywordMatch ? Number(keywordMatch[1]) : undefined }; } function dataStats(merged, comments) { const items = asArray(merged.items); const mergedComments = asArray(merged.comments); const vocs = comments.length ? comments : mergedComments; const platforms = uniq([ ...asArray(merged.metadata?.platforms), ...items.map(item => item.platform), ...vocs.map(comment => comment.platform) ]); const keywords = uniq([ ...asArray(merged.metadata?.keywords), ...items.map(item => item.keyword), ...vocs.map(comment => comment.keyword) ]); const batches = uniq([ ...asArray(merged.metadata?.batches), ...items.map(item => item.batch), ...vocs.map(comment => comment.batch) ]); const hypothesisTags = uniq([ ...asArray(merged.metadata?.hypothesisTags), ...items.flatMap(item => asArray(item.hypothesisTags)), ...vocs.flatMap(comment => asArray(comment.hypothesisTags)) ]); return { itemCount: Number(merged.metadata?.itemCount ?? items.length), vocCount: Number(merged.metadata?.validVocCount ?? vocs.length), rawSampleCount: Number(merged.metadata?.rawSampleCount ?? items.length + vocs.length), platforms, keywords, batches, hypothesisTags, items, vocs }; } function buildEvidenceIndex(stats) { const index = new Set(); stats.items.forEach(item => { [item.id, item.productId, item.url, item.parentId].forEach(value => value && index.add(String(value))); }); stats.vocs.forEach(voc => { [voc.id, voc.commentId, voc.parentId, voc.url].forEach(value => value && index.add(String(value))); }); return index; } function extractEvidenceReferences(reportText) { const refs = new Set(); const idPattern = /\b(?:xhs|douyin|amazon|voc|note|video|review|comment)[a-zA-Z0-9_-]*\b/g; let match; while ((match = idPattern.exec(reportText)) !== null) { refs.add(match[0]); } const urlPattern = /https?:\/\/[^\s)\]}>"']+/g; while ((match = urlPattern.exec(reportText)) !== null) { refs.add(match[0]); } return Array.from(refs); } function hasDataNatureText(reportText) { return /(课程合成样例|真实采集|混合数据|模拟数据|合成样例|real\s+data|synthetic)/i.test(reportText); } function hasMedicalBoundary(reportText) { return /(不能替代|不代表|医学建议|临床|功效|合规|边界|风险)/i.test(reportText); } function checkPlaceholder(reportText, issues) { const patterns = [/\bTODO\b/i, /placeholder/i, /待补充/, /待完善/, /xxx/i, /TBD/i]; const hits = []; reportText.split(/\r?\n/).forEach((line, index) => { if (patterns.some(pattern => pattern.test(line))) hits.push({ line: index + 1, text: line.trim() }); }); if (hits.length) { addIssue(issues, 'fail', 'placeholder', `发现 ${hits.length} 处 placeholder 或待补内容`, '删除或填充所有 TODO、placeholder、待补充内容。', { hits: hits.slice(0, 20) }); } } function checkReportIntegrity(reportText, reportPath, issues) { const ext = path.extname(reportPath).toLowerCase(); const headings = getHeadings(reportText); if (!reportText.trim()) { addIssue(issues, 'fail', 'report-integrity', '报告文件为空', '检查报告生成流程并重新导出。'); return; } if (ext === '.html' || /]/i.test(reportText)) { if (!/<\/html>/i.test(reportText)) addIssue(issues, 'fail', 'html-integrity', 'HTML 缺少结束标签 ', '重新渲染 HTML 报告。'); if (!/]/i.test(reportText)) addIssue(issues, 'warn', 'html-integrity', 'HTML 缺少 body 标签', '确认 HTML 是否为完整交付文件。'); } else if (!headings.length) { addIssue(issues, 'fail', 'markdown-integrity', 'Markdown 报告缺少标题结构', '至少添加一级标题和主要章节标题。'); } const emptyHeadings = headings.filter((heading, index) => { const next = headings[index + 1]; if (next && next.level > heading.level) return false; const lines = reportText.split(/\r?\n/).slice(heading.line, next ? next.line - 1 : undefined).join('').trim(); return !lines; }); if (emptyHeadings.length) { addIssue(issues, 'fail', 'empty-section', `发现 ${emptyHeadings.length} 个空章节`, '为空章节补充结论、证据和业务解释,或删除空章节。', { headings: emptyHeadings }); } } function checkSampleCounts(reportText, stats, issues) { const numbers = extractReportNumbers(reportText); if (numbers.itemCount !== undefined && numbers.itemCount !== stats.itemCount) { addIssue(issues, 'fail', 'sample-count', `报告笔记/item 数为 ${numbers.itemCount},数据为 ${stats.itemCount}`, '同步报告封面和数据概况中的样本量。'); } if (numbers.vocCount !== undefined && numbers.vocCount !== stats.vocCount) { addIssue(issues, 'fail', 'sample-count', `报告评论/VOC 数为 ${numbers.vocCount},数据为 ${stats.vocCount}`, '同步报告封面、正文和 comments-flat.jsonl 的样本量。'); } if (numbers.keywordCount !== undefined && numbers.keywordCount !== stats.keywords.length) { addIssue(issues, 'warn', 'keyword-count', `报告关键词数为 ${numbers.keywordCount},数据为 ${stats.keywords.length}`, '确认关键词去重口径,并同步报告数字。'); } if (numbers.itemCount === undefined && numbers.vocCount === undefined) { addIssue(issues, 'warn', 'sample-count', '报告中未识别到样本量数字', '在报告信息或数据概况中写明 item 数和 VOC 数。'); } } function checkHypothesis(reportText, stats, issues) { const reportTags = uniq((reportText.match(/\bH[1-8]\b/g) || [])); const dataTags = stats.hypothesisTags.filter(tag => /^H[1-8]$/.test(tag)); if (!dataTags.length) { addIssue(issues, 'fail', 'hypothesis-coverage', '数据中没有 H1-H8 标签', '在采集矩阵或 normalizer 输入中补充 hypothesisTags。'); return; } const missingInReport = dataTags.filter(tag => !reportTags.includes(tag)); if (missingInReport.length) { addIssue(issues, 'warn', 'hypothesis-coverage', `报告未提及数据中的假设标签:${missingInReport.join(', ')}`, '在报告假设覆盖或证据卡中补充对应 H 标签。'); } const counts = {}; stats.vocs.forEach(voc => { asArray(voc.hypothesisTags).forEach(tag => { counts[tag] = (counts[tag] || 0) + 1; }); }); const zeroEvidence = dataTags.filter(tag => !counts[tag]); if (zeroEvidence.length) { addIssue(issues, 'fail', 'hypothesis-evidence', `以下假设标签没有 VOC 证据:${zeroEvidence.join(', ')}`, '补采或从结论中移除无证据假设。'); } } function checkEvidenceTraceability(reportText, stats, issues) { const refs = extractEvidenceReferences(reportText); const evidenceIndex = buildEvidenceIndex(stats); const knownRefs = refs.filter(ref => evidenceIndex.has(ref) || Array.from(evidenceIndex).some(value => value.includes(ref) || ref.includes(value))); const evidenceMarkers = (reportText.match(/VOC|原声|来源|证据|commentId|noteId|videoId|asin|review/gi) || []).length; if (!knownRefs.length && evidenceMarkers < 3) { addIssue(issues, 'fail', 'evidence-traceability', '报告缺少可回溯的 VOC 证据引用', '在证据卡中加入 platform、keyword、batch、noteId/commentId/url。'); } const suspiciousRefs = refs .filter(ref => /^(?:xhs|douyin|amazon|voc|note|video|review|comment)/i.test(ref)) .filter(ref => !knownRefs.includes(ref)); if (suspiciousRefs.length) { addIssue(issues, 'warn', 'evidence-traceability', `发现 ${suspiciousRefs.length} 个未匹配到数据的证据 ID`, '检查报告证据 ID 是否与 _merged.json 或 comments-flat.jsonl 一致。', { refs: suspiciousRefs.slice(0, 20) }); } } function checkDistribution(stats, issues) { if (!stats.platforms.length) addIssue(issues, 'fail', 'platform-distribution', '数据缺少 platform 分布', '检查 normalizer 输出中的 platform 字段。'); if (!stats.keywords.length) addIssue(issues, 'fail', 'keyword-distribution', '数据缺少 keyword 分布', '检查采集矩阵和 raw 数据 keyword 字段。'); if (!stats.batches.length) addIssue(issues, 'warn', 'batch-distribution', '数据缺少 batch 分布', '补充 P0/P1/P2 批次信息,方便审计采集优先级。'); const missingRequired = stats.vocs.filter(voc => !voc.platform || !voc.keyword || !voc.batch || !cleanText(voc.text) || !asArray(voc.hypothesisTags).length); if (missingRequired.length) { addIssue(issues, 'fail', 'field-completeness', `comments-flat 中有 ${missingRequired.length} 条 VOC 缺少必填字段`, '补齐 platform、keyword、batch、hypothesisTags、text。', { ids: missingRequired.slice(0, 20).map(voc => voc.id) }); } } function checkActions(reportText, issues) { const hasPriority = /\bP0\b|\bP1\b|\bP2\b/.test(reportText); const hasOwner = /负责人|负责角色|owner/i.test(reportText); const hasMetric = /验证指标|指标|转化率|点击率|满意度|复购率|metric/i.test(reportText); if (!hasPriority) addIssue(issues, 'fail', 'action-priority', '行动建议缺少 P0/P1/P2 优先级', '为关键行动补充 P0/P1/P2。'); if (!hasOwner) addIssue(issues, 'warn', 'action-owner', '行动建议缺少负责人或负责角色', '为 P0 行动补充负责人或负责角色。'); if (!hasMetric) addIssue(issues, 'warn', 'action-metric', '行动建议缺少验证指标', '为每条关键行动补充验证方式或指标。'); } function checkBoundaries(reportText, issues) { if (!hasDataNatureText(reportText)) { addIssue(issues, 'fail', 'data-nature', '报告未标注数据性质', '标注真实采集、课程样例或混合数据。'); } if (!hasMedicalBoundary(reportText)) { addIssue(issues, 'warn', 'risk-boundary', '报告未识别到明显风险/医学/功效边界说明', '补充数据边界、平台噪声、医学/功效边界和不能推出的结论。'); } } function checkMatrix(matrixPath, issues) { if (!matrixPath) return; if (!fs.existsSync(matrixPath)) { addIssue(issues, 'warn', 'collection-matrix', '传入的采集矩阵文件不存在', '检查 --matrix 路径。', { matrixPath }); return; } const text = readText(matrixPath); if (!/keyword|关键词/i.test(text) || !/platform|平台/i.test(text) || !/batch|P0|P1|P2/i.test(text)) { addIssue(issues, 'warn', 'collection-matrix', '采集矩阵缺少 keyword/platform/batch 基础字段', '补齐采集矩阵字段,保证样本可回溯。'); } } function statusFromIssues(issues) { const failCount = issues.filter(issue => issue.severity === 'fail').length; const warnCount = issues.filter(issue => issue.severity === 'warn').length; if (failCount) return 'fail'; if (warnCount) return 'warn'; return 'pass'; } function renderAuditMarkdown(result) { const lines = []; lines.push('# VOC Report Audit Result'); lines.push(''); lines.push(`- **Status**: ${result.status}`); lines.push(`- **Report**: ${result.inputs.report}`); lines.push(`- **Merged**: ${result.inputs.merged}`); lines.push(`- **Comments**: ${result.inputs.comments}`); lines.push(`- **GeneratedAt**: ${result.generatedAt}`); lines.push(''); lines.push('## Summary'); lines.push(''); lines.push('| Metric | Value |'); lines.push('|---|---:|'); lines.push(`| Fail | ${result.summary.fail} |`); lines.push(`| Warn | ${result.summary.warn} |`); lines.push(`| Pass checks | ${result.summary.passChecks} |`); lines.push(`| Item count | ${result.data.itemCount} |`); lines.push(`| VOC count | ${result.data.vocCount} |`); lines.push(`| Platform count | ${result.data.platforms.length} |`); lines.push(`| Keyword count | ${result.data.keywords.length} |`); lines.push(''); lines.push('## Data Distribution'); lines.push(''); lines.push(`- **Platforms**: ${result.data.platforms.join(', ') || 'N/A'}`); lines.push(`- **Keywords**: ${result.data.keywords.join(', ') || 'N/A'}`); lines.push(`- **Batches**: ${result.data.batches.join(', ') || 'N/A'}`); lines.push(`- **HypothesisTags**: ${result.data.hypothesisTags.join(', ') || 'N/A'}`); lines.push(''); lines.push('## Issues'); lines.push(''); if (!result.issues.length) { lines.push('No issues found.'); } else { result.issues.forEach((issue, index) => { lines.push(`### ${index + 1}. [${issue.severity}] ${issue.check}`); lines.push(''); lines.push(`- **Message**: ${issue.message}`); lines.push(`- **Suggestion**: ${issue.suggestion}`); if (issue.details && Object.keys(issue.details).length) { lines.push(`- **Details**: ${JSON.stringify(issue.details)}`); } lines.push(''); }); } return lines.join('\n') + '\n'; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args.report || !args.merged || !args.comments) { console.log(usage()); process.exit(args.help ? 0 : 1); } const reportPath = path.resolve(args.report); const mergedPath = path.resolve(args.merged); const commentsPath = path.resolve(args.comments); const matrixPath = args.matrix ? path.resolve(args.matrix) : undefined; const outputDir = path.resolve(args.output || path.dirname(reportPath)); const reportText = readText(reportPath); const merged = readJson(mergedPath); const comments = readJsonl(commentsPath); const stats = dataStats(merged, comments); const issues = []; checkPlaceholder(reportText, issues); checkReportIntegrity(reportText, reportPath, issues); checkSampleCounts(reportText, stats, issues); checkHypothesis(reportText, stats, issues); checkEvidenceTraceability(reportText, stats, issues); checkDistribution(stats, issues); checkActions(reportText, issues); checkBoundaries(reportText, issues); checkMatrix(matrixPath, issues); const status = statusFromIssues(issues); const result = { status, generatedAt: new Date().toISOString(), inputs: { report: reportPath, merged: mergedPath, comments: commentsPath, matrix: matrixPath }, summary: { fail: issues.filter(issue => issue.severity === 'fail').length, warn: issues.filter(issue => issue.severity === 'warn').length, passChecks: 8 - uniq(issues.map(issue => issue.check)).length }, data: { itemCount: stats.itemCount, vocCount: stats.vocCount, rawSampleCount: stats.rawSampleCount, platforms: stats.platforms, keywords: stats.keywords, batches: stats.batches, hypothesisTags: stats.hypothesisTags }, issues }; ensureDir(outputDir); fs.writeFileSync(path.join(outputDir, 'audit-result.json'), JSON.stringify(result, null, 2) + '\n', 'utf8'); fs.writeFileSync(path.join(outputDir, 'audit-report.md'), renderAuditMarkdown(result), 'utf8'); console.log(JSON.stringify({ status: result.status, fail: result.summary.fail, warn: result.summary.warn, outputDir, files: ['audit-result.json', 'audit-report.md'] }, null, 2)); if (status === 'fail' && args.strict) process.exit(1); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exit(1); } } module.exports = { parseArgs, dataStats, checkPlaceholder, checkSampleCounts, checkHypothesis, checkEvidenceTraceability, statusFromIssues };