smoke-package.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const http = require('http');
  4. const os = require('os');
  5. const path = require('path');
  6. const { runTihaoSourcing } = require('../mcp/src/tools/tihao-brief-sourcing-run');
  7. const { resolveCollectionMode, withLiveSmallRunDefaults } = require('../mcp/src/features/tihao-sourcing/sourcing-workflow');
  8. const { updatePreferenceMemory } = require('../mcp/src/tools/tihao-preference-update');
  9. const { parseArgs } = require('../mcp/src/tools/cli-args');
  10. const { parseBrief } = require('../mcp/src/features/tihao-sourcing/brief-parser');
  11. const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report');
  12. async function main() {
  13. const out = path.join(os.tmpdir(), `tihao-sourcing-smoke-${Date.now()}`);
  14. const briefText = [
  15. '客户名称:花漾肌研',
  16. '产品:敏感肌屏障修护精华',
  17. '关键词:敏感肌、修护、护肤、成分、测评、真实体验',
  18. '平台与人数:小红书 6 人,抖音 2 人',
  19. '粉丝量范围:2万-50万',
  20. '预算范围:3000-18000',
  21. '偏好真实体验、素人感、成分拆解、敏感肌修护,不要硬广。'
  22. ].join('\n');
  23. const parsed = parseBrief({ briefText });
  24. assert(parsed.brand.includes('花漾肌研'), 'brief parser should parse Chinese brand');
  25. assert(parsed.targetCount >= 8, 'brief parser should parse target count');
  26. const skillText = fs.readFileSync(path.join(__dirname, '..', 'skills', 'tihao', 'SKILL.md'), 'utf8');
  27. assert(skillText.includes('用 live 小规模检索出博主名单'), 'skill should route live small-run natural wording to live mode');
  28. assert(skillText.includes('Use `collectionMode=sample` only when the user explicitly asks'), 'skill should make sample explicit-only');
  29. assert(resolveCollectionMode({}) === 'live', 'unspecified collectionMode should default to live');
  30. assert(resolveCollectionMode({ collectionMode: 'sample' }) === 'sample', 'explicit sample collectionMode should remain sample');
  31. const liveDefaults = withLiveSmallRunDefaults({});
  32. assert(liveDefaults.keywordLimit === 1 && liveDefaults.pagesPerKeyword === 1, 'default live run should be small scale');
  33. const sample = await runTihaoSourcing({
  34. collectionMode: 'sample',
  35. briefText,
  36. output: path.join(out, 'sample')
  37. });
  38. assert(sample.status === 'ok', 'sample status should be ok');
  39. assert(sample.files.every(file => fs.existsSync(file)), 'sample files should exist');
  40. assert(sample.assistantMessage.includes('## 商务可用名单'), 'sample report should include business list');
  41. assert(sample.assistantMessage.includes('## 软件端交付表'), 'sample report should include software table');
  42. assert(sample.summary.clientReadyCount >= 6, 'sample should report client-ready count');
  43. assert(sample.summary.strong >= 1, 'sample should produce at least one strong recommendation');
  44. assert(Array.isArray(sample.data.calibrationQuestions) && sample.data.calibrationQuestions.length >= 2, 'sample should expose structured calibration questions');
  45. assert(Array.isArray(sample.nextActions) && sample.nextActions.some(item => item.includes('校准问题')), 'sample should expose calibration next action');
  46. assert(sample.data.criteria.homepageEvidenceStatus.status === 'not_requested', 'sample should expose homepage evidence fallback status');
  47. assertSoftwareCsv(path.join(out, 'sample', 'tihao-sourcing-client-list.csv'));
  48. const parsedCliArgs = parseArgs([
  49. '--message',
  50. '客户更喜欢素人感、敏感肌真实记录,不要硬广和泛美妆号',
  51. '--blocked-creators',
  52. '618母婴好物清单'
  53. ]);
  54. assert(typeof parsedCliArgs.message === 'string', 'CLI parser should keep natural-language message as text');
  55. assert(parsedCliArgs.blockedCreators === '618母婴好物清单', 'CLI parser should keep single blocked creator value');
  56. const memoryPath = path.join(out, 'memory.json');
  57. const pref = updatePreferenceMemory({
  58. message: '客户更喜欢素人感和真实体验,像“敏感肌成分研究所”这种账号,不要硬广,避开账号:618母婴好物清单',
  59. memory: memoryPath
  60. });
  61. assert(pref.status === 'ok', 'preference update should be ok');
  62. assert(fs.existsSync(memoryPath), 'memory file should exist');
  63. assert(pref.summary.preferredStyles.includes('素人感'), 'preference memory should extract preferred styles');
  64. assert(pref.summary.blockedStyles.includes('硬广'), 'preference memory should extract blocked styles');
  65. assert(pref.summary.preferredReferenceAccounts.includes('敏感肌成分研究所'), 'preference memory should store reference account preference');
  66. assert(pref.summary.blockedCreators.includes('618母婴好物清单'), 'preference memory should store blocked creator');
  67. const memorySample = await runTihaoSourcing({
  68. collectionMode: 'sample',
  69. briefText,
  70. memory: memoryPath,
  71. output: path.join(out, 'memory-sample')
  72. });
  73. assert(memorySample.status === 'ok', 'memory sample should be ok');
  74. assert(memorySample.summary.excluded >= 1, 'memory sample should exclude blocked creators');
  75. assert(!memorySample.data.candidates.some(item => item.displayName === '618母婴好物清单'), 'blocked creator should not enter client-ready candidates');
  76. const noToken = await runTihaoSourcing({
  77. collectionMode: 'live',
  78. briefText,
  79. output: path.join(out, 'live-no-token'),
  80. allowEnvToken: false
  81. });
  82. assert(noToken.status === 'needs_token', 'live without token should return needs_token');
  83. assert(Array.isArray(noToken.errors) && noToken.errors.length === 0, 'friendly token state should not return errors');
  84. assertFriendlyAuthState(noToken, 'live no-token result');
  85. const defaultLiveNoToken = await runTihaoSourcing({
  86. briefText,
  87. output: path.join(out, 'default-live-no-token'),
  88. allowEnvToken: false
  89. });
  90. assert(defaultLiveNoToken.status === 'needs_token', 'omitted collectionMode should enter live and request token instead of sample');
  91. assert(Array.isArray(defaultLiveNoToken.errors) && defaultLiveNoToken.errors.length === 0, 'default live token state should not return errors');
  92. assertFriendlyAuthState(defaultLiveNoToken, 'default live no-token result');
  93. const mock = await startMockHomepageProvider();
  94. try {
  95. const homepage = await runTihaoSourcing({
  96. collectionMode: 'sample',
  97. briefText,
  98. homepageEvidenceBaseUrl: mock.url,
  99. output: path.join(out, 'homepage-provider')
  100. });
  101. assert(homepage.status === 'ok', 'homepage provider sample should be ok');
  102. assert(homepage.data.criteria.homepageEvidenceStatus.status === 'ok', 'homepage provider status should be ok');
  103. assert(homepage.data.candidates.some(item => item.homepageEvidence?.source === 'provider'), 'homepage provider should attach provider evidence');
  104. } finally {
  105. await mock.close();
  106. }
  107. console.log(`tihao smoke ok: ${out}`);
  108. }
  109. function assertSoftwareCsv(csvPath) {
  110. const csv = fs.readFileSync(csvPath, 'utf8').replace(/^\uFEFF/, '');
  111. const lines = csv.trim().split(/\r?\n/);
  112. const expectedHeader = SOFTWARE_TABLE_HEADER.join(',');
  113. assert(lines[0] === expectedHeader, 'software csv should use fixed header');
  114. const header = parseCsvLine(lines[0]);
  115. const column = name => header.indexOf(name);
  116. ['目标城市', '预算规则', '命中价格类型', '命中价格', '账号ID', '粉丝数', '图文报价', '视频报价', '最低报价', '报价状态', '地区/定位', '蒲公英判断', '数据来源'].forEach(name => {
  117. assert(column(name) >= 0, `software csv should include ${name}`);
  118. });
  119. const quoteStatusIndex = column('报价状态');
  120. const imagePriceIndex = column('图文报价');
  121. const videoPriceIndex = column('视频报价');
  122. const minPriceIndex = column('最低报价');
  123. const seen = new Set();
  124. const rankByBrief = new Map();
  125. lines.slice(1).forEach((line, index) => {
  126. const row = parseCsvLine(line);
  127. const key = row[10] ? `${row[0]}|${row[7]}|${row[10]}` : `${row[0]}|${row[7]}|${row[8]}`;
  128. assert(!seen.has(key), 'software csv should have no duplicate creator keys');
  129. seen.add(key);
  130. const expectedRank = (rankByBrief.get(row[0]) || 0) + 1;
  131. rankByBrief.set(row[0], expectedRank);
  132. assert(Number(row[2]) === expectedRank, 'software csv ranks should be continuous within each brief');
  133. assert(row[quoteStatusIndex], 'software csv should explain quote status for every creator');
  134. });
  135. const body = lines.slice(1).map(parseCsvLine);
  136. assert(body.some(row => row[imagePriceIndex] || row[videoPriceIndex] || row[minPriceIndex]), 'first client list should include parsed quote fields when candidate data has prices');
  137. }
  138. function assertFriendlyAuthState(result, label) {
  139. const text = JSON.stringify(result);
  140. assert(!/"errors"\s*:\s*\[[^\]]+\]/.test(text), `${label} should not include raw errors`);
  141. assert(!/\b(401|403)\b/.test(text), `${label} should not expose raw 401/403`);
  142. assert(!/Authorization\s*:/i.test(text), `${label} should not expose Authorization header`);
  143. assert(!/Bearer\s+[A-Za-z0-9._~+/=-]+/i.test(text), `${label} should not expose bearer token`);
  144. assert(!/(sessionToken|X-Parse-Session-Token)["':=\s]+[A-Za-z0-9._~+/=-]+/i.test(text), `${label} should not expose sessionToken`);
  145. assert(!/sk-[A-Za-z0-9_-]{20,}/.test(text), `${label} should not expose model token`);
  146. }
  147. function parseCsvLine(line) {
  148. const cells = [];
  149. let current = '';
  150. let quoted = false;
  151. for (let i = 0; i < line.length; i += 1) {
  152. const char = line[i];
  153. if (char === '"' && quoted && line[i + 1] === '"') {
  154. current += '"';
  155. i += 1;
  156. } else if (char === '"') {
  157. quoted = !quoted;
  158. } else if (char === ',' && !quoted) {
  159. cells.push(current);
  160. current = '';
  161. } else {
  162. current += char;
  163. }
  164. }
  165. cells.push(current);
  166. return cells;
  167. }
  168. function startMockHomepageProvider() {
  169. const server = http.createServer((req, res) => {
  170. if (!req.url.startsWith('/homepage-evidence')) {
  171. res.writeHead(404, { 'content-type': 'application/json' });
  172. res.end(JSON.stringify({ error: 'not_found' }));
  173. return;
  174. }
  175. let body = '';
  176. req.on('data', chunk => { body += chunk; });
  177. req.on('end', () => {
  178. const payload = body ? JSON.parse(body) : {};
  179. const records = (payload.creators || []).slice(0, 2).map(creator => ({
  180. platform: creator.platform,
  181. platformUserId: creator.platformUserId,
  182. displayName: creator.displayName,
  183. profileUrl: creator.profileUrl,
  184. homepageEvidence: {
  185. source: 'provider',
  186. categoryConsistencyScore: 86,
  187. visualQualityScore: 82,
  188. toneConsistencyScore: 88,
  189. confidence: 'provider',
  190. reviewNotes: ['provider返回近10篇内容证据', '封面统一且近期内容垂类一致']
  191. }
  192. }));
  193. res.writeHead(200, { 'content-type': 'application/json' });
  194. res.end(JSON.stringify({ homepageEvidence: records }));
  195. });
  196. });
  197. return new Promise(resolve => {
  198. server.listen(0, '127.0.0.1', () => {
  199. const port = server.address().port;
  200. resolve({
  201. url: `http://127.0.0.1:${port}`,
  202. close: () => new Promise(done => server.close(done))
  203. });
  204. });
  205. });
  206. }
  207. function assert(condition, message) {
  208. if (!condition) throw new Error(message);
  209. }
  210. main().catch(error => {
  211. console.error(error && error.stack ? error.stack : String(error));
  212. process.exit(1);
  213. });