import { of, throwError } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; import { IpAccountSnapshot, IpAccountWorkSnapshot, IpAccountWorkTranscriptSnapshot, IpMonitoredAccount, } from '../models/ip-operator.model'; import { IpAccountWorkAnalysisService } from './ip-account-work-analysis.service'; import { IpAccountWorkReportService } from './ip-account-work-report.service'; import { buildAccountWorkAnalysisPrompt } from './ip-operator-prompts'; import { LlmService } from './llm.service'; describe('buildAccountWorkAnalysisPrompt', () => { it('uses transcript and comments for one work without including other works', () => { const prompt = buildAccountWorkAnalysisPrompt({ accountName: 'FredTalk', accountIntentSummary: '账号定位摘要,不应包含第二个作品文字稿', work: { workId: 'work-1', awemeId: 'aweme-1', title: '新手小白安装这六个 Skills 就行够了', desc: '工具安装顺序和选择建议', metricsSummary: '点赞 58850 / 评论 1036', transcriptText: '当前作品完整视频文字稿:先判断任务,再选择工具,并说明安装顺序。', comments: [ { id: 'comment-1', text: '刚开始不知道先用哪个工具', likeCount: 20 }, ], }, }); expect(prompt).toContain('当前作品完整视频文字稿'); expect(prompt).toContain('刚开始不知道先用哪个工具'); expect(prompt).toContain('一次只分析当前 workId 对应的一个作品'); expect(prompt).toContain('评论只能作为辅助证据'); expect(prompt).not.toContain('其他作品完整视频文字稿'); }); }); describe('IpAccountWorkAnalysisService', () => { it('generates one LLM work report from one transcript-backed work', async () => { const llm = llmMock(JSON.stringify({ workId: 'work-1', reports: [{ kind: 'structure', headline: '先纠偏再给步骤', summary: '文字稿先指出新手误区,再给工具选择顺序。', evidenceSignals: ['文字稿出现安装误区', '收藏和评论都高'], strategyJudgment: '适合沉淀成新手工具选择栏目。', supportingEvidenceIds: ['work-1', 'comment-1'], confidence: 'high', gaps: [], }, { kind: 'operation', headline: '可迁移为答疑栏目', summary: '评论说明用户在开工顺序上有强需求。', evidenceSignals: ['评论问先用哪个', '分享收藏高'], strategyJudgment: '后续可以围绕真实项目阶段持续答疑。', supportingEvidenceIds: ['comment-1'], confidence: 'medium', gaps: ['需要验证转粉数据'], }], })); const service = new IpAccountWorkAnalysisService( llm as unknown as LlmService, new IpAccountWorkReportService(), ); const result = await service.analyzeWork({ account: accountFixture(), snapshot: snapshotFixture(), work: workFixture(), transcript: transcriptFixture(), }); expect(result.run.status).toBe('completed'); expect(result.run.failureReason).toBeUndefined(); expect(result.report?.sourceMode).toBe('llm'); expect(result.report?.workId).toBe('work-1'); expect(result.report?.reports).toHaveLength(2); expect(result.report?.reports[0].summary).toContain('文字稿先指出'); expect(result.run.diagnostics.map((item) => item.stage)).toEqual([ 'context_built', 'llm_request', 'llm_response', 'llm_parse', 'result_built', ]); const prompt = llm.askWithSystem.mock.calls[0][1] as string; expect(prompt).toContain(transcriptFixture().text); expect(prompt).toContain('刚开始不知道先用哪个工具'); }); it('records LLM failure and returns a local fallback report without pretending it is LLM analysis', async () => { const llm = { askWithSystem: vi.fn().mockReturnValue(throwError(() => new Error('Timeout has occurred'))), }; const service = new IpAccountWorkAnalysisService( llm as unknown as LlmService, new IpAccountWorkReportService(), ); const result = await service.analyzeWork({ account: accountFixture(), snapshot: snapshotFixture(), work: workFixture(), transcript: transcriptFixture(), }); expect(result.run.status).toBe('failed'); expect(result.run.failureReason).toContain('Timeout has occurred'); expect(result.report?.sourceMode).toBe('local_fallback'); expect(result.report?.reports[0].gaps).toContain('未经过 LLM 作品级分析'); expect(result.run.diagnostics.some((item) => item.stage === 'llm_fallback' && item.status === 'fallback')).toBe(true); }); it('fails before LLM request when transcript text is empty', async () => { const llm = llmMock('{}'); const service = new IpAccountWorkAnalysisService( llm as unknown as LlmService, new IpAccountWorkReportService(), ); const result = await service.analyzeWork({ account: accountFixture(), snapshot: snapshotFixture(), work: workFixture(), transcript: { ...transcriptFixture(), text: ' ' }, }); expect(result.run.status).toBe('failed'); expect(result.run.failureReason).toContain('缺少视频文字稿'); expect(result.report).toBeUndefined(); expect(llm.askWithSystem).not.toHaveBeenCalled(); }); }); function llmMock(raw: string) { return { askWithSystem: vi.fn().mockReturnValue(of(raw)), }; } function accountFixture(): IpMonitoredAccount { return { id: 'account-1', userId: 'user-1', platform: 'douyin', role: 'owned', displayName: 'FredTalk', accountId: '917997605', enabled: true, lastRefreshStatus: 'completed', createdAt: now(), updatedAt: now(), }; } function snapshotFixture(): IpAccountSnapshot { return { id: 'snapshot-1', accountId: 'account-1', dataMode: 'data_diagnosis', capturedAt: now(), warnings: [], evidenceItemIds: [], profile: { nickname: 'FredTalk', signature: '关注小白如何从 0 到 1 学习 AI 实操能力', followerCount: 1000, followingCount: 10, totalFavorited: 2000, awemeCount: 10, }, works: [workFixture(), { ...workFixture(), id: 'work-2', awemeId: 'aweme-2', title: '其他作品标题', transcript: { ...transcriptFixture(), text: '其他作品完整视频文字稿,不应该进入单作品 prompt。', }, }], }; } function workFixture(): IpAccountWorkSnapshot { return { id: 'work-1', accountId: 'account-1', awemeId: 'aweme-1', title: '新手小白安装这六个 Skills 就行够了', desc: '工具安装顺序和选择建议', publishTime: '2026-06-30T00:00:00.000Z', metrics: { playCount: 0, likeCount: 58850, commentCount: 1036, collectCount: 61773, shareCount: 9581 }, interactionScore: 120, isDeepSampled: true, structure: { hook: '新手安装清单', topic: '工具选择', style: '教程', cta: '收藏' }, comments: [ { id: 'comment-1', workId: 'work-1', text: '刚开始不知道先用哪个工具', likeCount: 20, capturedAt: '2026-06-30T01:00:00.000Z' }, { id: 'comment-2', workId: 'work-1', text: '安装了很多但是不知道流程', likeCount: 12, capturedAt: '2026-06-30T02:00:00.000Z' }, ], capturedAt: now(), }; } function transcriptFixture(): IpAccountWorkTranscriptSnapshot { return { text: '当前作品完整视频文字稿:先判断任务,再选择对应工具。视频逐步讲了安装顺序、常见误区和每个工具的使用边界。', source: 'asr', confidence: 'high', capturedAt: now(), }; } function now(): string { return '2026-07-01T00:00:00.000Z'; }