| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- #!/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
- };
|