product-acceptance-audit.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { runTihaoSourcing } = require('../mcp/src/tools/tihao-brief-sourcing-run');
  6. const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report');
  7. async function main() {
  8. const root = path.resolve(__dirname, '..');
  9. const repoRoot = path.resolve(root, '..');
  10. const out = path.join(os.tmpdir(), `tihao-product-acceptance-${Date.now()}`);
  11. const dhaBrief = path.join(repoRoot, 'dha_brief.xlsx');
  12. const baselinePath = path.join(repoRoot, 'output', 'dha-tihao-poc', 'reference_baseline.json');
  13. const installGuide = path.join(root, 'docs', 'install-guide.md');
  14. const roadmap = path.join(root, 'docs', 'reference-evidence-roadmap.md');
  15. const runbook = path.join(root, 'docs', 'live-provider-integration-runbook.md');
  16. const readme = path.join(root, 'README.md');
  17. assert(fs.existsSync(installGuide), 'install guide should exist');
  18. assert(fs.existsSync(roadmap), 'reference roadmap should exist');
  19. assert(fs.existsSync(runbook), 'live provider runbook should exist');
  20. assert(fs.existsSync(readme), 'README should exist');
  21. assertNoPricingLeak(fs.readFileSync(installGuide, 'utf8'), 'install guide');
  22. const roadmapText = fs.readFileSync(roadmap, 'utf8');
  23. const runbookText = fs.readFileSync(runbook, 'utf8');
  24. const readmeText = fs.readFileSync(readme, 'utf8');
  25. assertNoSecretLeak(roadmapText, 'reference roadmap');
  26. assertNoSecretLeak(runbookText, 'live provider runbook');
  27. assertIncludes(roadmapText, 'docs/live-provider-integration-runbook.md', 'reference roadmap runbook link');
  28. assertIncludes(runbookText, '不能宣称', 'live provider wording guard');
  29. assertIncludes(runbookText, '没有跑通 `acceptance:providers`,不能说参考补证 provider 已接通', 'live provider reference guard');
  30. assertIncludes(readmeText, 'docs/live-provider-integration-runbook.md', 'README runbook link');
  31. scanPackageFiles(root);
  32. const input = {
  33. collectionMode: 'sample',
  34. brief: fs.existsSync(dhaBrief) ? dhaBrief : undefined,
  35. briefText: fs.existsSync(dhaBrief) ? undefined : [
  36. '平台:小红书',
  37. '产品:DHA',
  38. '干货科普参考账号:',
  39. '图文 http://xhslink.com/o/7KZdAn1bNgj',
  40. '视频 http://xhslink.com/o/2fg3MzG2Iau',
  41. '618合集参考账号:',
  42. '图文 http://xhslink.com/o/12gLsjgndoG',
  43. '视频 http://xhslink.com/o/1KYJBBeEhP4'
  44. ].join('\n'),
  45. referenceBaselinePath: fs.existsSync(baselinePath) ? baselinePath : undefined,
  46. evidenceCards: [
  47. {
  48. creatorId: 'xhs-dha-001',
  49. platform: 'xiaohongshu',
  50. displayName: '营养师妈妈DHA笔记',
  51. sourceLevel: 'multimodal_verified',
  52. contentEvidence: {
  53. textSignals: ['DHA', '母婴'],
  54. asrSignals: ['宝宝营养', '真实体验'],
  55. visualSignals: ['真人出镜', '产品实拍', '母婴家庭场景'],
  56. transcriptSnippets: ['这条主要讲宝宝 DHA 怎么选']
  57. },
  58. referenceFit: {
  59. score: 88,
  60. level: 'high',
  61. hitPoints: ['DHA', '母婴', '真实体验']
  62. },
  63. riskEvidence: {
  64. needsManualReview: ['宝宝营养功效表达需人工复核']
  65. }
  66. }
  67. ],
  68. output: out
  69. };
  70. const result = await runTihaoSourcing(input);
  71. assert(result.status === 'ok', 'product audit sample run should be ok');
  72. assert(result.files && result.files.length === 3, 'product audit should write three files');
  73. for (const file of result.files) assert(fs.existsSync(file), `output file should exist: ${file}`);
  74. const mdPath = path.join(out, 'tihao-sourcing-report.md');
  75. const jsonPath = path.join(out, 'tihao-sourcing-result.json');
  76. const csvPath = path.join(out, 'tihao-sourcing-client-list.csv');
  77. const markdown = fs.readFileSync(mdPath, 'utf8');
  78. const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
  79. const csv = fs.readFileSync(csvPath, 'utf8');
  80. assertIncludes(markdown, '# 提号博主推荐名单', 'markdown title');
  81. assertIncludes(markdown, '## Brief 解析摘要', 'brief summary section');
  82. assertIncludes(markdown, '## 需求三分层与隐性规则', 'requirement layers section');
  83. assertIncludes(markdown, '## 参考账号可参考性判断', 'reference usability section');
  84. assertIncludes(markdown, '## 参考视频风格指纹', 'reference fingerprint section');
  85. assertIncludes(markdown, '## 参考账号/风格锚点', 'reference anchor section');
  86. assertIncludes(markdown, '## 候选召回记录', 'recall record section');
  87. assertIncludes(markdown, '## 主页最近内容证据', 'homepage evidence section');
  88. assertIncludes(markdown, '## 多模态证据卡', 'multimodal evidence section');
  89. assertIncludes(markdown, '## 商务可用名单', 'business list section');
  90. assertIncludes(markdown, '## 剔除/降级原因', 'excluded or downgraded section');
  91. assertIncludes(markdown, '## 下一轮校准问题', 'calibration questions section');
  92. assertIncludes(markdown, '## 生成文件', 'generated files section');
  93. assertIncludes(markdown, '不是最终投放名单', 'first-round caveat');
  94. assertIncludes(markdown, '证据卡用于辅助复核', 'evidence-card caveat');
  95. assert(json.criteria.referenceLinks.length >= 4, 'criteria should keep reference links');
  96. assert(json.criteria.referenceStyleAnchors.length >= 4, 'criteria should keep reference style anchors');
  97. assert(Array.isArray(json.criteria.referenceStyleFingerprints) && json.criteria.referenceStyleFingerprints.length >= 4, 'criteria should keep reference style fingerprints');
  98. assert(json.criteria.referenceStyleFingerprints.some(item => item.contentType === 'video'), 'reference style fingerprints should include video links');
  99. assert(json.criteria.referenceStyleFingerprints.some(item => (item.missingEvidenceNotes || []).includes('待补口播证据')), 'video fingerprints should disclose missing ASR evidence');
  100. assert(json.criteria.referenceStyleFingerprints.some(item => (item.missingEvidenceNotes || []).includes('待补帧图证据')), 'video fingerprints should disclose missing frame evidence');
  101. assert(json.criteria.referenceEvidenceStatus && typeof json.criteria.referenceEvidenceStatus.providerStatus === 'string', 'criteria should keep reference provider status');
  102. assert(json.criteria.referenceFingerprintStatus && json.criteria.referenceFingerprintStatus.fingerprintCount >= 4, 'criteria should keep reference fingerprint status');
  103. assert(json.criteria.requirementLayers && Array.isArray(json.criteria.requirementLayers.hardConstraints), 'criteria should keep requirement layers');
  104. assert(Array.isArray(json.criteria.categoryRules) && json.criteria.categoryRules.length >= 1, 'criteria should keep category rules');
  105. assert(json.criteria.referenceUsability && typeof json.criteria.referenceUsability.status === 'string', 'criteria should keep reference usability');
  106. assert(Array.isArray(json.criteria.sourcingStrategy) && json.criteria.sourcingStrategy.length >= 1, 'criteria should keep platform sourcing strategy');
  107. assert(Array.isArray(json.criteria.evidenceCards) && json.criteria.evidenceCards.length >= 1, 'criteria should keep evidence cards');
  108. assert(json.criteria.evidenceStatus && typeof json.criteria.evidenceStatus.providerStatus === 'string', 'criteria should keep evidence provider status');
  109. assert(Array.isArray(json.calibrationQuestions) && json.calibrationQuestions.length >= 3, 'json should keep calibration questions');
  110. assert(Array.isArray(json.nextActions) && json.nextActions.length >= 3, 'json should keep next actions');
  111. assert(Array.isArray(result.nextActions) && result.nextActions.some(item => item.includes('校准问题')), 'tool result should expose calibration next action');
  112. assert(json.candidates.some(item => item.briefFitScore > 0), 'candidates should include briefFitScore');
  113. assert(json.candidates.some(item => item.referenceStyleFitScore > 0), 'candidates should include referenceStyleFitScore');
  114. assert(json.candidates.every(item => Array.isArray(item.briefHitConditions)), 'candidates should include brief hit conditions');
  115. assert(json.candidates.every(item => Array.isArray(item.referenceStyleHitPoints)), 'candidates should include reference style hit points');
  116. assert(json.candidates.every(item => Array.isArray(item.homepageEvidenceHitPoints)), 'candidates should include homepage evidence hit points');
  117. assert(json.candidates.every(item => Array.isArray(item.missingKeyConditions)), 'candidates should include missing key conditions');
  118. assert(json.candidates.every(item => Array.isArray(item.manualReviewFields)), 'candidates should include manual review fields');
  119. assert(json.candidates.every(item => item.homepageEvidence && Array.isArray(item.homepageEvidence.reviewNotes)), 'candidates should include homepage evidence');
  120. assert(json.candidates.every(item => typeof item.recentContentFitScore === 'number'), 'candidates should include recent content fit score');
  121. assert(json.candidates.every(item => item.implicitRuleFit && Array.isArray(item.implicitRuleFit.hitPoints)), 'candidates should include implicit rule fit');
  122. assert(json.candidates.filter(item => item.recommendStatus === '强推荐').every(item => (item.briefHitConditions || []).length >= 2 && ((item.referenceStyleHitPoints || []).length + (item.homepageEvidenceHitPoints || []).length) >= 1), 'strong candidates should hit brief and reference/homepage evidence gates');
  123. assert(json.candidates.some(item => Array.isArray(item.evidenceSignals) && item.evidenceSignals.includes('真人出镜')), 'candidates should include evidence signals');
  124. assert(json.candidates.some(item => Array.isArray(item.evidenceRiskHints) && item.evidenceRiskHints.includes('宝宝营养功效表达需人工复核')), 'candidates should include manual-review hints');
  125. const csvHeader = csv.split(/\r?\n/)[0].replace(/^\uFEFF/, '');
  126. const softwareHeader = SOFTWARE_TABLE_HEADER.join(',');
  127. assert(csvHeader === softwareHeader, 'csv should use fixed software table header');
  128. const header = csvHeader.split(',');
  129. const column = name => header.indexOf(name);
  130. const csvRows = csv.trim().split(/\r?\n/).slice(1).filter(Boolean).map(line => parseCsvLine(line));
  131. const seenKeys = new Set();
  132. for (const [index, row] of csvRows.entries()) {
  133. const key = row[column('主页链接')]
  134. ? `${row[column('brief编号')]}|${row[column('平台')]}|${row[column('主页链接')]}`
  135. : `${row[column('brief编号')]}|${row[column('平台')]}|${row[column('账号名称')]}`;
  136. assert(!seenKeys.has(key), 'software table should not include duplicate creator keys');
  137. seenKeys.add(key);
  138. assert(Number(row[column('序号')]) === index + 1, 'software table ranks should be continuous after dedupe');
  139. }
  140. assertNoSecretLeak(markdown, 'markdown report');
  141. assertNoSecretLeak(JSON.stringify(json), 'json result');
  142. assertNoSecretLeak(csv, 'csv list');
  143. console.log(`product acceptance audit ok: ${out}`);
  144. }
  145. function parseCsvLine(line) {
  146. const cells = [];
  147. let cell = '';
  148. let quoted = false;
  149. for (let index = 0; index < line.length; index += 1) {
  150. const char = line[index];
  151. if (char === "\"" && quoted && line[index + 1] === "\"") { cell += "\""; index += 1; continue; }
  152. if (char === "\"") { quoted = !quoted; continue; }
  153. if (char === ',' && !quoted) { cells.push(cell); cell = ''; continue; }
  154. cell += char;
  155. }
  156. cells.push(cell);
  157. return cells;
  158. }
  159. function assertNoPricingLeak(text, label) {
  160. const forbidden = [/priceStep/i, /"price"\s*:/i, /(price|cost|定价|价格)[^\n]{0,40}\b0\.1\b/i, /\b1000\s*[,,]\s*100\b/];
  161. for (const pattern of forbidden) {
  162. assert(!pattern.test(text), `${label} should not expose provisional pricing: ${pattern}`);
  163. }
  164. }
  165. function assertNoSecretLeak(text, label) {
  166. const forbidden = [
  167. /ark-[A-Za-z0-9-]{20,}/i,
  168. /sk-[A-Za-z0-9_-]{20,}/i,
  169. /SecretKey\s*\r?\n\s*[A-Za-z0-9_-]{16,}/i,
  170. /XFYUN_SECRET_KEY\s*=\s*['"]?(?!<)[^\s'"]+/i,
  171. /DOUBAO_API_KEY\s*=\s*['"]?(?!<)[^\s'"]+/i,
  172. /TIHAO_SESSION_TOKEN\s*=\s*['"]?(?!<)[^\s'"]+/,
  173. /VOC_ECOMMERCE_TOKEN\s*=\s*['"]?(?!<)[^\s'"]+/,
  174. /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/i,
  175. /sessionToken['"]?\s*[:=]\s*['"]?[A-Za-z0-9._-]{12,}/i
  176. ];
  177. for (const pattern of forbidden) {
  178. assert(!pattern.test(text), `${label} should not leak token-like content: ${pattern}`);
  179. }
  180. }
  181. // 字段 schema 是「未来建库」的结构化元数据(字段定义/DDL/查询映射),不含实际报价数值,
  182. // 不应被「临时定价泄露」扫描误伤(上方 assertNoSecretLeak 仍会覆盖它们做 token 泄露检查)。
  183. const PRICING_SCAN_SKIP = [
  184. 'docs/pgy-field-schema.json',
  185. 'docs/media-library-field-schema.json'
  186. ];
  187. function scanPackageFiles(root) {
  188. const files = [];
  189. walk(root, files);
  190. for (const file of files) {
  191. const rel = path.relative(root, file).replace(/\\/g, '/');
  192. if (shouldSkipScan(rel)) continue;
  193. const text = fs.readFileSync(file, 'utf8');
  194. assertNoSecretLeak(text, rel);
  195. if ((rel.startsWith('docs/') || rel.endsWith('.md')) && !PRICING_SCAN_SKIP.includes(rel)) assertNoPricingLeak(text, rel);
  196. }
  197. }
  198. function walk(dir, files) {
  199. for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
  200. const full = path.join(dir, entry.name);
  201. if (entry.isDirectory()) {
  202. if (['node_modules', '.claude', 'outputs'].includes(entry.name) || entry.name.startsWith('.npm-cache')) continue;
  203. walk(full, files);
  204. } else if (entry.isFile()) {
  205. files.push(full);
  206. }
  207. }
  208. }
  209. function shouldSkipScan(rel) {
  210. return rel === 'package-lock.json' || rel.endsWith('.tgz') || rel.startsWith('.git/');
  211. }
  212. function assertIncludes(text, expected, label) {
  213. assert(String(text).includes(expected), `${label} should include ${expected}`);
  214. }
  215. function assert(condition, message) {
  216. if (!condition) throw new Error(message);
  217. }
  218. main().catch(error => {
  219. console.error(error && error.stack ? error.stack : String(error));
  220. process.exit(1);
  221. });