#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { readFeedbackCsv } = require('./import-feedback-memory'); const ROOT = path.resolve(__dirname, '..'); const OUTPUTS = path.join(ROOT, 'outputs'); function main() { const args = parseArgs(process.argv.slice(2)); const outputDir = path.resolve(args.output || path.join(OUTPUTS, `feedback-loop-closure-${Date.now()}`)); const summary = buildFeedbackLoopClosureSummary({ root: ROOT, feedbackPath: args.feedback || args.feedbackPath, memoryPath: args.memory || args.memoryPath, resultPath: args.result || args.resultPath, mode: args.mode || args.proofMode || 'unknown', collectionMode: args.collectionMode || 'unknown', generatedBy: args.generatedBy || 'feedback-loop-closure:audit' }); fs.mkdirSync(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, 'feedback-loop-closure-summary.json'); const reportPath = path.join(outputDir, 'feedback-loop-closure-report.md'); const csvPath = path.join(outputDir, 'feedback-loop-closure.csv'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8'); console.log(JSON.stringify({ outputDir, json: jsonPath, report: reportPath, csv: csvPath, passed: summary.passed, complete: summary.complete, directCustomerProof: summary.directCustomerProof, negativeFeedbackCount: summary.negativeFeedbackCount, leakedCount: summary.leakedCount }, null, 2)); if (args.strict && !summary.passed) process.exitCode = 1; } function buildFeedbackLoopClosureSummary({ root = ROOT, feedbackPath, memoryPath, resultPath, mode = 'unknown', collectionMode = 'unknown', generatedBy = 'feedback-loop-closure:audit' }) { const resolvedFeedbackPath = feedbackPath ? path.resolve(feedbackPath) : ''; const resolvedMemoryPath = memoryPath ? path.resolve(memoryPath) : ''; const resolvedResultPath = resultPath ? path.resolve(resultPath) : ''; const missingInputs = []; if (!fileExists(resolvedFeedbackPath)) missingInputs.push('feedback csv'); if (!fileExists(resolvedMemoryPath)) missingInputs.push('preference memory json'); if (!fileExists(resolvedResultPath)) missingInputs.push('second-round sourcing result json'); const feedbackRecords = fileExists(resolvedFeedbackPath) ? readFeedbackCsv(resolvedFeedbackPath).map(normalizeFeedbackRecord) : []; const memory = fileExists(resolvedMemoryPath) ? readJson(resolvedMemoryPath) : null; const result = fileExists(resolvedResultPath) ? readJson(resolvedResultPath) : null; const negativeRows = feedbackRecords.filter(isNegativeFeedback); const blockedCreators = unique(negativeRows.map(row => row.creatorName || row.profileUrl).filter(Boolean)); const memoryBlockedCreators = new Set([...(memory?.blockedCreators || [])].map(normalizeKey)); const resultRows = extractResultRows(result); const leakedRows = resultRows.filter(row => { const key = normalizeKey(row.displayName || row.creatorName || row.name); const url = normalizeKey(row.profileUrl || row.homepage || row.url); return row.recommendStatus !== '已剔除' && (blockedCreators.some(name => normalizeKey(name) === key) || blockedCreators.some(name => normalizeKey(name) === url)); }); const blockedInMemoryCount = blockedCreators.filter(name => memoryBlockedCreators.has(normalizeKey(name))).length; const teamRuleSuggestions = Array.isArray(memory?.teamRuleSuggestions) ? memory.teamRuleSuggestions : []; const passed = missingInputs.length === 0 && negativeRows.length > 0 && blockedCreators.length > 0 && blockedInMemoryCount === blockedCreators.length && leakedRows.length === 0; const proofContext = { mode, collectionMode, generatedBy, requiresRealCustomerFeedback: true, requiresSecondRoundResult: true, requiresManualReviewLabels: true }; const directCustomerProof = passed && mode === 'live' && collectionMode === 'live' && Boolean(result?.proofContext?.customerFeedbackImported) && Boolean(result?.proofContext?.secondRoundAfterFeedback); return { generatedAt: new Date().toISOString(), root, complete: directCustomerProof, passed, proofLevel: directCustomerProof ? 'real_evidence' : passed ? 'smoke_or_local' : 'not_business_proof', directCustomerProof, proofContext, files: { feedback: resolvedFeedbackPath ? rel(root, resolvedFeedbackPath) : '', memory: resolvedMemoryPath ? rel(root, resolvedMemoryPath) : '', result: resolvedResultPath ? rel(root, resolvedResultPath) : '' }, missingInputs, feedbackCount: feedbackRecords.length, negativeFeedbackCount: negativeRows.length, blockedCreatorCount: blockedCreators.length, blockedInMemoryCount, leakedCount: leakedRows.length, secondRoundCandidateCount: resultRows.length, teamRuleSuggestionCount: teamRuleSuggestions.length, rows: blockedCreators.map(name => { const leaked = leakedRows.find(row => normalizeKey(row.displayName || row.creatorName || row.name) === normalizeKey(name)); return { creatorName: name, inMemory: memoryBlockedCreators.has(normalizeKey(name)), leakedToSecondRound: Boolean(leaked), leakedStatus: leaked?.recommendStatus || '', feedbackReasons: unique(negativeRows.filter(row => row.creatorName === name || row.profileUrl === name).map(row => row.feedbackReason || row.feedbackLabel)) }; }), guardrails: [ '本审计只证明反馈剔除/降权闭环是否执行,不证明客户效果达标。', '只有真实客户反馈、真实二轮 live 结果、人工复核标签和客户选择数据齐备时,才可进入客户效果审计。', 'sample 或 smoke 结果只能作为本地闭环能力证明。' ], nextActions: missingInputs.length ? [ '导入真实商务/客户反馈 CSV。', '使用同一 memory 路径重跑第二轮提号。', '把第二轮 tihao-sourcing-result.json 传给 feedback:closure。' ] : leakedRows.length ? ['修复记忆应用或排序逻辑,确保被拉黑/跑偏/调性不符账号不进入可发客户名单。'] : ['继续收集真实客户选择数据,再运行 customer-effect:audit。'] }; } function normalizeFeedbackRecord(row = {}) { return { briefId: pick(row, ['brief编号', 'briefId', 'brief_id']), platform: pick(row, ['平台', 'platform']), creatorName: pick(row, ['博主名称', 'creatorName', 'displayName', '账号']), profileUrl: pick(row, ['主页链接', 'profileUrl', 'url']), feedbackLabel: pick(row, ['人工复核标签', 'feedbackLabel', 'label']), feedbackReason: pick(row, ['反馈原因', 'feedbackReason', 'reason']), attribution: pick(row, ['归因类型', 'attribution']), scope: pick(row, ['影响范围', 'scope']), nextAction: pick(row, ['下一轮动作', 'nextAction']) }; } function isNegativeFeedback(row) { const text = `${row.feedbackLabel || ''} ${row.feedbackReason || ''} ${row.nextAction || ''}`; return /拉黑|硬性规则违规|跑偏|不可投|不要|剔除|调性不符|主页质感不符|参考账号不像|降权|拒绝/.test(text); } function extractResultRows(result) { const candidates = result?.data?.clientList || result?.data?.candidates || result?.clientList || result?.candidates || []; return Array.isArray(candidates) ? candidates.map(row => ({ displayName: row.displayName || row.creatorName || row.name || row['博主名称'] || '', profileUrl: row.profileUrl || row.homepage || row.url || row['主页链接'] || '', recommendStatus: row.recommendStatus || row.status || row['人工复核标签'] || '' })) : []; } function renderReport(summary) { const lines = [ '# 反馈二轮闭环审计', '', `- 生成时间:${summary.generatedAt}`, `- 是否通过:${summary.passed ? '是' : '否'}`, `- 是否客户效果证明:${summary.directCustomerProof ? '是' : '否'}`, `- proofLevel:${summary.proofLevel}`, `- mode:${summary.proofContext.mode}`, `- collectionMode:${summary.proofContext.collectionMode}`, `- 反馈记录数:${summary.feedbackCount}`, `- 负反馈记录数:${summary.negativeFeedbackCount}`, `- 拉黑/降权账号数:${summary.blockedCreatorCount}`, `- 已写入记忆账号数:${summary.blockedInMemoryCount}`, `- 第二轮候选数:${summary.secondRoundCandidateCount}`, `- 泄漏进第二轮可发名单数:${summary.leakedCount}`, `- 团队规则建议数:${summary.teamRuleSuggestionCount}`, '', '## 输入文件', '', `- 反馈 CSV:${summary.files.feedback || '缺失'}`, `- 记忆 JSON:${summary.files.memory || '缺失'}`, `- 第二轮结果 JSON:${summary.files.result || '缺失'}`, '', '## 闭环明细', '', '| 账号 | 写入记忆 | 泄漏进第二轮 | 泄漏状态 | 反馈原因 |', '| --- | --- | --- | --- | --- |', ...summary.rows.map(row => `| ${escapeCell(row.creatorName)} | ${row.inMemory ? '是' : '否'} | ${row.leakedToSecondRound ? '是' : '否'} | ${escapeCell(row.leakedStatus || '-')} | ${escapeCell(row.feedbackReasons.join(';') || '-')} |`), ...(summary.rows.length ? [] : ['| - | - | - | - | - |']), '', '## 下一步', '', ...summary.nextActions.map(item => `- ${item}`), '', '## 边界', '', ...summary.guardrails.map(item => `- ${item}`) ]; return lines.join('\n'); } function renderCsv(summary) { const header = ['账号', '写入记忆', '泄漏进第二轮', '泄漏状态', '反馈原因']; const rows = summary.rows.map(row => [ row.creatorName, row.inMemory ? '是' : '否', row.leakedToSecondRound ? '是' : '否', row.leakedStatus || '', row.feedbackReasons.join(';') ]); return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n'); } function pick(row, keys) { for (const key of keys) { if (row[key]) return String(row[key]).trim(); } return ''; } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } } function fileExists(file) { return Boolean(file && fs.existsSync(file)); } function unique(values) { return [...new Set((values || []).filter(Boolean).map(String))]; } function normalizeKey(value) { return String(value || '').trim().toLowerCase(); } function csvCell(value) { const text = String(value ?? ''); return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function escapeCell(value) { return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); } function withBom(text) { return `\uFEFF${text}`; } function rel(root, file) { return path.relative(root, file).replace(/\\/g, '/'); } function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i += 1) { const raw = argv[i]; if (!raw.startsWith('--')) continue; const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[i + 1]; if (!next || next.startsWith('--')) args[key] = true; else { args[key] = next; i += 1; } } return args; } if (require.main === module) { try { main(); } catch (error) { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); } } module.exports = { buildFeedbackLoopClosureSummary, normalizeFeedbackRecord, extractResultRows };