douyin-speaking-daily-p1-smoke.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { spawnSync } = require('child_process');
  5. function detectWorkspaceRoot() {
  6. const cwd = process.cwd();
  7. if (fs.existsSync(path.join(cwd, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) {
  8. return cwd;
  9. }
  10. const repoRoot = path.resolve(__dirname, '..', '..');
  11. if (fs.existsSync(path.join(repoRoot, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) {
  12. return repoRoot;
  13. }
  14. const openclawWorkspace = path.resolve(__dirname, '..', 'workspace');
  15. if (fs.existsSync(path.join(openclawWorkspace, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) {
  16. return openclawWorkspace;
  17. }
  18. return cwd;
  19. }
  20. const root = detectWorkspaceRoot();
  21. const outRoot = path.join(root, 'outputs', 'p1-auto-smoke');
  22. const fixtureDir = path.join(outRoot, 'fixtures');
  23. function ensureDir(dirPath) {
  24. fs.mkdirSync(dirPath, { recursive: true });
  25. }
  26. function writeJson(filePath, value) {
  27. ensureDir(path.dirname(filePath));
  28. fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
  29. }
  30. function extractLastJson(stdout) {
  31. const text = String(stdout || '').trim();
  32. for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) {
  33. try {
  34. return JSON.parse(text.slice(i));
  35. } catch {
  36. continue;
  37. }
  38. }
  39. throw new Error(`No JSON object found in output:\n${text.slice(-1200)}`);
  40. }
  41. function runNode(args, label) {
  42. const result = spawnSync(process.execPath, args, {
  43. cwd: root,
  44. encoding: 'utf8',
  45. maxBuffer: 1024 * 1024 * 120,
  46. windowsHide: true
  47. });
  48. if (result.error) throw result.error;
  49. if (result.status !== 0) {
  50. throw new Error(`${label} failed with code ${result.status}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`);
  51. }
  52. return result.stdout;
  53. }
  54. function assert(condition, message) {
  55. if (!condition) throw new Error(message);
  56. }
  57. function makeFixtures() {
  58. ensureDir(fixtureDir);
  59. const profilePath = path.join(fixtureDir, 'profile.json');
  60. const inputPath = path.join(fixtureDir, 'runner-raw-input.json');
  61. const noTranscriptInputPath = path.join(fixtureDir, 'runner-raw-input-no-transcript.json');
  62. const failGateInputPath = path.join(fixtureDir, 'runner-raw-input-fail-gate.json');
  63. const memoryPath = path.join(fixtureDir, 'script-memory.json');
  64. const historyPath = path.join(fixtureDir, 'run-history.json');
  65. const longAudioPath = path.join(fixtureDir, 'long-audio.mp3');
  66. const profile = {
  67. projectName: 'p1-smoke',
  68. industry: 'AI创业、AI工具、B2B增长',
  69. accountPositioning: '老板IP,面向中小创业者输出AI工具和增长判断',
  70. targetAudience: ['中小创业者', 'B2B服务团队'],
  71. conversionGoal: '获客咨询',
  72. coreOffer: 'AI增长诊断和工具落地陪跑',
  73. keywords: ['AI工具', 'AI创业'],
  74. referenceAccounts: [],
  75. forbiddenTopics: ['纯工具清单搬运'],
  76. dailyOutputCount: 3
  77. };
  78. const input = {
  79. metadata: { fixture: 'p1-auto-smoke' },
  80. videos: [
  81. {
  82. aweme_id: 'smoke_001',
  83. title: '影视大佬将退出舞台',
  84. keyword: 'AI工具',
  85. authorName: '9老板Ai创业',
  86. likeCount: 2308,
  87. commentCount: 246,
  88. shareCount: 147,
  89. url: 'https://www.iesdouyin.com/share/video/smoke_001/'
  90. },
  91. {
  92. aweme_id: 'smoke_002',
  93. title: '2026年5月最值得使用的AI工具有哪些',
  94. keyword: 'AI工具',
  95. authorName: '阿川同学',
  96. likeCount: 100,
  97. commentCount: 7,
  98. shareCount: 21,
  99. url: 'https://www.iesdouyin.com/share/video/smoke_002/'
  100. },
  101. {
  102. aweme_id: 'smoke_003',
  103. title: '玩汽车行业红绿战略的看这里',
  104. keyword: 'AI创业',
  105. authorName: '9老板Ai创业',
  106. likeCount: 7,
  107. commentCount: 2,
  108. shareCount: 1,
  109. url: 'https://www.iesdouyin.com/share/video/smoke_003/'
  110. }
  111. ],
  112. accountVideos: [
  113. { aweme_id: 'smoke_001', title: '影视大佬将退出舞台', authorName: '9老板Ai创业' }
  114. ],
  115. comments: [
  116. { cid: 'c1', aweme_id: 'smoke_001', text: '老板到底怎么用AI工具获客?', digg_count: 88 },
  117. { cid: 'c2', aweme_id: 'smoke_001', text: '有没有B2B服务团队能照着做的步骤?', digg_count: 66 },
  118. { cid: 'c3', aweme_id: 'smoke_002', text: '这些工具哪个最适合小团队?', digg_count: 12 }
  119. ],
  120. transcripts: [
  121. {
  122. awemeId: 'smoke_001',
  123. provider: 'iflytek-gateway',
  124. text: '影视大佬华谊挺不住了。真正的问题不是没有机会,而是过去那套判断方式不适合现在了。老板要先看趋势,再看现金流,最后决定动作。',
  125. segments: []
  126. }
  127. ]
  128. };
  129. const memory = {
  130. version: 1,
  131. updatedAt: new Date().toISOString(),
  132. sessions: [],
  133. preferenceSignals: {
  134. tones: { '老板视角': 2 },
  135. hookStrength: { strong: 2 },
  136. ctaStyles: { '产品转化': 1 },
  137. add: { '具体场景': 1 },
  138. avoid: { '泛泛表达': 1 }
  139. }
  140. };
  141. writeJson(profilePath, profile);
  142. writeJson(inputPath, input);
  143. writeJson(noTranscriptInputPath, { ...input, transcripts: [] });
  144. writeJson(failGateInputPath, { metadata: { fixture: 'p1-auto-smoke-fail-gate' }, videos: [], comments: [], transcripts: [] });
  145. writeJson(memoryPath, memory);
  146. fs.writeFileSync(longAudioPath, 'fake audio placeholder for duration guard smoke', 'utf8');
  147. return { profilePath, inputPath, noTranscriptInputPath, failGateInputPath, memoryPath, historyPath, longAudioPath };
  148. }
  149. function run() {
  150. if (fs.existsSync(outRoot)) fs.rmSync(outRoot, { recursive: true, force: true });
  151. const fixtures = makeFixtures();
  152. const checks = [];
  153. const check = (name, fn) => {
  154. fn();
  155. checks.push(name);
  156. console.log(`[ok] ${name}`);
  157. };
  158. check('syntax', () => {
  159. runNode(['--check', 'scripts/tools/douyin-speaking-daily-report.js'], 'report syntax');
  160. runNode(['--check', 'scripts/tools/douyin-speaking-daily-runner.js'], 'runner syntax');
  161. runNode(['--check', 'scripts/tools/douyin-video-transcriber.js'], 'transcriber syntax');
  162. });
  163. let v0;
  164. const v0Out = path.join(outRoot, 'report-v0');
  165. check('report selected topic v0 uses script memory', () => {
  166. v0 = extractLastJson(runNode([
  167. 'scripts/tools/douyin-speaking-daily-report.js',
  168. '--profile', fixtures.profilePath,
  169. '--input', fixtures.inputPath,
  170. '--output', v0Out,
  171. '--selected-topic-index', '1',
  172. '--script-memory', fixtures.memoryPath,
  173. '--history-memory', fixtures.historyPath
  174. ], 'report v0'));
  175. assert(v0.status === 'ok', 'v0 status should be ok');
  176. assert(v0.scriptVersion === 'v0', 'v0 scriptVersion should be v0');
  177. assert(v0.assistantMessage.includes('历史偏好'), 'v0 should surface memory guidance');
  178. assert(v0.assistantMessage.includes('老板'), 'v0 should apply remembered boss angle');
  179. assert(!v0.assistantMessage.includes('VOC'), 'generic profile should not leak VOC wording');
  180. assert(v0.qualityGate && v0.qualityGate.level, 'v0 should include qualityGate');
  181. });
  182. const v1Out = v0Out;
  183. check('feedback produces v1', () => {
  184. const v1 = extractLastJson(runNode([
  185. 'scripts/tools/douyin-speaking-daily-report.js',
  186. '--profile', fixtures.profilePath,
  187. '--input', fixtures.inputPath,
  188. '--output', v1Out,
  189. '--script-session', path.join(v0Out, 'script-session.json'),
  190. '--feedback', '开头更狠,老板视角,太泛了,加一个具体 B2B 场景',
  191. '--script-memory', fixtures.memoryPath,
  192. '--history-memory', fixtures.historyPath
  193. ], 'report v1'));
  194. assert(v1.scriptVersion === 'v1', 'feedback should produce v1');
  195. assert(v1.assistantMessage.includes('本轮理解'), 'v1 should explain parsed feedback');
  196. assert(v1.assistantMessage.includes('具体场景'), 'v1 should retain concrete scenario request');
  197. });
  198. check('implicit finalize reuses output script session and writes memory', () => {
  199. const finalResult = extractLastJson(runNode([
  200. 'scripts/tools/douyin-speaking-daily-report.js',
  201. '--profile', fixtures.profilePath,
  202. '--input', fixtures.inputPath,
  203. '--output', v1Out,
  204. '--finalize', 'true',
  205. '--script-memory', fixtures.memoryPath,
  206. '--history-memory', fixtures.historyPath
  207. ], 'report final'));
  208. assert(finalResult.scriptSessionStatus === 'finalized', 'finalize should mark session finalized');
  209. assert(finalResult.scriptVersion === 'v1', 'implicit finalize should reuse v1 output session');
  210. assert(finalResult.assistantMessage.includes('定稿自检'), 'finalized script should include final quality gate');
  211. assert(finalResult.assistantMessage.includes('30秒压缩版'), 'finalized script should include a 30s delivery version');
  212. assert(finalResult.historyMemoryPath === fixtures.historyPath, 'finalize should return history memory path');
  213. const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8'));
  214. const last = memory.sessions.at(-1);
  215. assert(last.feedbackCount === 1, 'finalize should persist feedback count');
  216. assert(memory.preferenceSignals.tones['老板视角'] >= 1, 'finalize should persist tone preference');
  217. assert(memory.preferenceSignals.hookStrength.strong >= 1, 'finalize should persist hook preference');
  218. const history = JSON.parse(fs.readFileSync(fixtures.historyPath, 'utf8'));
  219. assert(history.runs.length >= 1, 'run history should persist report runs');
  220. assert(history.runs.at(-1).scriptVersion === 'v1', 'run history should record script version');
  221. });
  222. check('runner selected topic passthrough', () => {
  223. const runner = extractLastJson(runNode([
  224. 'scripts/tools/douyin-speaking-daily-runner.js',
  225. '--profile', fixtures.profilePath,
  226. '--input', fixtures.inputPath,
  227. '--output', path.join(outRoot, 'runner-selected'),
  228. '--selected-topic-index', '1',
  229. '--history-memory', fixtures.historyPath
  230. ], 'runner selected topic'));
  231. assert(runner.scriptVersion === 'v0', 'runner should passthrough selectedTopicIndex to report');
  232. assert(runner.assistantMessage.startsWith('# 选题 1 共创稿'), 'runner should return script co-creation body');
  233. });
  234. check('natural message selects topic and auto-resumes latest raw input', () => {
  235. const runner = extractLastJson(runNode([
  236. 'scripts/tools/douyin-speaking-daily-runner.js',
  237. '--profile', fixtures.profilePath,
  238. '--output', path.join(outRoot, 'runner-natural-selected'),
  239. '--message', '选题 1,进入脚本共创',
  240. '--script-memory', fixtures.memoryPath,
  241. '--history-memory', fixtures.historyPath
  242. ], 'runner natural selected topic'));
  243. assert(runner.selectedTopicIndex === 1, 'natural message should parse selected topic');
  244. assert(runner.scriptVersion === 'v0', 'natural selected topic should produce v0');
  245. assert(runner.autoResolvedInputPath && runner.autoResolvedInputPath.endsWith('runner-raw-input.json'), 'natural selected topic should auto-resolve raw input');
  246. });
  247. check('natural selected topic overrides stale script session', () => {
  248. const stalePath = path.join(v0Out, 'script-session.json');
  249. const out = path.join(outRoot, 'report-natural-select-two');
  250. const selected = extractLastJson(runNode([
  251. 'scripts/tools/douyin-speaking-daily-report.js',
  252. '--profile', fixtures.profilePath,
  253. '--input', fixtures.inputPath,
  254. '--output', out,
  255. '--script-session', stalePath,
  256. '--message', '选题2,进入脚本共创',
  257. '--script-memory', fixtures.memoryPath,
  258. '--history-memory', fixtures.historyPath
  259. ], 'report natural select two'));
  260. assert(selected.selectedTopicIndex === 2, 'natural select two should use topic 2');
  261. assert(selected.scriptVersion === 'v0', 'topic switch should create a fresh v0 session');
  262. assert(selected.scriptSessionPath === path.join(out, 'script-session.json'), 'topic switch should not overwrite stale session path');
  263. const session = JSON.parse(fs.readFileSync(selected.scriptSessionPath, 'utf8'));
  264. assert(session.selectedTopicIndex === 2, 'new session should persist topic 2');
  265. assert(session.sourceAwemeId === 'smoke_002', 'new session should bind to topic 2 source video');
  266. });
  267. check('natural finalize auto-resumes latest script session', () => {
  268. const runner = extractLastJson(runNode([
  269. 'scripts/tools/douyin-speaking-daily-runner.js',
  270. '--profile', fixtures.profilePath,
  271. '--output', path.join(outRoot, 'runner-natural-finalize'),
  272. '--message', '定稿',
  273. '--script-memory', fixtures.memoryPath,
  274. '--history-memory', fixtures.historyPath
  275. ], 'runner natural finalize'));
  276. assert(runner.scriptSessionStatus === 'finalized', 'natural finalize should keep session finalized');
  277. const resumedSessionPath = runner.autoResolvedScriptSessionPath || runner.scriptSessionPath;
  278. assert(resumedSessionPath && resumedSessionPath.endsWith('script-session.json'), 'natural finalize should auto-resolve or reuse script session');
  279. });
  280. check('natural preference after finalize writes memory only', () => {
  281. const finalizedSessionPath = path.join(v1Out, 'script-session.json');
  282. const before = JSON.parse(fs.readFileSync(finalizedSessionPath, 'utf8'));
  283. assert(before.status === 'finalized', 'preference-only regression needs a finalized session');
  284. const pref = extractLastJson(runNode([
  285. 'scripts/tools/douyin-speaking-daily-runner.js',
  286. '--profile', fixtures.profilePath,
  287. '--output', v1Out,
  288. '--message', '保留选题1和2,以后多给这种方向;不要纯工具清单方向',
  289. '--script-memory', fixtures.memoryPath,
  290. '--history-memory', fixtures.historyPath
  291. ], 'runner natural preference after finalize'));
  292. assert(pref.topicPreferenceMemoryPath === fixtures.memoryPath, 'runner preference should write script memory');
  293. assert(pref.assistantMessage.includes('偏好已记录'), 'runner preference should acknowledge preference writes');
  294. assert(!pref.assistantMessage.includes('今日热点选题池'), 'preference-only reply should not append a fresh report');
  295. const after = JSON.parse(fs.readFileSync(finalizedSessionPath, 'utf8'));
  296. assert(after.status === 'finalized', 'preference-only message must not revert finalized script to draft');
  297. const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8'));
  298. assert(memory.topicPreferences.keepPhrases['AI工具'] >= 1, 'keep topic indexes should persist from natural phrase');
  299. assert(memory.topicPreferences.dropPhrases['纯工具清单方向'] >= 1, 'drop phrase should persist from natural phrase');
  300. });
  301. check('natural preference message writes keep and drop memory', () => {
  302. const pref = extractLastJson(runNode([
  303. 'scripts/tools/douyin-speaking-daily-report.js',
  304. '--profile', fixtures.profilePath,
  305. '--input', fixtures.inputPath,
  306. '--output', path.join(outRoot, 'report-preference-message'),
  307. '--message', '保留1、2,不要汽车行业方向',
  308. '--script-memory', fixtures.memoryPath,
  309. '--history-memory', fixtures.historyPath
  310. ], 'report natural preference'));
  311. assert(pref.topicPreferenceMemoryPath === fixtures.memoryPath, 'preference message should write script memory');
  312. assert(pref.assistantMessage.includes('偏好已记录'), 'preference message should acknowledge preference writes');
  313. const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8'));
  314. assert(memory.topicPreferences.keepPhrases['AI工具'] >= 1, 'kept topic keyword should be weighted');
  315. assert(memory.topicPreferences.dropPhrases['汽车行业方向'] >= 1, 'drop phrase should be weighted');
  316. });
  317. check('final quality gate blocks unsupported precise case claims', () => {
  318. const out = path.join(outRoot, 'report-unsupported-claim');
  319. const seed = extractLastJson(runNode([
  320. 'scripts/tools/douyin-speaking-daily-report.js',
  321. '--profile', fixtures.profilePath,
  322. '--input', fixtures.inputPath,
  323. '--output', out,
  324. '--selected-topic-index', '1',
  325. '--history-memory', fixtures.historyPath
  326. ], 'report unsupported claim seed'));
  327. const sessionPath = seed.scriptSessionPath;
  328. const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
  329. session.versions.push({
  330. version: 'v_bad',
  331. status: 'draft',
  332. title: '未证实案例稿',
  333. script: '我们最近服务了一个真实客户,投流20万以后成本降了42%,转化翻了2.7倍。老板要先看评论区真实问题,再决定下一步怎么验证。',
  334. cta: '评论区告诉我你的行业。',
  335. strategy: 'unsupported precise case smoke',
  336. changeNotes: [],
  337. createdAt: new Date().toISOString()
  338. });
  339. fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
  340. const blocked = extractLastJson(runNode([
  341. 'scripts/tools/douyin-speaking-daily-report.js',
  342. '--profile', fixtures.profilePath,
  343. '--input', fixtures.inputPath,
  344. '--output', out,
  345. '--script-session', sessionPath,
  346. '--finalize', 'true',
  347. '--history-memory', fixtures.historyPath
  348. ], 'report unsupported claim finalize'));
  349. assert(blocked.scriptSessionStatus === 'needs_revision', 'unsupported precise case should block finalization');
  350. assert(blocked.assistantMessage.includes('未证实'), 'unsupported precise case should be explained in chat output');
  351. });
  352. check('no transcript fallback still generates v0', () => {
  353. const runner = extractLastJson(runNode([
  354. 'scripts/tools/douyin-speaking-daily-runner.js',
  355. '--profile', fixtures.profilePath,
  356. '--input', fixtures.noTranscriptInputPath,
  357. '--output', path.join(outRoot, 'runner-no-transcript'),
  358. '--selected-topic-index', '1',
  359. '--history-memory', fixtures.historyPath
  360. ], 'runner no transcript'));
  361. assert(runner.scriptVersion === 'v0', 'no transcript should still produce v0');
  362. assert(runner.assistantMessage.includes('结构推断稿'), 'no transcript v0 should mark structural inference');
  363. });
  364. check('quality gate fail blocks formal script', () => {
  365. const out = path.join(outRoot, 'runner-quality-gate-fail');
  366. const runner = extractLastJson(runNode([
  367. 'scripts/tools/douyin-speaking-daily-runner.js',
  368. '--profile', fixtures.profilePath,
  369. '--input', fixtures.failGateInputPath,
  370. '--output', out,
  371. '--selected-topic-index', '1',
  372. '--finalize', 'true',
  373. '--history-memory', fixtures.historyPath
  374. ], 'runner quality gate fail'));
  375. assert(runner.qualityGate.level === 'fail', 'quality gate should fail without video samples');
  376. assert(runner.scriptSessionStatus === 'blocked', 'quality gate fail should block script session');
  377. const session = JSON.parse(fs.readFileSync(path.join(out, 'script-session.json'), 'utf8'));
  378. assert(session.status === 'blocked', 'blocked session should stay blocked');
  379. assert(session.versions[0].status === 'blocked', 'blocked session should not generate formal draft');
  380. assert(session.versions[0].strategy === 'qualityGate.fail', 'blocked session should record quality gate strategy');
  381. });
  382. check('gateway duration guard does not upload long audio', () => {
  383. const guard = extractLastJson(runNode([
  384. 'scripts/tools/douyin-video-transcriber.js',
  385. '--provider', 'iflytek-gateway',
  386. '--file', fixtures.longAudioPath,
  387. '--duration-ms', '1200000',
  388. '--gateway-max-duration-ms', '480000',
  389. '--output', path.join(outRoot, 'gateway-duration-guard')
  390. ], 'gateway duration guard'));
  391. assert(['needs_media_processing', 'needs_provider_config'].includes(guard.status), 'duration guard should stop before successful upload');
  392. if (guard.status === 'needs_media_processing') {
  393. assert(guard.warningCount >= 1, 'duration guard should explain long video limit');
  394. }
  395. });
  396. console.log(JSON.stringify({
  397. status: 'ok',
  398. checks,
  399. outputDir: outRoot
  400. }, null, 2));
  401. }
  402. try {
  403. run();
  404. } catch (error) {
  405. console.error(error.stack || error.message);
  406. process.exit(1);
  407. }