ip-operator-real-llm.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { expect, Page, test } from '@playwright/test';
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. declare global {
  5. interface Window {
  6. __seedIpOperatorScenario?: (scenario: 'empty') => unknown;
  7. }
  8. }
  9. const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
  10. const sampleRecordPath = path.resolve(
  11. __dirname,
  12. '../docs/项目概括/20-实施方案/10-IP操盘工作台V1/IP操盘工作台V1-真实LLM样例验收记录.md',
  13. );
  14. const debugPlanPath = path.resolve(__dirname, '../test-results/ip-operator-real-llm-plan.json');
  15. test.describe('IP操盘真实 LLM 样例验收', () => {
  16. test.skip(process.env.IP_OPERATOR_REAL_LLM !== '1', 'Set IP_OPERATOR_REAL_LLM=1 to run the paid/network real LLM validation.');
  17. test('can generate a real evaluable IP operator plan', async ({ page }) => {
  18. test.setTimeout(300_000);
  19. await seedEmptyLoggedInWorkbench(page);
  20. await page.goto('/');
  21. const profileCard = page.locator('.ip-card').first();
  22. await profileCard.locator('input').nth(0).fill('林川老板增长笔记');
  23. await profileCard.locator('input').nth(1).fill('传统行业增长顾问');
  24. await profileCard.locator('input').nth(2).fill('本地生活 / 家居建材 / 教育培训');
  25. await profileCard.locator('input').nth(3).fill('传统行业中小企业老板');
  26. await profileCard.locator('input').nth(4).fill('老板 IP 诊断与私域转化陪跑');
  27. await profileCard.locator('textarea').nth(0).fill('内容像广告、不知道如何表达专业判断、缺少真实案例支撑。');
  28. await profileCard.locator('textarea').nth(1).fill('服务过本地商家,做过短视频账号定位和私域转化。');
  29. await profileCard.locator('textarea').nth(2).fill('账号定位、选题策划、脚本诊断、私域转化路径设计。');
  30. await profileCard.locator('textarea').nth(3).fill('直接、务实、案例化。');
  31. await profileCard.locator('textarea').nth(4).fill('不夸大收益,不做成功学。');
  32. await profileCard.locator('textarea').nth(5).fill('生成可执行的 7 天内容测试方案。');
  33. await profileCard.locator('.btn-primary').click();
  34. const questionnaireCard = page.locator('.ip-card').first();
  35. const answers = [
  36. '老板想用内容建立信任,但担心拍出来像广告。',
  37. '我能把真实服务经验翻译成用户听得懂的判断。',
  38. '过去服务过本地门店和中小企业,有转化案例。',
  39. '目前通过咨询、诊断和陪跑服务承接。',
  40. '不想被误解成卖课或夸大结果的人。',
  41. '擅长直接讲问题、讲案例、给判断标准。',
  42. '对标本地商业顾问和老板 IP 账号。',
  43. '借鉴他们的问题切入、案例表达和评论区痛点捕捉。',
  44. '我的不同是更强调本地业务和私域转化。',
  45. '目前最缺稳定选题、脚本和拍摄节奏。',
  46. ];
  47. for (let index = 0; index < answers.length; index += 1) {
  48. await questionnaireCard.locator('textarea').nth(index).fill(answers[index]);
  49. }
  50. await questionnaireCard.locator('.btn-primary').click();
  51. const benchmarkCard = page.locator('.ip-card').first();
  52. await benchmarkCard.locator('input').nth(0).fill('商业小纸条');
  53. await benchmarkCard.locator('input').nth(1).fill('https://example.com/benchmark-account');
  54. await benchmarkCard.locator('input').nth(2).fill('https://www.douyin.com/video/7123456789012345678');
  55. await benchmarkCard.locator('textarea').nth(0).fill('问题切入直接,能让老板快速代入;希望 AI 判断爆款点如何迁移到本地老板 IP。');
  56. const optionalDetails = benchmarkCard.locator('.ip-benchmark-optional');
  57. await optionalDetails.locator('summary').click();
  58. await benchmarkCard.locator('input').nth(3).fill('为什么你的短视频越拍越像广告?');
  59. await benchmarkCard.locator('input').nth(4).fill('点赞 3.2 万,评论 1800+,收藏明显高于普通内容');
  60. await benchmarkCard.locator('textarea').nth(1).fill('开头直接戳中老板害怕广告感的痛点,中段用咨询案例拆解信任建立,结尾引导评论区自查。');
  61. await benchmarkCard.locator('textarea').nth(2).fill('评论区大量用户问“怎么表达才不像卖东西”“私域怎么接”,说明转化链路可以从诊断清单切入。');
  62. await benchmarkCard.locator('.btn-primary').click();
  63. const generateCard = page.locator('.ip-card').first();
  64. await expect(generateCard.locator('.btn-primary')).toBeEnabled();
  65. await generateCard.locator('.btn-primary').click();
  66. await expect(page.locator('.ip-result')).toBeVisible({ timeout: 240_000 });
  67. const plan = await page.evaluate(() => {
  68. const planIndex = JSON.parse(localStorage.getItem('tiktok.ipOperator.plans.local-guest') || '[]');
  69. const planId = planIndex[0];
  70. return JSON.parse(localStorage.getItem(`tiktok.ipOperator.plan.${planId}.local-guest`) || 'null');
  71. });
  72. writeDebugPlan(plan);
  73. expect(plan).toBeTruthy();
  74. expect(['ready', 'needs_input']).toContain(plan.status);
  75. expect(plan.diagnosis?.positioningStatement || '').not.toHaveLength(0);
  76. expect(plan.benchmarkAnalyses?.[0]?.borrowablePoints?.length || 0).toBeGreaterThanOrEqual(2);
  77. expect(plan.benchmarkAnalyses?.[0]?.nonCopyablePoints?.length || 0).toBeGreaterThanOrEqual(2);
  78. expect(plan.opportunityMap?.length || 0).toBeGreaterThanOrEqual(3);
  79. expect(plan.topics?.length || 0).toBeGreaterThanOrEqual(8);
  80. expect((plan.scripts || []).filter((script: any) => script.type === 'full').length).toBeGreaterThanOrEqual(3);
  81. expect(plan.sevenDayTestPlan?.length || 0).toBe(7);
  82. expect(plan.qualityCheck?.overallScore).toBeGreaterThanOrEqual(0);
  83. expect(plan.qualityCheck?.overallScore).toBeLessThanOrEqual(100);
  84. const topicsWithQuality = (plan.topics || []).filter((topic: any) => topic.qualityLevel && typeof topic.productionReadinessScore === 'number');
  85. const scriptsWithQuality = (plan.scripts || []).filter((script: any) => script.qualityLevel && script.scriptSummary);
  86. expect(topicsWithQuality.length).toBeGreaterThanOrEqual(8);
  87. expect(scriptsWithQuality.length).toBeGreaterThanOrEqual(3);
  88. expect(plan.qualityAudit?.overallScore).toBeGreaterThanOrEqual(0);
  89. expect(plan.qualityAudit?.overallScore).toBeLessThanOrEqual(100);
  90. expect(plan.qualityAudit?.dimensions?.length || 0).toBeGreaterThanOrEqual(6);
  91. expect(plan.qualityAudit?.productionGate?.reason || '').not.toHaveLength(0);
  92. writeRealLlmSampleRecord(plan);
  93. });
  94. });
  95. async function seedEmptyLoggedInWorkbench(page: Page) {
  96. await page.addInitScript({ path: scenarioSeedPath });
  97. await page.addInitScript(() => {
  98. window.__seedIpOperatorScenario?.('empty');
  99. localStorage.setItem('tiktok.currentTab', 'ip-operator');
  100. });
  101. }
  102. function writeDebugPlan(plan: any): void {
  103. fs.mkdirSync(path.dirname(debugPlanPath), { recursive: true });
  104. fs.writeFileSync(debugPlanPath, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
  105. }
  106. function writeRealLlmSampleRecord(plan: any): void {
  107. const fullScript = (plan.scripts || []).find((script: any) => script.type === 'full');
  108. const lines = [
  109. '# IP操盘工作台 V1 真实 LLM 样例验收记录',
  110. '',
  111. `验收时间:${new Date().toISOString()}`,
  112. '',
  113. '## 输入 IP 档案',
  114. '',
  115. '- IP 名称:林川老板增长笔记',
  116. '- 身份:传统行业增长顾问',
  117. '- 行业:本地生活 / 家居建材 / 教育培训',
  118. '- 目标用户:传统行业中小企业老板',
  119. '- 目标:生成可执行的 7 天内容测试方案',
  120. '',
  121. '## 输出摘要',
  122. '',
  123. `- 最终状态:${plan.status}`,
  124. `- 质量分:${plan.qualityCheck?.overallScore ?? '未返回'}/100`,
  125. `- P2A 质量审计分:${plan.qualityAudit?.overallScore ?? '未返回'}/100`,
  126. `- 选题质量分层数量:${(plan.topics || []).filter((topic: any) => topic.qualityLevel).length}`,
  127. `- 脚本质量分层数量:${(plan.scripts || []).filter((script: any) => script.qualityLevel).length}`,
  128. `- 制作门槛说明:${text(plan.qualityAudit?.productionGate?.reason)}`,
  129. `- 定位一句话:${text(plan.diagnosis?.positioningStatement)}`,
  130. `- 待补资料:${text((plan.missingInputs || []).map((item: any) => `${item.type}:${item.description}`))}`,
  131. '',
  132. '## 代表选题',
  133. '',
  134. ...(plan.topics || []).slice(0, 3).map((topic: any) => `- ${text(topic.title)}:${text(topic.fitReason)};质量 ${text(topic.qualityLevel)};可制作分 ${text(topic.productionReadinessScore)}`),
  135. '',
  136. '## 完整口播摘要',
  137. '',
  138. `- 标题:${text(fullScript?.title)}`,
  139. `- 脚本质量:${text(fullScript?.qualityLevel)}`,
  140. `- 脚本摘要:${text(fullScript?.scriptSummary)}`,
  141. `- 钩子:${text(fullScript?.hook)}`,
  142. `- 观点:${text(fullScript?.viewpoint)}`,
  143. '',
  144. '## 人工复核结论',
  145. '',
  146. '- 结论:自动结构验收通过;仍需人工复核内容是否符合具体客户语气、案例真实性和业务边界。',
  147. '',
  148. ];
  149. fs.mkdirSync(path.dirname(sampleRecordPath), { recursive: true });
  150. fs.writeFileSync(sampleRecordPath, `${lines.join('\n').trim()}\n`, 'utf8');
  151. }
  152. function text(value: unknown): string {
  153. if (value === null || value === undefined || value === '') return '待补充';
  154. if (Array.isArray(value)) return value.map(text).filter(Boolean).join(';') || '待补充';
  155. if (typeof value === 'object') return JSON.stringify(value);
  156. return String(value);
  157. }