smoke-package.js 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017
  1. #!/usr/bin/env node
  2. const path = require('path');
  3. const os = require('os');
  4. const fs = require('fs');
  5. const { runXiaohongshuTrend } = require('../mcp/src/tools/xiaohongshu-trend-run');
  6. const { updateXiaohongshuPreference } = require('../mcp/src/tools/xiaohongshu-preference-update');
  7. const { normalizeNote } = require('../mcp/src/features/xiaohongshu-trend/live-collector');
  8. const { buildTrendReport } = require('../mcp/src/features/xiaohongshu-trend/report');
  9. const { XiaohongshuApi } = require('../mcp/src/providers/xiaohongshu-api');
  10. const { runDouyinTrend } = require('../mcp/src/tools/douyin-trend-run');
  11. const { updateDouyinPreference } = require('../mcp/src/tools/douyin-preference-update');
  12. const { runVocIssuePool } = require('../mcp/src/tools/voc-issue-pool-run');
  13. const { runVocProblemDeepDive } = require('../mcp/src/tools/voc-problem-deep-dive-run');
  14. const { runVocContentPlan } = require('../mcp/src/tools/voc-content-plan-run');
  15. const { runVocSpeakingScript } = require('../mcp/src/tools/voc-speaking-script-run');
  16. const { runVocCompetitorMap } = require('../mcp/src/tools/voc-competitor-map-run');
  17. const { runBusinessWorkflow } = require('../mcp/src/tools/voc-business-workflow-run');
  18. const {
  19. normalizeVideo,
  20. normalizeSearchKeyword,
  21. keywordValidationIssue,
  22. buildKeywordRepairCandidates
  23. } = require('../mcp/src/features/douyin-trend/live-collector');
  24. const { DouyinApi } = require('../mcp/src/providers/douyin-api');
  25. const { readVocToken } = require('../mcp/src/core/credentials');
  26. const { smokeCrossIndustry } = require('./smoke-cross-industry');
  27. const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
  28. const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
  29. const { version } = require('../package.json');
  30. const ROOT = path.resolve(__dirname, '..');
  31. const OUTPUT_ROOT = path.resolve(ROOT, 'outputs', 'claude-code-package-smoke');
  32. const MEMORY_PATH = path.join(OUTPUT_ROOT, 'xiaohongshu-trend-memory.json');
  33. const DOUYIN_MEMORY_PATH = path.join(OUTPUT_ROOT, 'douyin-trend-memory.json');
  34. async function smokeMcpTools() {
  35. const client = new Client({
  36. name: 'voc-intelligence-package-smoke',
  37. version
  38. });
  39. const transport = new StdioClientTransport({
  40. command: process.execPath,
  41. args: ['mcp/src/server.js'],
  42. cwd: ROOT,
  43. stderr: 'pipe'
  44. });
  45. await client.connect(transport);
  46. try {
  47. const tools = await client.listTools();
  48. const toolNames = tools.tools.map(tool => tool.name);
  49. if (!toolNames.includes('fmode_image_analysis')) {
  50. throw new Error(`Expected tool fmode_image_analysis, got: ${toolNames.join(', ')}`);
  51. }
  52. const imageTool = tools.tools.find(tool => tool.name === 'fmode_image_analysis');
  53. const imageSchema = JSON.stringify(imageTool?.inputSchema || {});
  54. if (!imageTool || !imageSchema.includes('imagePath') || !imageSchema.includes('fmodeToken')) {
  55. throw new Error('Expected fmode_image_analysis to accept imagePath and fmodeToken inputs');
  56. }
  57. const xhsTrendTool = tools.tools.find(tool => tool.name === 'voc_xiaohongshu_trend_run');
  58. const xhsTrendSchema = JSON.stringify(xhsTrendTool?.inputSchema || {});
  59. if (!xhsTrendTool || !xhsTrendSchema.includes('xiaohongshuToken') || !xhsTrendSchema.includes('vocToken')) {
  60. throw new Error('Expected voc_xiaohongshu_trend_run to accept simple request token inputs xiaohongshuToken and vocToken');
  61. }
  62. const douyinTrendTool = tools.tools.find(tool => tool.name === 'voc_douyin_trend_run');
  63. const douyinTrendSchema = JSON.stringify(douyinTrendTool?.inputSchema || {});
  64. if (!douyinTrendTool || !douyinTrendSchema.includes('douyinToken') || !douyinTrendSchema.includes('vocToken')) {
  65. throw new Error('Expected voc_douyin_trend_run to accept simple request token inputs douyinToken and vocToken');
  66. }
  67. const deepDiveTool = tools.tools.find(tool => tool.name === 'voc_problem_deep_dive_run');
  68. const deepDiveSchema = JSON.stringify(deepDiveTool?.inputSchema || {});
  69. if (!deepDiveTool || !deepDiveSchema.includes('issue') || !deepDiveSchema.includes('evidence')) {
  70. throw new Error('Expected voc_problem_deep_dive_run to accept issue and evidence inputs');
  71. }
  72. const issuePoolTool = tools.tools.find(tool => tool.name === 'voc_issue_pool_run');
  73. const issuePoolSchema = JSON.stringify(issuePoolTool?.inputSchema || {});
  74. if (!issuePoolTool || !issuePoolSchema.includes('evidence') || !issuePoolSchema.includes('resolvedIssues')) {
  75. throw new Error('Expected voc_issue_pool_run to accept evidence and status inputs');
  76. }
  77. return toolNames;
  78. } finally {
  79. await client.close();
  80. }
  81. }
  82. async function smokeNoTokenRechargePrompt() {
  83. const originalCwd = process.cwd();
  84. const originalEnv = {
  85. USERPROFILE: process.env.USERPROFILE,
  86. HOME: process.env.HOME,
  87. VOC_TOKEN: process.env.VOC_TOKEN,
  88. VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
  89. XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
  90. TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
  91. FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
  92. FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
  93. DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
  94. DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
  95. VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
  96. };
  97. const tempHome = path.join(os.tmpdir(), 'claude-voc-no-token-smoke');
  98. const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-no-token-workspace-smoke');
  99. fs.mkdirSync(tempHome, { recursive: true });
  100. fs.mkdirSync(tempWorkspace, { recursive: true });
  101. try {
  102. process.chdir(tempWorkspace);
  103. process.env.USERPROFILE = tempHome;
  104. process.env.HOME = tempHome;
  105. [
  106. 'VOC_TOKEN',
  107. 'VOC_SOCIAL_TOKEN',
  108. 'XIAOHONGSHU_API_TOKEN',
  109. 'TIKHUB_API_TOKEN',
  110. 'FMODE_XIAOHONGSHU_TOKEN',
  111. 'FMODE_SOCIAL_TOKEN',
  112. 'DOUYIN_API_TOKEN',
  113. 'DOUYIN_TOKEN',
  114. 'VOC_DOUYIN_TOKEN'
  115. ].forEach(key => delete process.env[key]);
  116. const result = await runXiaohongshuTrend({
  117. collectionMode: 'live',
  118. keywords: '全屋定制',
  119. output: path.join(OUTPUT_ROOT, 'no-token')
  120. });
  121. const body = String(result.assistantMessage || '');
  122. const hasPaymentUrl = /apig-pay/.test(body) && /Vo3ROWEvDy/.test(body);
  123. const hasEarlyPaymentUrl = /apig-pay/.test(body.slice(0, 180));
  124. const hasNextActionUrl = (result.nextActions || []).some(action => /apig-pay/.test(String(action)));
  125. if (result.status !== 'needs_token' || !hasPaymentUrl || !hasEarlyPaymentUrl || !hasNextActionUrl || (result.errors || []).length) {
  126. throw new Error('no-token live path did not return a clean recharge prompt');
  127. }
  128. } finally {
  129. Object.entries(originalEnv).forEach(([key, value]) => {
  130. if (value === undefined) {
  131. delete process.env[key];
  132. } else {
  133. process.env[key] = value;
  134. }
  135. });
  136. process.chdir(originalCwd);
  137. }
  138. }
  139. async function smokeDouyinNoTokenRechargePrompt() {
  140. const originalCwd = process.cwd();
  141. const originalEnv = {
  142. USERPROFILE: process.env.USERPROFILE,
  143. HOME: process.env.HOME,
  144. VOC_TOKEN: process.env.VOC_TOKEN,
  145. VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
  146. XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
  147. TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
  148. FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
  149. FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
  150. DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
  151. DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
  152. VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
  153. };
  154. const tempHome = path.join(os.tmpdir(), 'claude-voc-douyin-no-token-smoke');
  155. const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-douyin-no-token-workspace-smoke');
  156. fs.mkdirSync(tempHome, { recursive: true });
  157. fs.mkdirSync(tempWorkspace, { recursive: true });
  158. try {
  159. process.chdir(tempWorkspace);
  160. process.env.USERPROFILE = tempHome;
  161. process.env.HOME = tempHome;
  162. [
  163. 'VOC_TOKEN',
  164. 'VOC_SOCIAL_TOKEN',
  165. 'XIAOHONGSHU_API_TOKEN',
  166. 'TIKHUB_API_TOKEN',
  167. 'FMODE_XIAOHONGSHU_TOKEN',
  168. 'FMODE_SOCIAL_TOKEN',
  169. 'DOUYIN_API_TOKEN',
  170. 'DOUYIN_TOKEN',
  171. 'VOC_DOUYIN_TOKEN'
  172. ].forEach(key => delete process.env[key]);
  173. const result = await runDouyinTrend({
  174. collectionMode: 'live',
  175. keywords: '全屋定制',
  176. output: path.join(OUTPUT_ROOT, 'douyin-no-token')
  177. });
  178. const body = String(result.assistantMessage || '');
  179. const hasPaymentUrl = /apig-pay/.test(body) && /Vo3ROWEvDy/.test(body);
  180. const hasEarlyPaymentUrl = /apig-pay/.test(body.slice(0, 180));
  181. const hasNextActionUrl = (result.nextActions || []).some(action => /apig-pay/.test(String(action)));
  182. if (result.status !== 'needs_token' || !hasPaymentUrl || !hasEarlyPaymentUrl || !hasNextActionUrl || (result.errors || []).length) {
  183. throw new Error('douyin no-token live path did not return a clean recharge prompt');
  184. }
  185. } finally {
  186. Object.entries(originalEnv).forEach(([key, value]) => {
  187. if (value === undefined) {
  188. delete process.env[key];
  189. } else {
  190. process.env[key] = value;
  191. }
  192. });
  193. process.chdir(originalCwd);
  194. }
  195. }
  196. function smokeWorkspaceEnvLocalToken() {
  197. const originalCwd = process.cwd();
  198. const originalEnv = {
  199. USERPROFILE: process.env.USERPROFILE,
  200. HOME: process.env.HOME,
  201. VOC_TOKEN: process.env.VOC_TOKEN,
  202. VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
  203. XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
  204. TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
  205. FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
  206. FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
  207. DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
  208. DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
  209. VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
  210. };
  211. const tempHome = path.join(os.tmpdir(), 'claude-voc-env-local-home-smoke');
  212. const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-env-local-workspace-smoke');
  213. fs.mkdirSync(tempHome, { recursive: true });
  214. fs.mkdirSync(tempWorkspace, { recursive: true });
  215. try {
  216. process.chdir(tempWorkspace);
  217. process.env.USERPROFILE = tempHome;
  218. process.env.HOME = tempHome;
  219. [
  220. 'VOC_TOKEN',
  221. 'VOC_SOCIAL_TOKEN',
  222. 'XIAOHONGSHU_API_TOKEN',
  223. 'TIKHUB_API_TOKEN',
  224. 'FMODE_XIAOHONGSHU_TOKEN',
  225. 'FMODE_SOCIAL_TOKEN',
  226. 'DOUYIN_API_TOKEN',
  227. 'DOUYIN_TOKEN',
  228. 'VOC_DOUYIN_TOKEN'
  229. ].forEach(key => delete process.env[key]);
  230. fs.writeFileSync(path.join(tempWorkspace, '.env.local'), 'VOC_TOKEN=workspace-smoke-token\n');
  231. const token = readVocToken({});
  232. if (token !== 'workspace-smoke-token') {
  233. throw new Error('workspace .env.local token was not used');
  234. }
  235. } finally {
  236. Object.entries(originalEnv).forEach(([key, value]) => {
  237. if (value === undefined) {
  238. delete process.env[key];
  239. } else {
  240. process.env[key] = value;
  241. }
  242. });
  243. process.chdir(originalCwd);
  244. }
  245. }
  246. function smokeAncestorEnvLocalToken() {
  247. const originalCwd = process.cwd();
  248. const originalEnv = {
  249. USERPROFILE: process.env.USERPROFILE,
  250. HOME: process.env.HOME,
  251. VOC_TOKEN: process.env.VOC_TOKEN,
  252. VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
  253. XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
  254. TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
  255. FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
  256. FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
  257. DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
  258. DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
  259. VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
  260. };
  261. const tempHome = path.join(os.tmpdir(), 'claude-voc-ancestor-env-home-smoke');
  262. const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-ancestor-env-workspace-smoke');
  263. const nested = path.join(tempWorkspace, 'nested', 'plugin');
  264. fs.mkdirSync(tempHome, { recursive: true });
  265. fs.mkdirSync(nested, { recursive: true });
  266. try {
  267. process.chdir(nested);
  268. process.env.USERPROFILE = tempHome;
  269. process.env.HOME = tempHome;
  270. [
  271. 'VOC_TOKEN',
  272. 'VOC_SOCIAL_TOKEN',
  273. 'XIAOHONGSHU_API_TOKEN',
  274. 'TIKHUB_API_TOKEN',
  275. 'FMODE_XIAOHONGSHU_TOKEN',
  276. 'FMODE_SOCIAL_TOKEN',
  277. 'DOUYIN_API_TOKEN',
  278. 'DOUYIN_TOKEN',
  279. 'VOC_DOUYIN_TOKEN'
  280. ].forEach(key => delete process.env[key]);
  281. fs.writeFileSync(path.join(tempWorkspace, '.env.local'), 'VOC_DOUYIN_TOKEN=ancestor-smoke-token\n');
  282. const token = readVocToken({});
  283. if (token !== 'ancestor-smoke-token') {
  284. throw new Error('ancestor .env.local token was not used');
  285. }
  286. } finally {
  287. Object.entries(originalEnv).forEach(([key, value]) => {
  288. if (value === undefined) {
  289. delete process.env[key];
  290. } else {
  291. process.env[key] = value;
  292. }
  293. });
  294. process.chdir(originalCwd);
  295. }
  296. }
  297. async function smokeIssuePoolReportEvidenceFiltering() {
  298. const reportPath = path.join(OUTPUT_ROOT, 'issue-pool-report-filter-smoke.md');
  299. const memoryPath = path.join(OUTPUT_ROOT, 'issue-pool-report-filter-memory.json');
  300. fs.writeFileSync(reportPath, [
  301. '# 示例品牌竞品图谱',
  302. '',
  303. '## 品牌背景',
  304. '- 品牌:示例品牌A',
  305. '- 城市:本地',
  306. '',
  307. '## 当前经营痛点',
  308. '1. 日常营销能够带来线索,但是活动折扣大。',
  309. '',
  310. '## live 采集真实样本',
  311. '1. [价格样本](https://example.test/note)',
  312. ' - 关键评论:多少钱啊',
  313. ' - 可解读点:用户第一反应不是质量,而是价格。',
  314. '2. [选择样本](https://example.test/note2)',
  315. ' - 高赞评论:第一次买不知道怎么选,怕踩雷',
  316. '',
  317. '## 下一步建议',
  318. '- 先选一个问题做深度拆解。'
  319. ].join('\n'));
  320. const result = await runVocIssuePool({
  321. project: '示例品牌A',
  322. industry: '家居定制',
  323. scenario: '线上获客',
  324. audience: '意向用户',
  325. report: reportPath,
  326. memory: memoryPath
  327. });
  328. const issues = result.data?.issues || [];
  329. const evidence = issues.flatMap(issue => issue.evidence || []);
  330. if (!issues.some(issue => issue.id === 'price') || !issues.some(issue => issue.id === 'decision')) {
  331. throw new Error('issue pool did not classify filtered report comments');
  332. }
  333. if (issues.some(issue => issue.id === 'other' && issue.count > 0)) {
  334. throw new Error('issue pool classified generated report prose as other evidence');
  335. }
  336. if (evidence.some(item => /^#|品牌:|城市:|当前经营痛点|下一步建议/.test(String(item)))) {
  337. throw new Error(`issue pool leaked report headings/prose into evidence: ${JSON.stringify(evidence)}`);
  338. }
  339. }
  340. function smokeAccessibleUrlNormalization() {
  341. const note = normalizeNote({
  342. id: '69f72f860000000035038a78',
  343. title: '自制多功能收纳抽屉课题',
  344. desc: '把柜内空间重新分区,适合小户型收纳。',
  345. user: { nickname: '小熊居家' },
  346. liked_count: '16.3w',
  347. collected_count: '15.9w',
  348. comments_count: '1.8k',
  349. mini_program_info: {
  350. webpage_url: 'https://www.xiaohongshu.com/discovery/item/69f72f860000000035038a78?xsec_source=app_share&xsec_token=sample-xsec-token'
  351. },
  352. share_info: {
  353. link: 'https://www.xiaohongshu.com/discovery/item/69f72f860000000035038a78?xsec_source=app_share&xsec_token=sample-share-token'
  354. },
  355. image_list: [
  356. { url: 'https://img.example.com/xhs-cover.jpg' }
  357. ]
  358. }, '全屋定制');
  359. if (!note.sourceUrlVerified || !/xsec_token=sample-xsec-token/.test(note.sourceUrl || '')) {
  360. throw new Error(`expected xsec_token sourceUrl, got ${note.sourceUrl}`);
  361. }
  362. if (/\/explore\//.test(note.sourceUrl || '') || /\/explore\//.test(note.url || '')) {
  363. throw new Error('normalizeNote should not use /explore/ as the primary source URL');
  364. }
  365. if (note.coverUrl !== 'https://img.example.com/xhs-cover.jpg') {
  366. throw new Error(`expected coverUrl from image_list, got ${note.coverUrl}`);
  367. }
  368. const unverified = normalizeNote({
  369. id: '69f72f860000000035038a79',
  370. title: '没有 xsec 的候选链接不应直接展示',
  371. desc: '避免把可能 404 的候选链接当原帖。',
  372. user: { nickname: '链接测试' },
  373. webpage_url: 'https://www.xiaohongshu.com/discovery/item/69f72f860000000035038a79',
  374. display_image_info: {
  375. url_list: ['https://img.example.com/xhs-display-cover.webp']
  376. }
  377. }, '全屋定制');
  378. if (unverified.sourceUrl || unverified.url || unverified.sourceUrlVerified) {
  379. throw new Error(`unverified xiaohongshu URL should not become clickable sourceUrl: ${unverified.sourceUrl}`);
  380. }
  381. if (!/xiaohongshu\.com\/discovery\/item/.test(unverified.candidateUrl || '')) {
  382. throw new Error(`expected candidateUrl to preserve raw fallback for debugging, got ${unverified.candidateUrl}`);
  383. }
  384. if (unverified.coverUrl !== 'https://img.example.com/xhs-display-cover.webp') {
  385. throw new Error(`expected coverUrl from display_image_info, got ${unverified.coverUrl}`);
  386. }
  387. const report = buildTrendReport({
  388. profile: {
  389. industry: '测试行业',
  390. businessType: '测试业务',
  391. targetAudience: ['测试人群'],
  392. keywords: ['全屋定制']
  393. },
  394. dataset: { notes: [unverified], comments: [] },
  395. collectionMode: 'live',
  396. memory: {}
  397. });
  398. const body = String(report.assistantMessage || '');
  399. if (body.includes('[打开原帖') || body.includes('链接待核验')) {
  400. throw new Error('unverified Xiaohongshu links should not be rendered as clickable evidence links');
  401. }
  402. if (!body.includes('平台未返回可核验原帖链接')) {
  403. throw new Error('unverified Xiaohongshu links should explain why no clickable original link is shown');
  404. }
  405. }
  406. function smokeDouyinUrlNormalization() {
  407. const video = normalizeVideo({
  408. aweme_id: '7448118827402972455',
  409. desc: '全屋定制视频开头怎么讲更容易留人?',
  410. author: { nickname: '抖音家装观察员', uid: 'u001', sec_uid: 'sec001' },
  411. statistics: {
  412. digg_count: '1.2w',
  413. comment_count: '860',
  414. share_count: '520',
  415. play_count: '23.5w'
  416. },
  417. video: {
  418. cover: { url_list: ['https://img.example.com/dy-cover.jpg'] }
  419. },
  420. share_info: {
  421. link: 'https://www.douyin.com/video/7448118827402972455?from=sample'
  422. }
  423. }, '全屋定制');
  424. if (!video.sourceUrlVerified || !/douyin\.com\/video\//.test(video.sourceUrl || '')) {
  425. throw new Error(`expected douyin sourceUrl, got ${video.sourceUrl}`);
  426. }
  427. if (video.coverUrl !== 'https://img.example.com/dy-cover.jpg') {
  428. throw new Error(`expected coverUrl from video.cover, got ${video.coverUrl}`);
  429. }
  430. }
  431. function smokeEvidenceCardReport(result) {
  432. const body = String(result.assistantMessage || '');
  433. const required = ['## 证据样本', '原帖', '原文摘录', '高赞评论'];
  434. const missing = required.filter(item => !body.includes(item));
  435. if (missing.length) {
  436. throw new Error(`sample report missing evidence card fields: ${missing.join(', ')}`);
  437. }
  438. if (/xiaohongshu\.com\/explore\//.test(body)) {
  439. throw new Error('sample report should not contain /explore/ Xiaohongshu links');
  440. }
  441. if (!body.includes('## 证据质量门槛') || !result.summary?.evidenceQuality?.grade) {
  442. throw new Error('sample report did not include evidence quality gate');
  443. }
  444. const assetFiles = (result.files || []).filter(file => /[\\/]assets[\\/]/.test(file));
  445. if (!assetFiles.length) {
  446. throw new Error('sample report did not return cached asset files');
  447. }
  448. const missingAsset = assetFiles.find(file => !fs.existsSync(file));
  449. if (missingAsset) {
  450. throw new Error(`cached asset file missing: ${missingAsset}`);
  451. }
  452. }
  453. function smokeDouyinEvidenceCardReport(result) {
  454. const body = String(result.assistantMessage || '');
  455. const required = ['## 证据样本', '原视频', '视频文案', '高赞评论'];
  456. const missing = required.filter(item => !body.includes(item));
  457. if (missing.length) {
  458. throw new Error(`douyin sample report missing evidence card fields: ${missing.join(', ')}`);
  459. }
  460. if (!/douyin\.com\/video\//.test(body)) {
  461. throw new Error('douyin sample report should contain a Douyin video link');
  462. }
  463. if (!body.includes('## 证据质量门槛') || !result.summary?.evidenceQuality?.grade) {
  464. throw new Error('douyin sample report did not include evidence quality gate');
  465. }
  466. const assetFiles = (result.files || []).filter(file => /[\\/]assets[\\/]/.test(file));
  467. if (!assetFiles.length) {
  468. throw new Error('douyin sample report did not return cached asset files');
  469. }
  470. const missingAsset = assetFiles.find(file => !fs.existsSync(file));
  471. if (missingAsset) {
  472. throw new Error(`cached douyin asset file missing: ${missingAsset}`);
  473. }
  474. }
  475. function sectionBetween(text, startTitle, endTitle) {
  476. const body = String(text || '');
  477. const start = body.indexOf(startTitle);
  478. if (start < 0) return '';
  479. const end = body.indexOf(endTitle, start + startTitle.length);
  480. return end >= 0 ? body.slice(start, end) : body.slice(start);
  481. }
  482. function smokeDeepDiveMemoryBehavior(result, blockedTerm) {
  483. const body = String(result.assistantMessage || '');
  484. const actionSection = sectionBetween(
  485. body,
  486. '### 今天就能做',
  487. '## \u53ef\u8f6c\u5316\u5185\u5bb9\u9009\u9898'
  488. );
  489. const memorySection = sectionBetween(
  490. body,
  491. '## \u5df2\u5e94\u7528\u7684\u4f7f\u7528\u8bb0\u5fc6',
  492. '## \u4ecd\u9700\u786e\u8ba4\u7684\u95ee\u9898'
  493. );
  494. if (!actionSection) {
  495. throw new Error('VOC problem deep dive action section was not found');
  496. }
  497. if (actionSection.includes(blockedTerm)) {
  498. throw new Error('VOC problem deep dive leaked a blocked action into executable actions');
  499. }
  500. if (!memorySection.includes(blockedTerm)) {
  501. throw new Error('VOC problem deep dive did not show blocked action in applied memory');
  502. }
  503. }
  504. async function smokeBusinessStatusErrors() {
  505. const originalFetch = global.fetch;
  506. const makeBusinessErrorFetch = () => async () => ({
  507. ok: true,
  508. status: 200,
  509. text: async () => JSON.stringify({
  510. code: 403,
  511. message: 'insufficient balance'
  512. })
  513. });
  514. try {
  515. global.fetch = makeBusinessErrorFetch();
  516. await new XiaohongshuApi({
  517. token: 'r:smoke-token',
  518. baseUrl: 'https://example.test/xiaohongshu/app'
  519. }).searchNotes({ keyword: 'smoke' });
  520. throw new Error('xiaohongshu business status 403 did not throw');
  521. } catch (error) {
  522. if (error.message === 'xiaohongshu business status 403 did not throw') {
  523. throw error;
  524. }
  525. if (Number(error.httpStatus) !== 403) {
  526. throw new Error(`expected xiaohongshu business httpStatus 403, got ${error.httpStatus}`);
  527. }
  528. } finally {
  529. global.fetch = originalFetch;
  530. }
  531. try {
  532. global.fetch = makeBusinessErrorFetch();
  533. await new DouyinApi({
  534. token: 'r:smoke-token',
  535. baseUrl: 'https://example.test/douyin'
  536. }).searchVideos({ keyword: 'smoke' });
  537. throw new Error('douyin business status 403 did not throw');
  538. } catch (error) {
  539. if (error.message === 'douyin business status 403 did not throw') {
  540. throw error;
  541. }
  542. if (Number(error.httpStatus) !== 403) {
  543. throw new Error(`expected douyin business httpStatus 403, got ${error.httpStatus}`);
  544. }
  545. } finally {
  546. global.fetch = originalFetch;
  547. }
  548. }
  549. async function smokeDouyinSearchInputErrors() {
  550. const normalized = normalizeSearchKeyword('家居定制18元22元');
  551. if (normalized !== '家居定制 18元 22元') {
  552. throw new Error(`expected douyin keyword spacing normalization, got ${normalized}`);
  553. }
  554. const repairCandidates = buildKeywordRepairCandidates('家居定制 18元 22元', {
  555. industry: '家居定制',
  556. targetAudience: ['新客']
  557. });
  558. if (!repairCandidates.includes('家居定制 性价比 新客')) {
  559. throw new Error(`expected douyin keyword repair candidates, got ${repairCandidates.join(', ')}`);
  560. }
  561. if (!keywordValidationIssue('家居定制'.repeat(40))) {
  562. throw new Error('expected overlong douyin keyword to be rejected before search');
  563. }
  564. const overlong = await runDouyinTrend({
  565. collectionMode: 'live',
  566. vocToken: 'r:smoke-token',
  567. keywords: '家居定制'.repeat(40),
  568. output: path.join(OUTPUT_ROOT, 'douyin-overlong-keyword')
  569. });
  570. if (overlong.status !== 'needs_keyword_fix') {
  571. throw new Error(`expected overlong keyword to return needs_keyword_fix, got ${overlong.status}`);
  572. }
  573. const originalFetch = global.fetch;
  574. try {
  575. global.fetch = async () => ({
  576. ok: false,
  577. status: 500,
  578. text: async () => JSON.stringify({
  579. code: 500,
  580. message: '社交平台接口请求错误'
  581. })
  582. });
  583. const result = await runDouyinTrend({
  584. collectionMode: 'live',
  585. vocToken: 'r:smoke-token',
  586. keywords: '家居定制 18元 22元',
  587. keywordLimit: 1,
  588. videosPerKeyword: 1,
  589. maxCommentPages: 0,
  590. output: path.join(OUTPUT_ROOT, 'douyin-search-input-error')
  591. });
  592. if (result.status !== 'upstream_unstable') {
  593. throw new Error(`expected persistent upstream search error to return upstream_unstable, got ${result.status}`);
  594. }
  595. if (/充值链接|apig-pay/.test(String(result.assistantMessage || ''))) {
  596. throw new Error('upstream search error was misreported as recharge/balance');
  597. }
  598. if (result.status === 'needs_keyword_fix') {
  599. throw new Error('upstream search error must not be misreported as a keyword problem');
  600. }
  601. } finally {
  602. global.fetch = originalFetch;
  603. }
  604. let callCount = 0;
  605. try {
  606. global.fetch = async () => {
  607. callCount += 1;
  608. if (callCount === 1) {
  609. return {
  610. ok: false,
  611. status: 500,
  612. text: async () => JSON.stringify({
  613. code: 500,
  614. message: '社交平台接口请求错误'
  615. })
  616. };
  617. }
  618. return {
  619. ok: true,
  620. status: 200,
  621. text: async () => JSON.stringify({
  622. code: 0,
  623. data: {
  624. data: {
  625. business_data: [
  626. {
  627. data: {
  628. aweme_info: {
  629. aweme_id: 'douyin-repair-smoke-1',
  630. desc: '家居定制性价比新客怎么选避坑',
  631. author: { nickname: 'smoke' },
  632. statistics: { digg_count: 1, comment_count: 0, share_count: 0 }
  633. }
  634. }
  635. }
  636. ]
  637. }
  638. }
  639. })
  640. };
  641. };
  642. const repaired = await runDouyinTrend({
  643. collectionMode: 'live',
  644. vocToken: 'r:smoke-token',
  645. industry: '家居定制',
  646. targetAudience: '新客',
  647. keywords: '家居定制 18元 22元',
  648. keywordLimit: 1,
  649. videosPerKeyword: 1,
  650. maxCommentPages: 0,
  651. cacheAssets: false,
  652. output: path.join(OUTPUT_ROOT, 'douyin-auto-keyword-repair')
  653. });
  654. if (repaired.status !== 'ok' || repaired.summary?.videoCount !== 1) {
  655. throw new Error(`expected transient upstream error to auto-recover via request retry, got ${repaired.status}`);
  656. }
  657. } finally {
  658. global.fetch = originalFetch;
  659. }
  660. }
  661. async function main() {
  662. const sample = await runXiaohongshuTrend({
  663. collectionMode: 'sample',
  664. profile: path.join(ROOT, 'memory-templates', 'xiaohongshu-trend-profile.json'),
  665. output: path.join(OUTPUT_ROOT, 'sample')
  666. });
  667. if (sample.status !== 'ok') {
  668. throw new Error(`sample failed: ${sample.status}`);
  669. }
  670. smokeEvidenceCardReport(sample);
  671. smokeAccessibleUrlNormalization();
  672. const douyinSample = await runDouyinTrend({
  673. collectionMode: 'sample',
  674. profile: path.join(ROOT, 'memory-templates', 'douyin-trend-profile.json'),
  675. output: path.join(OUTPUT_ROOT, 'douyin-sample')
  676. });
  677. if (douyinSample.status !== 'ok') {
  678. throw new Error(`douyin sample failed: ${douyinSample.status}`);
  679. }
  680. smokeDouyinEvidenceCardReport(douyinSample);
  681. smokeDouyinUrlNormalization();
  682. const preference = await updateXiaohongshuPreference({
  683. message: '保留奶油风和全屋定制翻车方向,不要纯风格美图,下一个版本更偏门店转化话术。',
  684. memory: MEMORY_PATH
  685. });
  686. if (preference.status !== 'ok') {
  687. throw new Error(`preference failed: ${preference.status}`);
  688. }
  689. const withMemory = await runXiaohongshuTrend({
  690. collectionMode: 'sample',
  691. profile: path.join(ROOT, 'memory-templates', 'xiaohongshu-trend-profile.json'),
  692. memory: MEMORY_PATH,
  693. output: path.join(OUTPUT_ROOT, 'with-memory')
  694. });
  695. if (withMemory.status !== 'ok' || !withMemory.summary.preferenceMemoryApplied) {
  696. throw new Error('sample-with-memory did not apply preference memory');
  697. }
  698. const douyinPreference = await updateDouyinPreference({
  699. message: '保留口播开头和评论区问题,不要只做纯素材剪辑,下一个版本更偏门店转化话术。',
  700. memory: DOUYIN_MEMORY_PATH
  701. });
  702. if (douyinPreference.status !== 'ok') {
  703. throw new Error(`douyin preference failed: ${douyinPreference.status}`);
  704. }
  705. const douyinWithMemory = await runDouyinTrend({
  706. collectionMode: 'sample',
  707. profile: path.join(ROOT, 'memory-templates', 'douyin-trend-profile.json'),
  708. memory: DOUYIN_MEMORY_PATH,
  709. output: path.join(OUTPUT_ROOT, 'douyin-with-memory')
  710. });
  711. if (douyinWithMemory.status !== 'ok' || !douyinWithMemory.summary.preferenceMemoryApplied) {
  712. throw new Error('douyin sample-with-memory did not apply preference memory');
  713. }
  714. const deepDive = await runVocProblemDeepDive({
  715. issue: '怎么选',
  716. industry: '家居定制',
  717. scenario: '线上获客',
  718. audience: '意向用户',
  719. memory: path.join(OUTPUT_ROOT, 'voc-problem-memory.json'),
  720. evidence: [
  721. '第一次买就得反复对比。',
  722. '对比了很久以后,如果质量普通就会觉得不值。'
  723. ]
  724. });
  725. const deepDiveBody = String(deepDive.assistantMessage || '');
  726. if (deepDive.status !== 'ok' ||
  727. !deepDiveBody.includes('## 老板视角影响') ||
  728. !deepDiveBody.includes('## 可执行解决动作') ||
  729. !deepDiveBody.includes('## 下轮验证指标')) {
  730. throw new Error('VOC problem deep dive did not return boss-oriented action output');
  731. }
  732. if (!deepDiveBody.includes('## 优先级判断') || !deepDiveBody.includes('## 7 天验证计划')) {
  733. throw new Error('VOC problem deep dive did not return priority and validation plan');
  734. }
  735. if (!deepDive.summary.memoryPath || !(deepDive.files || []).includes(deepDive.summary.memoryPath)) {
  736. throw new Error('VOC problem deep dive did not persist memory');
  737. }
  738. if (!JSON.stringify(deepDive.nextActions || []).includes('live')) {
  739. throw new Error('VOC problem deep dive next actions did not hand off to live collection');
  740. }
  741. const deepDiveWithMemory = await runVocProblemDeepDive({
  742. issue: '怎么选',
  743. industry: '家居定制',
  744. scenario: '线上获客',
  745. audience: '意向用户',
  746. memory: deepDive.summary.memoryPath,
  747. feedback: '老板不想做试用体验,更想先改评论区回复和案例展示',
  748. blockedActions: ['试用体验'],
  749. preferredActions: ['评论区回复', '案例展示']
  750. });
  751. const memoryBody = String(deepDiveWithMemory.assistantMessage || '');
  752. if (!deepDiveWithMemory.summary.memoryApplied || !memoryBody.includes('## 已应用的使用记忆')) {
  753. throw new Error('VOC problem deep dive did not apply iterative memory');
  754. }
  755. smokeDeepDiveMemoryBehavior(deepDiveWithMemory, '试用体验');
  756. const issuePool = await runVocIssuePool({
  757. project: '示例品牌A',
  758. industry: '家居定制',
  759. scenario: '线上获客',
  760. audience: '意向用户',
  761. evidence: [
  762. '第一次买不知道怎么选,怕踩雷。',
  763. '对比来对比去,到了下单才发现细节没说清。',
  764. '价格有点高,第一次买不知道怎么选不踩雷。',
  765. '咨询忙起来没人理。',
  766. '视频很种草,实际拿到手有落差。'
  767. ]
  768. });
  769. const issuePoolBody = String(issuePool.assistantMessage || '');
  770. if (issuePool.status !== 'ok' ||
  771. !issuePoolBody.includes('# VOC 问题池') ||
  772. !issuePoolBody.includes('## 问题优先级') ||
  773. !issuePool.summary.memoryPath ||
  774. !issuePool.data?.issues?.length) {
  775. throw new Error('VOC issue pool did not return prioritized issue output');
  776. }
  777. if (!issuePool.data.issues.some(issue => issue.title === '选择门槛/决策')) {
  778. throw new Error('VOC issue pool did not classify decision issue');
  779. }
  780. const issuePoolUpdated = await runVocIssuePool({
  781. project: '示例品牌A',
  782. industry: '家居定制',
  783. scenario: '线上获客',
  784. audience: '意向用户',
  785. memory: issuePool.summary.memoryPath,
  786. evidence: ['选择问题已经开始做对比清单验证。'],
  787. validatingIssues: ['选择门槛/决策']
  788. });
  789. if (!issuePoolUpdated.data?.issues?.some(issue => issue.title === '选择门槛/决策' && issue.status === '验证中')) {
  790. throw new Error('VOC issue pool did not apply issue status update');
  791. }
  792. const waitingTrend = issuePoolUpdated.data?.issues?.find(issue => issue.title === '选择门槛/决策');
  793. if (!waitingTrend || waitingTrend.previousCount < 1 || waitingTrend.delta >= 0 || waitingTrend.trend !== 'down') {
  794. throw new Error('VOC issue pool did not compare issue trend with previous run');
  795. }
  796. if (!String(issuePoolUpdated.assistantMessage || '').includes('## 问题变化')) {
  797. throw new Error('VOC issue pool did not output issue change section');
  798. }
  799. const parsedFeedbackDeepDive = await runVocProblemDeepDive({
  800. issue: '怎么选',
  801. project: '示例品牌A',
  802. industry: '家居定制',
  803. scenario: '线上获客',
  804. audience: '意向用户',
  805. feedback: '这个品牌不想做试用体验,更想先改评论区回复和案例展示'
  806. });
  807. smokeDeepDiveMemoryBehavior(parsedFeedbackDeepDive, '试用体验');
  808. if (!String(parsedFeedbackDeepDive.summary.memoryPath || '').includes('示例品牌a')) {
  809. throw new Error('VOC problem deep dive did not use scoped default memory path');
  810. }
  811. const parsedMemory = parsedFeedbackDeepDive.data?.memory || {};
  812. if (!parsedMemory.blockedActions?.includes('试用体验') ||
  813. !parsedMemory.preferredActions?.includes('评论区回复') ||
  814. !parsedMemory.preferredActions?.includes('案例展示')) {
  815. throw new Error('VOC problem deep dive did not parse natural-language feedback into memory');
  816. }
  817. const actionWeightedDeepDive = await runVocProblemDeepDive({
  818. issue: '怎么选',
  819. project: '示例品牌A',
  820. industry: '家居定制',
  821. scenario: '线上获客',
  822. audience: '意向用户',
  823. memory: parsedFeedbackDeepDive.summary.memoryPath,
  824. validatedActions: ['案例展示'],
  825. rejectedActions: ['试用体验'],
  826. feedback: '案例展示有效,试用体验无效'
  827. });
  828. const actionWeightedBody = String(actionWeightedDeepDive.assistantMessage || '');
  829. if (!actionWeightedBody.includes('为什么优先推荐这些动作')) {
  830. throw new Error('VOC problem deep dive did not explain action-weight memory');
  831. }
  832. const actionStats = actionWeightedDeepDive.data?.memory?.actionStats || {};
  833. if (!actionStats['案例展示']?.validatedCount || !actionStats['试用体验']?.rejectedCount) {
  834. throw new Error('VOC problem deep dive did not persist action validation stats');
  835. }
  836. const contentPlan = await runVocContentPlan({
  837. brand: '示例品牌A',
  838. industry: '家居定制',
  839. issues: ['第一次买怕踩雷', '觉得价格贵', '怕质量不稳定']
  840. });
  841. const contentPlanBody = String(contentPlan.assistantMessage || '');
  842. if (contentPlan.status !== 'ok' ||
  843. !contentPlanBody.includes('7 天 VOC 内容选题') ||
  844. !contentPlanBody.includes('前 3 秒钩子') ||
  845. !contentPlanBody.includes('口播脚本') ||
  846. contentPlan.data?.plan?.length !== 7) {
  847. throw new Error('VOC content plan did not return 7-day script output');
  848. }
  849. const speakingScript = await runVocSpeakingScript({
  850. brand: '示例品牌A',
  851. industry: '家居定制',
  852. topic: '第一次买怎么选不踩雷',
  853. userIssue: '第一次买怕选错',
  854. evidence: ['第一次买不知道怎么选,怕踩雷'],
  855. feedback: '开头更狠一点,老板视角,少讲概念,多给具体场景',
  856. memory: path.join(OUTPUT_ROOT, 'voc-speaking-script-memory.json'),
  857. finalize: true
  858. });
  859. const speakingScriptBody = String(speakingScript.assistantMessage || '');
  860. if (speakingScript.status !== 'ok' ||
  861. !speakingScriptBody.includes('VOC 口播脚本共创') ||
  862. !speakingScriptBody.includes('60 秒口播稿') ||
  863. !speakingScriptBody.includes('30 秒压缩版') ||
  864. !speakingScript.data?.memory?.finalizedScripts?.length ||
  865. !speakingScript.data?.memory?.preferredStyles?.includes('老板视角')) {
  866. throw new Error('VOC speaking script did not return co-creation output with memory');
  867. }
  868. const competitorMap = await runVocCompetitorMap({
  869. brand: '示例品牌A',
  870. city: '本地',
  871. category: '家居定制'
  872. });
  873. const competitorMapBody = String(competitorMap.assistantMessage || '');
  874. if (competitorMap.status !== 'ok' ||
  875. !competitorMapBody.includes('竞品图谱') ||
  876. !competitorMapBody.includes('错位竞争') ||
  877. !competitorMap.data?.competitors?.length) {
  878. throw new Error('VOC competitor map did not return competitor opportunity output');
  879. }
  880. const businessWorkflow = await runBusinessWorkflow({
  881. brand: '示例品牌A',
  882. industry: '家居定制',
  883. platform: 'douyin',
  884. collectionMode: 'sample',
  885. keywords: ['家居定制怎么选', '第一次买怎么对比', '售后']
  886. });
  887. const businessWorkflowBody = String(businessWorkflow.assistantMessage || '');
  888. if (businessWorkflow.status !== 'ok' ||
  889. !businessWorkflowBody.includes('VOC经营闭环') ||
  890. !businessWorkflowBody.includes('市场和用户声音') ||
  891. !businessWorkflowBody.includes('先改哪个问题') ||
  892. !businessWorkflowBody.includes('第一条口播稿') ||
  893. businessWorkflow.summary?.workflowStage !== 'business_workflow_first_round') {
  894. throw new Error('VOC business workflow did not return end-to-end workflow output');
  895. }
  896. await smokeNoTokenRechargePrompt();
  897. await smokeDouyinNoTokenRechargePrompt();
  898. smokeWorkspaceEnvLocalToken();
  899. smokeAncestorEnvLocalToken();
  900. await smokeIssuePoolReportEvidenceFiltering();
  901. await smokeBusinessStatusErrors();
  902. await smokeDouyinSearchInputErrors();
  903. await smokeCrossIndustry({ root: ROOT });
  904. const toolNames = await smokeMcpTools();
  905. const requiredTools = [
  906. 'fmode_image_analysis',
  907. 'voc_business_workflow_run',
  908. 'voc_xiaohongshu_token_check',
  909. 'voc_xiaohongshu_trend_run',
  910. 'voc_xiaohongshu_preference_update',
  911. 'voc_douyin_token_check',
  912. 'voc_douyin_trend_run',
  913. 'voc_douyin_preference_update',
  914. 'voc_issue_pool_run',
  915. 'voc_problem_deep_dive_run',
  916. 'voc_content_plan_run',
  917. 'voc_speaking_script_run',
  918. 'voc_competitor_map_run',
  919. 'voc_api_search',
  920. 'voc_api_doc',
  921. 'voc_api_call'
  922. ];
  923. for (const tool of requiredTools) {
  924. if (!toolNames.includes(tool)) {
  925. throw new Error(`missing MCP tool: ${tool}`);
  926. }
  927. }
  928. console.log(JSON.stringify({
  929. status: 'ok',
  930. checks: [
  931. 'sample report',
  932. 'evidence cards',
  933. 'local asset cache',
  934. 'xsec URL normalization',
  935. 'preference memory',
  936. 'sample report with memory',
  937. 'no-token recharge prompt',
  938. 'workspace .env.local token source',
  939. 'ancestor .env.local token source',
  940. 'VOC issue pool report evidence filtering',
  941. 'douyin sample report',
  942. 'douyin sample report with memory',
  943. 'VOC issue pool',
  944. 'VOC issue pool status memory',
  945. 'VOC issue pool trend comparison',
  946. 'VOC problem deep dive',
  947. 'VOC problem deep dive scoped memory',
  948. 'VOC problem deep dive feedback parser',
  949. 'VOC problem deep dive blocked-action guard',
  950. 'VOC problem deep dive action-weight memory',
  951. 'VOC content plan',
  952. 'VOC speaking script co-creation',
  953. 'VOC competitor map',
  954. 'VOC business workflow',
  955. 'douyin no-token recharge prompt',
  956. 'business-status recharge guard',
  957. 'douyin search input error guard',
  958. 'cross-industry sample smoke',
  959. 'MCP tools'
  960. ],
  961. mcpTools: toolNames,
  962. reportPreview: String(withMemory.assistantMessage || '').split('\n').slice(0, 8).join('\n'),
  963. files: withMemory.files
  964. }, null, 2));
  965. }
  966. main().catch(error => {
  967. console.error(JSON.stringify({
  968. status: 'error',
  969. message: error.message
  970. }, null, 2));
  971. process.exit(1);
  972. });