feedback-loop-closure-audit.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { readFeedbackCsv } = require('./import-feedback-memory');
  5. const ROOT = path.resolve(__dirname, '..');
  6. const OUTPUTS = path.join(ROOT, 'outputs');
  7. function main() {
  8. const args = parseArgs(process.argv.slice(2));
  9. const outputDir = path.resolve(args.output || path.join(OUTPUTS, `feedback-loop-closure-${Date.now()}`));
  10. const summary = buildFeedbackLoopClosureSummary({
  11. root: ROOT,
  12. feedbackPath: args.feedback || args.feedbackPath,
  13. memoryPath: args.memory || args.memoryPath,
  14. resultPath: args.result || args.resultPath,
  15. mode: args.mode || args.proofMode || 'unknown',
  16. collectionMode: args.collectionMode || 'unknown',
  17. generatedBy: args.generatedBy || 'feedback-loop-closure:audit'
  18. });
  19. fs.mkdirSync(outputDir, { recursive: true });
  20. const jsonPath = path.join(outputDir, 'feedback-loop-closure-summary.json');
  21. const reportPath = path.join(outputDir, 'feedback-loop-closure-report.md');
  22. const csvPath = path.join(outputDir, 'feedback-loop-closure.csv');
  23. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  24. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  25. fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8');
  26. console.log(JSON.stringify({
  27. outputDir,
  28. json: jsonPath,
  29. report: reportPath,
  30. csv: csvPath,
  31. passed: summary.passed,
  32. complete: summary.complete,
  33. directCustomerProof: summary.directCustomerProof,
  34. negativeFeedbackCount: summary.negativeFeedbackCount,
  35. leakedCount: summary.leakedCount
  36. }, null, 2));
  37. if (args.strict && !summary.passed) process.exitCode = 1;
  38. }
  39. function buildFeedbackLoopClosureSummary({ root = ROOT, feedbackPath, memoryPath, resultPath, mode = 'unknown', collectionMode = 'unknown', generatedBy = 'feedback-loop-closure:audit' }) {
  40. const resolvedFeedbackPath = feedbackPath ? path.resolve(feedbackPath) : '';
  41. const resolvedMemoryPath = memoryPath ? path.resolve(memoryPath) : '';
  42. const resolvedResultPath = resultPath ? path.resolve(resultPath) : '';
  43. const missingInputs = [];
  44. if (!fileExists(resolvedFeedbackPath)) missingInputs.push('feedback csv');
  45. if (!fileExists(resolvedMemoryPath)) missingInputs.push('preference memory json');
  46. if (!fileExists(resolvedResultPath)) missingInputs.push('second-round sourcing result json');
  47. const feedbackRecords = fileExists(resolvedFeedbackPath) ? readFeedbackCsv(resolvedFeedbackPath).map(normalizeFeedbackRecord) : [];
  48. const memory = fileExists(resolvedMemoryPath) ? readJson(resolvedMemoryPath) : null;
  49. const result = fileExists(resolvedResultPath) ? readJson(resolvedResultPath) : null;
  50. const negativeRows = feedbackRecords.filter(isNegativeFeedback);
  51. const blockedCreators = unique(negativeRows.map(row => row.creatorName || row.profileUrl).filter(Boolean));
  52. const memoryBlockedCreators = new Set([...(memory?.blockedCreators || [])].map(normalizeKey));
  53. const resultRows = extractResultRows(result);
  54. const leakedRows = resultRows.filter(row => {
  55. const key = normalizeKey(row.displayName || row.creatorName || row.name);
  56. const url = normalizeKey(row.profileUrl || row.homepage || row.url);
  57. return row.recommendStatus !== '已剔除' &&
  58. (blockedCreators.some(name => normalizeKey(name) === key) || blockedCreators.some(name => normalizeKey(name) === url));
  59. });
  60. const blockedInMemoryCount = blockedCreators.filter(name => memoryBlockedCreators.has(normalizeKey(name))).length;
  61. const teamRuleSuggestions = Array.isArray(memory?.teamRuleSuggestions) ? memory.teamRuleSuggestions : [];
  62. const passed = missingInputs.length === 0 &&
  63. negativeRows.length > 0 &&
  64. blockedCreators.length > 0 &&
  65. blockedInMemoryCount === blockedCreators.length &&
  66. leakedRows.length === 0;
  67. const proofContext = {
  68. mode,
  69. collectionMode,
  70. generatedBy,
  71. requiresRealCustomerFeedback: true,
  72. requiresSecondRoundResult: true,
  73. requiresManualReviewLabels: true
  74. };
  75. const directCustomerProof = passed &&
  76. mode === 'live' &&
  77. collectionMode === 'live' &&
  78. Boolean(result?.proofContext?.customerFeedbackImported) &&
  79. Boolean(result?.proofContext?.secondRoundAfterFeedback);
  80. return {
  81. generatedAt: new Date().toISOString(),
  82. root,
  83. complete: directCustomerProof,
  84. passed,
  85. proofLevel: directCustomerProof ? 'real_evidence' : passed ? 'smoke_or_local' : 'not_business_proof',
  86. directCustomerProof,
  87. proofContext,
  88. files: {
  89. feedback: resolvedFeedbackPath ? rel(root, resolvedFeedbackPath) : '',
  90. memory: resolvedMemoryPath ? rel(root, resolvedMemoryPath) : '',
  91. result: resolvedResultPath ? rel(root, resolvedResultPath) : ''
  92. },
  93. missingInputs,
  94. feedbackCount: feedbackRecords.length,
  95. negativeFeedbackCount: negativeRows.length,
  96. blockedCreatorCount: blockedCreators.length,
  97. blockedInMemoryCount,
  98. leakedCount: leakedRows.length,
  99. secondRoundCandidateCount: resultRows.length,
  100. teamRuleSuggestionCount: teamRuleSuggestions.length,
  101. rows: blockedCreators.map(name => {
  102. const leaked = leakedRows.find(row => normalizeKey(row.displayName || row.creatorName || row.name) === normalizeKey(name));
  103. return {
  104. creatorName: name,
  105. inMemory: memoryBlockedCreators.has(normalizeKey(name)),
  106. leakedToSecondRound: Boolean(leaked),
  107. leakedStatus: leaked?.recommendStatus || '',
  108. feedbackReasons: unique(negativeRows.filter(row => row.creatorName === name || row.profileUrl === name).map(row => row.feedbackReason || row.feedbackLabel))
  109. };
  110. }),
  111. guardrails: [
  112. '本审计只证明反馈剔除/降权闭环是否执行,不证明客户效果达标。',
  113. '只有真实客户反馈、真实二轮 live 结果、人工复核标签和客户选择数据齐备时,才可进入客户效果审计。',
  114. 'sample 或 smoke 结果只能作为本地闭环能力证明。'
  115. ],
  116. nextActions: missingInputs.length
  117. ? [
  118. '导入真实商务/客户反馈 CSV。',
  119. '使用同一 memory 路径重跑第二轮提号。',
  120. '把第二轮 tihao-sourcing-result.json 传给 feedback:closure。'
  121. ]
  122. : leakedRows.length
  123. ? ['修复记忆应用或排序逻辑,确保被拉黑/跑偏/调性不符账号不进入可发客户名单。']
  124. : ['继续收集真实客户选择数据,再运行 customer-effect:audit。']
  125. };
  126. }
  127. function normalizeFeedbackRecord(row = {}) {
  128. return {
  129. briefId: pick(row, ['brief编号', 'briefId', 'brief_id']),
  130. platform: pick(row, ['平台', 'platform']),
  131. creatorName: pick(row, ['博主名称', 'creatorName', 'displayName', '账号']),
  132. profileUrl: pick(row, ['主页链接', 'profileUrl', 'url']),
  133. feedbackLabel: pick(row, ['人工复核标签', 'feedbackLabel', 'label']),
  134. feedbackReason: pick(row, ['反馈原因', 'feedbackReason', 'reason']),
  135. attribution: pick(row, ['归因类型', 'attribution']),
  136. scope: pick(row, ['影响范围', 'scope']),
  137. nextAction: pick(row, ['下一轮动作', 'nextAction'])
  138. };
  139. }
  140. function isNegativeFeedback(row) {
  141. const text = `${row.feedbackLabel || ''} ${row.feedbackReason || ''} ${row.nextAction || ''}`;
  142. return /拉黑|硬性规则违规|跑偏|不可投|不要|剔除|调性不符|主页质感不符|参考账号不像|降权|拒绝/.test(text);
  143. }
  144. function extractResultRows(result) {
  145. const candidates = result?.data?.clientList || result?.data?.candidates || result?.clientList || result?.candidates || [];
  146. return Array.isArray(candidates) ? candidates.map(row => ({
  147. displayName: row.displayName || row.creatorName || row.name || row['博主名称'] || '',
  148. profileUrl: row.profileUrl || row.homepage || row.url || row['主页链接'] || '',
  149. recommendStatus: row.recommendStatus || row.status || row['人工复核标签'] || ''
  150. })) : [];
  151. }
  152. function renderReport(summary) {
  153. const lines = [
  154. '# 反馈二轮闭环审计',
  155. '',
  156. `- 生成时间:${summary.generatedAt}`,
  157. `- 是否通过:${summary.passed ? '是' : '否'}`,
  158. `- 是否客户效果证明:${summary.directCustomerProof ? '是' : '否'}`,
  159. `- proofLevel:${summary.proofLevel}`,
  160. `- mode:${summary.proofContext.mode}`,
  161. `- collectionMode:${summary.proofContext.collectionMode}`,
  162. `- 反馈记录数:${summary.feedbackCount}`,
  163. `- 负反馈记录数:${summary.negativeFeedbackCount}`,
  164. `- 拉黑/降权账号数:${summary.blockedCreatorCount}`,
  165. `- 已写入记忆账号数:${summary.blockedInMemoryCount}`,
  166. `- 第二轮候选数:${summary.secondRoundCandidateCount}`,
  167. `- 泄漏进第二轮可发名单数:${summary.leakedCount}`,
  168. `- 团队规则建议数:${summary.teamRuleSuggestionCount}`,
  169. '',
  170. '## 输入文件',
  171. '',
  172. `- 反馈 CSV:${summary.files.feedback || '缺失'}`,
  173. `- 记忆 JSON:${summary.files.memory || '缺失'}`,
  174. `- 第二轮结果 JSON:${summary.files.result || '缺失'}`,
  175. '',
  176. '## 闭环明细',
  177. '',
  178. '| 账号 | 写入记忆 | 泄漏进第二轮 | 泄漏状态 | 反馈原因 |',
  179. '| --- | --- | --- | --- | --- |',
  180. ...summary.rows.map(row => `| ${escapeCell(row.creatorName)} | ${row.inMemory ? '是' : '否'} | ${row.leakedToSecondRound ? '是' : '否'} | ${escapeCell(row.leakedStatus || '-')} | ${escapeCell(row.feedbackReasons.join(';') || '-')} |`),
  181. ...(summary.rows.length ? [] : ['| - | - | - | - | - |']),
  182. '',
  183. '## 下一步',
  184. '',
  185. ...summary.nextActions.map(item => `- ${item}`),
  186. '',
  187. '## 边界',
  188. '',
  189. ...summary.guardrails.map(item => `- ${item}`)
  190. ];
  191. return lines.join('\n');
  192. }
  193. function renderCsv(summary) {
  194. const header = ['账号', '写入记忆', '泄漏进第二轮', '泄漏状态', '反馈原因'];
  195. const rows = summary.rows.map(row => [
  196. row.creatorName,
  197. row.inMemory ? '是' : '否',
  198. row.leakedToSecondRound ? '是' : '否',
  199. row.leakedStatus || '',
  200. row.feedbackReasons.join(';')
  201. ]);
  202. return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n');
  203. }
  204. function pick(row, keys) {
  205. for (const key of keys) {
  206. if (row[key]) return String(row[key]).trim();
  207. }
  208. return '';
  209. }
  210. function readJson(file) {
  211. try {
  212. return JSON.parse(fs.readFileSync(file, 'utf8'));
  213. } catch {
  214. return null;
  215. }
  216. }
  217. function fileExists(file) {
  218. return Boolean(file && fs.existsSync(file));
  219. }
  220. function unique(values) {
  221. return [...new Set((values || []).filter(Boolean).map(String))];
  222. }
  223. function normalizeKey(value) {
  224. return String(value || '').trim().toLowerCase();
  225. }
  226. function csvCell(value) {
  227. const text = String(value ?? '');
  228. return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  229. }
  230. function escapeCell(value) {
  231. return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
  232. }
  233. function withBom(text) {
  234. return `\uFEFF${text}`;
  235. }
  236. function rel(root, file) {
  237. return path.relative(root, file).replace(/\\/g, '/');
  238. }
  239. function parseArgs(argv) {
  240. const args = {};
  241. for (let i = 0; i < argv.length; i += 1) {
  242. const raw = argv[i];
  243. if (!raw.startsWith('--')) continue;
  244. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  245. const next = argv[i + 1];
  246. if (!next || next.startsWith('--')) args[key] = true;
  247. else {
  248. args[key] = next;
  249. i += 1;
  250. }
  251. }
  252. return args;
  253. }
  254. if (require.main === module) {
  255. try {
  256. main();
  257. } catch (error) {
  258. console.error(error && error.stack ? error.stack : String(error));
  259. process.exit(1);
  260. }
  261. }
  262. module.exports = {
  263. buildFeedbackLoopClosureSummary,
  264. normalizeFeedbackRecord,
  265. extractResultRows
  266. };