smoke-package.js 39 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. '- 品牌:盛焰铁板烧',
  305. '- 城市:南昌',
  306. '',
  307. '## 当前经营痛点',
  308. '1. 日常营销能够带来 TC,但是活动折扣大。',
  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: '盛焰铁板烧',
  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 === 'waiting')) {
  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 waiting 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. });