| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- 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 <report.md|html> --merged <_merged.json> --comments <comments-flat.jsonl> [--matrix <matrix.md|json>] [--output <out-dir>]',
- '',
- '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' || /<html[\s>]/i.test(reportText)) {
- if (!/<\/html>/i.test(reportText)) addIssue(issues, 'fail', 'html-integrity', 'HTML 缺少结束标签 </html>', '重新渲染 HTML 报告。');
- if (!/<body[\s>]/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
- };
|