ip-account-work-analysis.service.spec.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { of, throwError } from 'rxjs';
  2. import { describe, expect, it, vi } from 'vitest';
  3. import {
  4. IpAccountSnapshot,
  5. IpAccountWorkSnapshot,
  6. IpAccountWorkTranscriptSnapshot,
  7. IpMonitoredAccount,
  8. } from '../models/ip-operator.model';
  9. import { IpAccountWorkAnalysisService } from './ip-account-work-analysis.service';
  10. import { IpAccountWorkReportService } from './ip-account-work-report.service';
  11. import { buildAccountWorkAnalysisPrompt } from './ip-operator-prompts';
  12. import { LlmService } from './llm.service';
  13. describe('buildAccountWorkAnalysisPrompt', () => {
  14. it('uses transcript and comments for one work without including other works', () => {
  15. const prompt = buildAccountWorkAnalysisPrompt({
  16. accountName: 'FredTalk',
  17. accountIntentSummary: '账号定位摘要,不应包含第二个作品文字稿',
  18. work: {
  19. workId: 'work-1',
  20. awemeId: 'aweme-1',
  21. title: '新手小白安装这六个 Skills 就行够了',
  22. desc: '工具安装顺序和选择建议',
  23. metricsSummary: '点赞 58850 / 评论 1036',
  24. transcriptText: '当前作品完整视频文字稿:先判断任务,再选择工具,并说明安装顺序。',
  25. comments: [
  26. { id: 'comment-1', text: '刚开始不知道先用哪个工具', likeCount: 20 },
  27. ],
  28. },
  29. });
  30. expect(prompt).toContain('当前作品完整视频文字稿');
  31. expect(prompt).toContain('刚开始不知道先用哪个工具');
  32. expect(prompt).toContain('一次只分析当前 workId 对应的一个作品');
  33. expect(prompt).toContain('评论只能作为辅助证据');
  34. expect(prompt).not.toContain('其他作品完整视频文字稿');
  35. });
  36. });
  37. describe('IpAccountWorkAnalysisService', () => {
  38. it('generates one LLM work report from one transcript-backed work', async () => {
  39. const llm = llmMock(JSON.stringify({
  40. workId: 'work-1',
  41. reports: [{
  42. kind: 'structure',
  43. headline: '先纠偏再给步骤',
  44. summary: '文字稿先指出新手误区,再给工具选择顺序。',
  45. evidenceSignals: ['文字稿出现安装误区', '收藏和评论都高'],
  46. strategyJudgment: '适合沉淀成新手工具选择栏目。',
  47. supportingEvidenceIds: ['work-1', 'comment-1'],
  48. confidence: 'high',
  49. gaps: [],
  50. }, {
  51. kind: 'operation',
  52. headline: '可迁移为答疑栏目',
  53. summary: '评论说明用户在开工顺序上有强需求。',
  54. evidenceSignals: ['评论问先用哪个', '分享收藏高'],
  55. strategyJudgment: '后续可以围绕真实项目阶段持续答疑。',
  56. supportingEvidenceIds: ['comment-1'],
  57. confidence: 'medium',
  58. gaps: ['需要验证转粉数据'],
  59. }],
  60. }));
  61. const service = new IpAccountWorkAnalysisService(
  62. llm as unknown as LlmService,
  63. new IpAccountWorkReportService(),
  64. );
  65. const result = await service.analyzeWork({
  66. account: accountFixture(),
  67. snapshot: snapshotFixture(),
  68. work: workFixture(),
  69. transcript: transcriptFixture(),
  70. });
  71. expect(result.run.status).toBe('completed');
  72. expect(result.run.failureReason).toBeUndefined();
  73. expect(result.report?.sourceMode).toBe('llm');
  74. expect(result.report?.workId).toBe('work-1');
  75. expect(result.report?.reports).toHaveLength(2);
  76. expect(result.report?.reports[0].summary).toContain('文字稿先指出');
  77. expect(result.run.diagnostics.map((item) => item.stage)).toEqual([
  78. 'context_built',
  79. 'llm_request',
  80. 'llm_response',
  81. 'llm_parse',
  82. 'result_built',
  83. ]);
  84. const prompt = llm.askWithSystem.mock.calls[0][1] as string;
  85. expect(prompt).toContain(transcriptFixture().text);
  86. expect(prompt).toContain('刚开始不知道先用哪个工具');
  87. });
  88. it('records LLM failure and returns a local fallback report without pretending it is LLM analysis', async () => {
  89. const llm = {
  90. askWithSystem: vi.fn().mockReturnValue(throwError(() => new Error('Timeout has occurred'))),
  91. };
  92. const service = new IpAccountWorkAnalysisService(
  93. llm as unknown as LlmService,
  94. new IpAccountWorkReportService(),
  95. );
  96. const result = await service.analyzeWork({
  97. account: accountFixture(),
  98. snapshot: snapshotFixture(),
  99. work: workFixture(),
  100. transcript: transcriptFixture(),
  101. });
  102. expect(result.run.status).toBe('failed');
  103. expect(result.run.failureReason).toContain('Timeout has occurred');
  104. expect(result.report?.sourceMode).toBe('local_fallback');
  105. expect(result.report?.reports[0].gaps).toContain('未经过 LLM 作品级分析');
  106. expect(result.run.diagnostics.some((item) => item.stage === 'llm_fallback' && item.status === 'fallback')).toBe(true);
  107. });
  108. it('fails before LLM request when transcript text is empty', async () => {
  109. const llm = llmMock('{}');
  110. const service = new IpAccountWorkAnalysisService(
  111. llm as unknown as LlmService,
  112. new IpAccountWorkReportService(),
  113. );
  114. const result = await service.analyzeWork({
  115. account: accountFixture(),
  116. snapshot: snapshotFixture(),
  117. work: workFixture(),
  118. transcript: { ...transcriptFixture(), text: ' ' },
  119. });
  120. expect(result.run.status).toBe('failed');
  121. expect(result.run.failureReason).toContain('缺少视频文字稿');
  122. expect(result.report).toBeUndefined();
  123. expect(llm.askWithSystem).not.toHaveBeenCalled();
  124. });
  125. });
  126. function llmMock(raw: string) {
  127. return {
  128. askWithSystem: vi.fn().mockReturnValue(of(raw)),
  129. };
  130. }
  131. function accountFixture(): IpMonitoredAccount {
  132. return {
  133. id: 'account-1',
  134. userId: 'user-1',
  135. platform: 'douyin',
  136. role: 'owned',
  137. displayName: 'FredTalk',
  138. accountId: '917997605',
  139. enabled: true,
  140. lastRefreshStatus: 'completed',
  141. createdAt: now(),
  142. updatedAt: now(),
  143. };
  144. }
  145. function snapshotFixture(): IpAccountSnapshot {
  146. return {
  147. id: 'snapshot-1',
  148. accountId: 'account-1',
  149. dataMode: 'data_diagnosis',
  150. capturedAt: now(),
  151. warnings: [],
  152. evidenceItemIds: [],
  153. profile: {
  154. nickname: 'FredTalk',
  155. signature: '关注小白如何从 0 到 1 学习 AI 实操能力',
  156. followerCount: 1000,
  157. followingCount: 10,
  158. totalFavorited: 2000,
  159. awemeCount: 10,
  160. },
  161. works: [workFixture(), {
  162. ...workFixture(),
  163. id: 'work-2',
  164. awemeId: 'aweme-2',
  165. title: '其他作品标题',
  166. transcript: {
  167. ...transcriptFixture(),
  168. text: '其他作品完整视频文字稿,不应该进入单作品 prompt。',
  169. },
  170. }],
  171. };
  172. }
  173. function workFixture(): IpAccountWorkSnapshot {
  174. return {
  175. id: 'work-1',
  176. accountId: 'account-1',
  177. awemeId: 'aweme-1',
  178. title: '新手小白安装这六个 Skills 就行够了',
  179. desc: '工具安装顺序和选择建议',
  180. publishTime: '2026-06-30T00:00:00.000Z',
  181. metrics: { playCount: 0, likeCount: 58850, commentCount: 1036, collectCount: 61773, shareCount: 9581 },
  182. interactionScore: 120,
  183. isDeepSampled: true,
  184. structure: { hook: '新手安装清单', topic: '工具选择', style: '教程', cta: '收藏' },
  185. comments: [
  186. { id: 'comment-1', workId: 'work-1', text: '刚开始不知道先用哪个工具', likeCount: 20, capturedAt: '2026-06-30T01:00:00.000Z' },
  187. { id: 'comment-2', workId: 'work-1', text: '安装了很多但是不知道流程', likeCount: 12, capturedAt: '2026-06-30T02:00:00.000Z' },
  188. ],
  189. capturedAt: now(),
  190. };
  191. }
  192. function transcriptFixture(): IpAccountWorkTranscriptSnapshot {
  193. return {
  194. text: '当前作品完整视频文字稿:先判断任务,再选择对应工具。视频逐步讲了安装顺序、常见误区和每个工具的使用边界。',
  195. source: 'asr',
  196. confidence: 'high',
  197. capturedAt: now(),
  198. };
  199. }
  200. function now(): string {
  201. return '2026-07-01T00:00:00.000Z';
  202. }