| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- #!/usr/bin/env node
- const fs = require('fs');
- const path = require('path');
- const { spawnSync } = require('child_process');
- function detectWorkspaceRoot() {
- const cwd = process.cwd();
- if (fs.existsSync(path.join(cwd, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) {
- return cwd;
- }
- const repoRoot = path.resolve(__dirname, '..', '..');
- if (fs.existsSync(path.join(repoRoot, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) {
- return repoRoot;
- }
- const openclawWorkspace = path.resolve(__dirname, '..', 'workspace');
- if (fs.existsSync(path.join(openclawWorkspace, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) {
- return openclawWorkspace;
- }
- return cwd;
- }
- const root = detectWorkspaceRoot();
- const outRoot = path.join(root, 'outputs', 'p1-auto-smoke');
- const fixtureDir = path.join(outRoot, 'fixtures');
- function ensureDir(dirPath) {
- fs.mkdirSync(dirPath, { recursive: true });
- }
- function writeJson(filePath, value) {
- ensureDir(path.dirname(filePath));
- fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
- }
- function extractLastJson(stdout) {
- const text = String(stdout || '').trim();
- for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) {
- try {
- return JSON.parse(text.slice(i));
- } catch {
- continue;
- }
- }
- throw new Error(`No JSON object found in output:\n${text.slice(-1200)}`);
- }
- function runNode(args, label) {
- const result = spawnSync(process.execPath, args, {
- cwd: root,
- encoding: 'utf8',
- maxBuffer: 1024 * 1024 * 120,
- windowsHide: true
- });
- if (result.error) throw result.error;
- if (result.status !== 0) {
- throw new Error(`${label} failed with code ${result.status}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`);
- }
- return result.stdout;
- }
- function assert(condition, message) {
- if (!condition) throw new Error(message);
- }
- function makeFixtures() {
- ensureDir(fixtureDir);
- const profilePath = path.join(fixtureDir, 'profile.json');
- const inputPath = path.join(fixtureDir, 'runner-raw-input.json');
- const noTranscriptInputPath = path.join(fixtureDir, 'runner-raw-input-no-transcript.json');
- const failGateInputPath = path.join(fixtureDir, 'runner-raw-input-fail-gate.json');
- const memoryPath = path.join(fixtureDir, 'script-memory.json');
- const historyPath = path.join(fixtureDir, 'run-history.json');
- const longAudioPath = path.join(fixtureDir, 'long-audio.mp3');
- const profile = {
- projectName: 'p1-smoke',
- industry: 'AI创业、AI工具、B2B增长',
- accountPositioning: '老板IP,面向中小创业者输出AI工具和增长判断',
- targetAudience: ['中小创业者', 'B2B服务团队'],
- conversionGoal: '获客咨询',
- coreOffer: 'AI增长诊断和工具落地陪跑',
- keywords: ['AI工具', 'AI创业'],
- referenceAccounts: [],
- forbiddenTopics: ['纯工具清单搬运'],
- dailyOutputCount: 3
- };
- const input = {
- metadata: { fixture: 'p1-auto-smoke' },
- videos: [
- {
- aweme_id: 'smoke_001',
- title: '影视大佬将退出舞台',
- keyword: 'AI工具',
- authorName: '9老板Ai创业',
- likeCount: 2308,
- commentCount: 246,
- shareCount: 147,
- url: 'https://www.iesdouyin.com/share/video/smoke_001/'
- },
- {
- aweme_id: 'smoke_002',
- title: '2026年5月最值得使用的AI工具有哪些',
- keyword: 'AI工具',
- authorName: '阿川同学',
- likeCount: 100,
- commentCount: 7,
- shareCount: 21,
- url: 'https://www.iesdouyin.com/share/video/smoke_002/'
- },
- {
- aweme_id: 'smoke_003',
- title: '玩汽车行业红绿战略的看这里',
- keyword: 'AI创业',
- authorName: '9老板Ai创业',
- likeCount: 7,
- commentCount: 2,
- shareCount: 1,
- url: 'https://www.iesdouyin.com/share/video/smoke_003/'
- }
- ],
- accountVideos: [
- { aweme_id: 'smoke_001', title: '影视大佬将退出舞台', authorName: '9老板Ai创业' }
- ],
- comments: [
- { cid: 'c1', aweme_id: 'smoke_001', text: '老板到底怎么用AI工具获客?', digg_count: 88 },
- { cid: 'c2', aweme_id: 'smoke_001', text: '有没有B2B服务团队能照着做的步骤?', digg_count: 66 },
- { cid: 'c3', aweme_id: 'smoke_002', text: '这些工具哪个最适合小团队?', digg_count: 12 }
- ],
- transcripts: [
- {
- awemeId: 'smoke_001',
- provider: 'iflytek-gateway',
- text: '影视大佬华谊挺不住了。真正的问题不是没有机会,而是过去那套判断方式不适合现在了。老板要先看趋势,再看现金流,最后决定动作。',
- segments: []
- }
- ]
- };
- const memory = {
- version: 1,
- updatedAt: new Date().toISOString(),
- sessions: [],
- preferenceSignals: {
- tones: { '老板视角': 2 },
- hookStrength: { strong: 2 },
- ctaStyles: { '产品转化': 1 },
- add: { '具体场景': 1 },
- avoid: { '泛泛表达': 1 }
- }
- };
- writeJson(profilePath, profile);
- writeJson(inputPath, input);
- writeJson(noTranscriptInputPath, { ...input, transcripts: [] });
- writeJson(failGateInputPath, { metadata: { fixture: 'p1-auto-smoke-fail-gate' }, videos: [], comments: [], transcripts: [] });
- writeJson(memoryPath, memory);
- fs.writeFileSync(longAudioPath, 'fake audio placeholder for duration guard smoke', 'utf8');
- return { profilePath, inputPath, noTranscriptInputPath, failGateInputPath, memoryPath, historyPath, longAudioPath };
- }
- function run() {
- if (fs.existsSync(outRoot)) fs.rmSync(outRoot, { recursive: true, force: true });
- const fixtures = makeFixtures();
- const checks = [];
- const check = (name, fn) => {
- fn();
- checks.push(name);
- console.log(`[ok] ${name}`);
- };
- check('syntax', () => {
- runNode(['--check', 'scripts/tools/douyin-speaking-daily-report.js'], 'report syntax');
- runNode(['--check', 'scripts/tools/douyin-speaking-daily-runner.js'], 'runner syntax');
- runNode(['--check', 'scripts/tools/douyin-video-transcriber.js'], 'transcriber syntax');
- });
- let v0;
- const v0Out = path.join(outRoot, 'report-v0');
- check('report selected topic v0 uses script memory', () => {
- v0 = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-report.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', v0Out,
- '--selected-topic-index', '1',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'report v0'));
- assert(v0.status === 'ok', 'v0 status should be ok');
- assert(v0.scriptVersion === 'v0', 'v0 scriptVersion should be v0');
- assert(v0.assistantMessage.includes('历史偏好'), 'v0 should surface memory guidance');
- assert(v0.assistantMessage.includes('老板'), 'v0 should apply remembered boss angle');
- assert(!v0.assistantMessage.includes('VOC'), 'generic profile should not leak VOC wording');
- assert(v0.qualityGate && v0.qualityGate.level, 'v0 should include qualityGate');
- });
- const v1Out = v0Out;
- check('feedback produces v1', () => {
- const v1 = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-report.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', v1Out,
- '--script-session', path.join(v0Out, 'script-session.json'),
- '--feedback', '开头更狠,老板视角,太泛了,加一个具体 B2B 场景',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'report v1'));
- assert(v1.scriptVersion === 'v1', 'feedback should produce v1');
- assert(v1.assistantMessage.includes('本轮理解'), 'v1 should explain parsed feedback');
- assert(v1.assistantMessage.includes('具体场景'), 'v1 should retain concrete scenario request');
- });
- check('implicit finalize reuses output script session and writes memory', () => {
- const finalResult = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-report.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', v1Out,
- '--finalize', 'true',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'report final'));
- assert(finalResult.scriptSessionStatus === 'finalized', 'finalize should mark session finalized');
- assert(finalResult.scriptVersion === 'v1', 'implicit finalize should reuse v1 output session');
- assert(finalResult.assistantMessage.includes('定稿自检'), 'finalized script should include final quality gate');
- assert(finalResult.assistantMessage.includes('30秒压缩版'), 'finalized script should include a 30s delivery version');
- assert(finalResult.historyMemoryPath === fixtures.historyPath, 'finalize should return history memory path');
- const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8'));
- const last = memory.sessions.at(-1);
- assert(last.feedbackCount === 1, 'finalize should persist feedback count');
- assert(memory.preferenceSignals.tones['老板视角'] >= 1, 'finalize should persist tone preference');
- assert(memory.preferenceSignals.hookStrength.strong >= 1, 'finalize should persist hook preference');
- const history = JSON.parse(fs.readFileSync(fixtures.historyPath, 'utf8'));
- assert(history.runs.length >= 1, 'run history should persist report runs');
- assert(history.runs.at(-1).scriptVersion === 'v1', 'run history should record script version');
- });
- check('runner selected topic passthrough', () => {
- const runner = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-runner.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', path.join(outRoot, 'runner-selected'),
- '--selected-topic-index', '1',
- '--history-memory', fixtures.historyPath
- ], 'runner selected topic'));
- assert(runner.scriptVersion === 'v0', 'runner should passthrough selectedTopicIndex to report');
- assert(runner.assistantMessage.startsWith('# 选题 1 共创稿'), 'runner should return script co-creation body');
- });
- check('natural message selects topic and auto-resumes latest raw input', () => {
- const runner = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-runner.js',
- '--profile', fixtures.profilePath,
- '--output', path.join(outRoot, 'runner-natural-selected'),
- '--message', '选题 1,进入脚本共创',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'runner natural selected topic'));
- assert(runner.selectedTopicIndex === 1, 'natural message should parse selected topic');
- assert(runner.scriptVersion === 'v0', 'natural selected topic should produce v0');
- assert(runner.autoResolvedInputPath && runner.autoResolvedInputPath.endsWith('runner-raw-input.json'), 'natural selected topic should auto-resolve raw input');
- });
- check('natural selected topic overrides stale script session', () => {
- const stalePath = path.join(v0Out, 'script-session.json');
- const out = path.join(outRoot, 'report-natural-select-two');
- const selected = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-report.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', out,
- '--script-session', stalePath,
- '--message', '选题2,进入脚本共创',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'report natural select two'));
- assert(selected.selectedTopicIndex === 2, 'natural select two should use topic 2');
- assert(selected.scriptVersion === 'v0', 'topic switch should create a fresh v0 session');
- assert(selected.scriptSessionPath === path.join(out, 'script-session.json'), 'topic switch should not overwrite stale session path');
- const session = JSON.parse(fs.readFileSync(selected.scriptSessionPath, 'utf8'));
- assert(session.selectedTopicIndex === 2, 'new session should persist topic 2');
- assert(session.sourceAwemeId === 'smoke_002', 'new session should bind to topic 2 source video');
- });
- check('natural finalize auto-resumes latest script session', () => {
- const runner = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-runner.js',
- '--profile', fixtures.profilePath,
- '--output', path.join(outRoot, 'runner-natural-finalize'),
- '--message', '定稿',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'runner natural finalize'));
- assert(runner.scriptSessionStatus === 'finalized', 'natural finalize should keep session finalized');
- const resumedSessionPath = runner.autoResolvedScriptSessionPath || runner.scriptSessionPath;
- assert(resumedSessionPath && resumedSessionPath.endsWith('script-session.json'), 'natural finalize should auto-resolve or reuse script session');
- });
- check('natural preference after finalize writes memory only', () => {
- const finalizedSessionPath = path.join(v1Out, 'script-session.json');
- const before = JSON.parse(fs.readFileSync(finalizedSessionPath, 'utf8'));
- assert(before.status === 'finalized', 'preference-only regression needs a finalized session');
- const pref = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-runner.js',
- '--profile', fixtures.profilePath,
- '--output', v1Out,
- '--message', '保留选题1和2,以后多给这种方向;不要纯工具清单方向',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'runner natural preference after finalize'));
- assert(pref.topicPreferenceMemoryPath === fixtures.memoryPath, 'runner preference should write script memory');
- assert(pref.assistantMessage.includes('偏好已记录'), 'runner preference should acknowledge preference writes');
- assert(!pref.assistantMessage.includes('今日热点选题池'), 'preference-only reply should not append a fresh report');
- const after = JSON.parse(fs.readFileSync(finalizedSessionPath, 'utf8'));
- assert(after.status === 'finalized', 'preference-only message must not revert finalized script to draft');
- const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8'));
- assert(memory.topicPreferences.keepPhrases['AI工具'] >= 1, 'keep topic indexes should persist from natural phrase');
- assert(memory.topicPreferences.dropPhrases['纯工具清单方向'] >= 1, 'drop phrase should persist from natural phrase');
- });
- check('natural preference message writes keep and drop memory', () => {
- const pref = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-report.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', path.join(outRoot, 'report-preference-message'),
- '--message', '保留1、2,不要汽车行业方向',
- '--script-memory', fixtures.memoryPath,
- '--history-memory', fixtures.historyPath
- ], 'report natural preference'));
- assert(pref.topicPreferenceMemoryPath === fixtures.memoryPath, 'preference message should write script memory');
- assert(pref.assistantMessage.includes('偏好已记录'), 'preference message should acknowledge preference writes');
- const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8'));
- assert(memory.topicPreferences.keepPhrases['AI工具'] >= 1, 'kept topic keyword should be weighted');
- assert(memory.topicPreferences.dropPhrases['汽车行业方向'] >= 1, 'drop phrase should be weighted');
- });
- check('final quality gate blocks unsupported precise case claims', () => {
- const out = path.join(outRoot, 'report-unsupported-claim');
- const seed = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-report.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', out,
- '--selected-topic-index', '1',
- '--history-memory', fixtures.historyPath
- ], 'report unsupported claim seed'));
- const sessionPath = seed.scriptSessionPath;
- const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
- session.versions.push({
- version: 'v_bad',
- status: 'draft',
- title: '未证实案例稿',
- script: '我们最近服务了一个真实客户,投流20万以后成本降了42%,转化翻了2.7倍。老板要先看评论区真实问题,再决定下一步怎么验证。',
- cta: '评论区告诉我你的行业。',
- strategy: 'unsupported precise case smoke',
- changeNotes: [],
- createdAt: new Date().toISOString()
- });
- fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
- const blocked = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-report.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.inputPath,
- '--output', out,
- '--script-session', sessionPath,
- '--finalize', 'true',
- '--history-memory', fixtures.historyPath
- ], 'report unsupported claim finalize'));
- assert(blocked.scriptSessionStatus === 'needs_revision', 'unsupported precise case should block finalization');
- assert(blocked.assistantMessage.includes('未证实'), 'unsupported precise case should be explained in chat output');
- });
- check('no transcript fallback still generates v0', () => {
- const runner = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-runner.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.noTranscriptInputPath,
- '--output', path.join(outRoot, 'runner-no-transcript'),
- '--selected-topic-index', '1',
- '--history-memory', fixtures.historyPath
- ], 'runner no transcript'));
- assert(runner.scriptVersion === 'v0', 'no transcript should still produce v0');
- assert(runner.assistantMessage.includes('结构推断稿'), 'no transcript v0 should mark structural inference');
- });
- check('quality gate fail blocks formal script', () => {
- const out = path.join(outRoot, 'runner-quality-gate-fail');
- const runner = extractLastJson(runNode([
- 'scripts/tools/douyin-speaking-daily-runner.js',
- '--profile', fixtures.profilePath,
- '--input', fixtures.failGateInputPath,
- '--output', out,
- '--selected-topic-index', '1',
- '--finalize', 'true',
- '--history-memory', fixtures.historyPath
- ], 'runner quality gate fail'));
- assert(runner.qualityGate.level === 'fail', 'quality gate should fail without video samples');
- assert(runner.scriptSessionStatus === 'blocked', 'quality gate fail should block script session');
- const session = JSON.parse(fs.readFileSync(path.join(out, 'script-session.json'), 'utf8'));
- assert(session.status === 'blocked', 'blocked session should stay blocked');
- assert(session.versions[0].status === 'blocked', 'blocked session should not generate formal draft');
- assert(session.versions[0].strategy === 'qualityGate.fail', 'blocked session should record quality gate strategy');
- });
- check('gateway duration guard does not upload long audio', () => {
- const guard = extractLastJson(runNode([
- 'scripts/tools/douyin-video-transcriber.js',
- '--provider', 'iflytek-gateway',
- '--file', fixtures.longAudioPath,
- '--duration-ms', '1200000',
- '--gateway-max-duration-ms', '480000',
- '--output', path.join(outRoot, 'gateway-duration-guard')
- ], 'gateway duration guard'));
- assert(['needs_media_processing', 'needs_provider_config'].includes(guard.status), 'duration guard should stop before successful upload');
- if (guard.status === 'needs_media_processing') {
- assert(guard.warningCount >= 1, 'duration guard should explain long video limit');
- }
- });
- console.log(JSON.stringify({
- status: 'ok',
- checks,
- outputDir: outRoot
- }, null, 2));
- }
- try {
- run();
- } catch (error) {
- console.error(error.stack || error.message);
- process.exit(1);
- }
|